You've probably seen this one before. A list is cut off at the bottom, and a fade tells you there's more to scroll. It's a nice touch, but it's easy to get wrong.
The usual problem is the padding. Put it on the card, and the fade starts inside it, so the content looks chopped in the middle of the card instead of at its edge.
This uses tw-fade for the fade and the Base UI ScrollArea for the scrollbar. The padding idea works with any scroller, though.
The Fade
Put fade-y on the element that scrolls. Use fade-x for sideways.
<div className='max-h-80 overflow-y-auto fade-y'>{/* list */}</div>The fade only shows while there is more to scroll in that direction. At the end it goes away, so nobody has to add a hint in the copy.
Move the Padding Inside
Take the vertical padding off the card, and put it on the scroller itself.
<div className='rounded-lg border px-4'>
<div className='max-h-80 overflow-y-auto fade-y pt-5 pb-4'>{/* list */}</div>
</div>Now the fade starts flush with the edge of the card. And since the padding is part of the scrolled content, a short list that fits still looks padded, because nothing is scrolling and there's no fade.
Only move the padding on the side that fades. px-4 can stay on the card.
If the scroller has a max-h, add the padding you moved to it. Otherwise the visible area gets smaller than you planned.
Fix the Scrollbar
If you use a custom scrollbar, you'll notice a new problem. The padding is fake now, but the scrollbar track doesn't know that. It runs the whole height of the box, past the first and the last row, and touches the edges of the card. It looks off.
Give the scrollbar the same padding as the scroller.
function ScrollArea({ viewportClassName, scrollbarClassName, ...props }) {
return (
<ScrollAreaPrimitive.Root {...props}>
<ScrollAreaPrimitive.Viewport className={viewportClassName}>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar className={scrollbarClassName} />
</ScrollAreaPrimitive.Root>
);
}<ScrollArea
viewportClassName='max-h-80 fade-y pt-5 pb-4'
scrollbarClassName='pt-5 pb-4'
>
{/* list */}
</ScrollArea>Base UI counts the scrollbar's padding when it sizes the thumb, so the thumb still reaches both ends. It just starts at the first row and stops at the last, instead of touching the frame.
A Few Gotchas
- The fade is a mask, and a mask hides the outline of the box. Draw the focus ring on a wrapper with
has-[>:focus-visible]:outline-*, and setoutline-noneon the scroller. - A sticky column inside a sideways scroller needs its own clear zone as wide as the column, like
fade-clear-end-[28px]. - To stop a sideways scroller from rubber-banding on iOS, use
overscroll-x-none.overscroll-x-containstill bounces.
Prompt for Your Agent
Copy this when you want an agent to do it for you.
Add an edge fade to a scrolling box, and make the padding and the scrollbar line up with it.
Stack: Tailwind with tw-fade (`fade-x`, `fade-y`, `fade-clear-*`) and, if the box has a custom scrollbar, Base UI `ScrollArea`.
1. Put `fade-y` (or `fade-x` for sideways) on the element that scrolls. The fade only shows while there is more to scroll, so nobody needs a hint in the copy.
2. Start the fade flush with the container's edge. The fade is a mask on the scroller, so padding on the container pushes the fade inside it. Take the padding off the container on the faded side, and put it on the scroller (`pt-5 pb-4`). Keep padding on the sides that don't fade (`px-4`) on the container. Add the moved padding to the scroller's `max-h`.
3. Give the scrollbar the same padding as the scroller (`scrollbarClassName='pt-5 pb-4'`). Without it, the track runs past the first and last row and touches the frame. If `ScrollArea` has no `scrollbarClassName` prop, add one and forward it to `<ScrollBar className={scrollbarClassName} />`. Base UI counts the track's padding when it sizes the thumb, so the thumb still reaches both ends.
4. A mask hides the box's outline. Draw the focus ring on a wrapper (`has-[>:focus-visible]:outline-*`) and set `outline-none` on the scroller.
5. A sticky column in a sideways scroller needs `fade-clear-end-[<column width>]`. Sideways scrollers also take `overscroll-x-none`, because `overscroll-x-contain` still rubber-bands on iOS.
Check it in dark mode with a list long enough to scroll:
- At the top: no fade, and the first row sits padded from the frame.
- Mid-scroll: content fades at the frame's edge, with no padded dead band above the fade.
- At the end: the bottom fade is gone.
- A short list that fits: no fade, same padding as before.
- The scrollbar thumb starts at the first row and ends at the last, and doesn't touch the frame.
- Tab to the scroller: the focus ring still draws.