crumpled paper texture

Edge Fade on Scrollable Lists

Fade the edge of a scrolling list so people know there is more, without the padding and the scrollbar getting in the way

––– views

5 min read

0 likes

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.

list.tsx
<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.

scroll-area.tsx
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 set outline-none on 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-contain still 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.