Scroll Fades

A scroll container with a hard edge looks like a list that ends. A scroll fade dissolves the rows at the edge into the background, so the reader understands there is more to see without a scrollbar telling them.

Wrong
Right
  • Review pull request #482
  • Update onboarding copy
  • Fix focus ring on the date picker
  • Write release notes for 2.4
  • Reply to the design review thread
  • Migrate icons to the new set
  • Check contrast on the dark theme
  • Prepare the roadmap for Q4
  • Book the offsite venue
  • Archive the old marketing site
  • Review pull request #482
  • Update onboarding copy
  • Fix focus ring on the date picker
  • Write release notes for 2.4
  • Reply to the design review thread
  • Migrate icons to the new set
  • Check contrast on the dark theme
  • Prepare the roadmap for Q4
  • Book the offsite venue
  • Archive the old marketing site

The fade comes from mask-image with a linear gradient. A mask is not a layer on top. It sets how visible each part of the element is: where the gradient is opaque the content shows, where it is transparent the content disappears. Because it is a mask and not an overlay, it works over any background, including images and gradients, and it never blocks clicks.

Only fade the side that can scroll

The obvious version is a static mask on both ends. It has one flaw: it fades the first row when you are at the top, and the last row when you are at the bottom. At the bottom this is actively misleading. The final item looks half hidden, as if something is still below it.

Wrong
Right
  • Review pull request #482
  • Update onboarding copy
  • Fix focus ring on the date picker
  • Write release notes for 2.4
  • Reply to the design review thread
  • Migrate icons to the new set
  • Check contrast on the dark theme
  • Prepare the roadmap for Q4
  • Book the offsite venue
  • Archive the old marketing site
  • Review pull request #482
  • Update onboarding copy
  • Fix focus ring on the date picker
  • Write release notes for 2.4
  • Reply to the design review thread
  • Migrate icons to the new set
  • Check contrast on the dark theme
  • Prepare the roadmap for Q4
  • Book the offsite venue
  • Archive the old marketing site

The right list only fades the edge that still has content behind it. The trick is that the two fade sizes are custom properties, and a scroll-driven animation moves them. The top fade grows from 0 to its full size over the first 3rem of scrolling, the bottom fade shrinks back to 0 over the last 3rem.

Horizontal rows

The same idea applies sideways. A row of chips or tabs that overflows should fade at the edge that can still scroll, which also hints at the gesture.

Wrong
Right

I like to hide the scrollbar on rows like this. I know that we lose the indication of how much is left to scroll, but a horizontal bar under a row of chips is just too much visual noise.

Usage

app.tsx
<!-- scroll-fade-y and scroll-fade-x come from the CSS tab -->

<!-- Vertical -->
<div class="h-44 overflow-y-auto scroll-fade-y">
  ...
</div>

<!-- Horizontal, and hide the scrollbar -->
<div class="flex overflow-x-auto scroll-fade-x scrollbar-none">
  ...
</div>
/* The fade sizes. Registered so they can animate. */
@property --fade-start {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --fade-end {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@keyframes fade-start {
  from {
    --fade-start: 0px;
  }
  to {
    --fade-start: 2.5rem;
  }
}

@keyframes fade-end {
  from {
    --fade-end: 2.5rem;
  }
  to {
    --fade-end: 0px;
  }
}

.scroll-fade-y,
.scroll-fade-x {
  mask-image: linear-gradient(
    var(--fade-direction),
    transparent,
    black var(--fade-start),
    black calc(100% - var(--fade-end)),
    transparent
  );
}

.scroll-fade-y {
  --fade-direction: to bottom;
}

.scroll-fade-x {
  --fade-direction: to right;
}

/* Let the scroll position drive both fades. */
@supports (animation-timeline: scroll()) {
  .scroll-fade-y,
  .scroll-fade-x {
    animation:
      fade-start 1ms linear both,
      fade-end 1ms linear both;
    animation-range:
      0 3rem,
      calc(100% - 3rem) 100%;
  }

  /* After the shorthand, which resets the timeline. */
  .scroll-fade-y {
    animation-timeline: scroll(self y);
  }

  .scroll-fade-x {
    animation-timeline: scroll(self x);
  }
}

/* No scroll timelines: fade both ends all the time. */
@supports not (animation-timeline: scroll()) {
  .scroll-fade-y,
  .scroll-fade-x {
    --fade-start: 2.5rem;
    --fade-end: 2.5rem;
  }
}

One thing to know: the mask applies to the whole scroll container, scrollbar included, so a visible scrollbar fades out at both ends too. Either hide it, as above, or move the mask to an inner wrapper and let the outer element scroll.

Resources

Search concepts

Search for a command to run...