# Scroll Edge Effects, Not Hard Dividers

**SHOULD** · **ID:** `design-scroll-edge-effects` · **Category:** design
**Source:** [Emil Kowalski](https://emilkowalski.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/design-scroll-edge-effects

> SHOULD: Where floating chrome overlaps scrolling content, fade the seam with a short `mask-image` gradient instead of drawing a 1px divider under the sticky header — content should recede under the chrome, not be guillotined by a rule that claims it ends there. Apply the mask ONLY where floating UI actually overlaps content; a fade at an edge nothing floats over just eats readable text.

Where floating chrome overlaps scrolling content, fade the content out with a mask instead of drawing a 1px rule under the header

A hard border under a sticky header is a claim that the header is a boundary — that content ends there. It does not; content continues underneath, and the border cuts it. What the eye wants at that seam is evidence of depth: the content should recede under the chrome rather than being sliced by it. A short `mask-image: linear-gradient(...)` on the scroll container (or an equivalent gradient overlay) fades the last few pixels of content to transparent exactly where the floating chrome sits over it, so a scrolling line of text dissolves under the header instead of being guillotined by a rule. Note the last clause of the rule, which is the part that stops this becoming decoration: only where floating UI actually overlaps content. A mask at an edge nothing floats over is a gratuitous fade that eats readable text. Also worth reading against design-impeccable-hairline-plus-shadow: that principle bans a hairline and a soft shadow together and tells you to commit to one of them — but both of its options are still a drawn edge, and neither proposes the mask. This is the third answer it never offers, and at the one seam where the other two are both wrong.

## Rule snippet

```tsx
.scroller { mask-image: linear-gradient(to bottom, transparent 0, #000 48px); }
```

## Bad — do not do this

`design-scroll-edge-effects-bad`

```tsx
export function ScrollEdgeEffectsBad() {
  return (
    <div className="space-y-3">
      <div className="relative h-56 overflow-hidden rounded-lg border border-border bg-card">
        {/* A 1px rule announcing a boundary that isn't one: content carries on underneath it. */}
        <div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between border-b border-border bg-card px-4 py-3">
          <span className="text-sm font-semibold text-foreground">Changelog</span>
          <span className="text-xs text-muted-foreground">v4.2</span>
        </div>

        <div className="h-full overflow-y-auto px-4 pb-4 pt-14">
          {Array.from({ length: 10 }).map((_, i) => (
            <p key={i} className="mb-3 text-sm leading-relaxed text-foreground">
              <span className="font-medium">4.2.{10 - i}</span> — Scroll this list. Every line is sliced
              clean in half by the divider the instant it reaches the header.
            </p>
          ))}
        </div>
      </div>

      <p className="text-xs text-destructive">
        A hard rule under a sticky header is a claim that content <em>ends</em> there. It does not — it
        continues underneath, and the border guillotines it. Scroll, and each line is chopped at exactly
        the same pixel: there is no depth here, only a cut. The header reads as a lid welded onto the
        content rather than as chrome floating above it.
      </p>
    </div>
  );
}
```

## Good — do this

`design-scroll-edge-effects-good`

```tsx
export function ScrollEdgeEffectsGood() {
  return (
    <div className="space-y-3">
      <style>{`
        /* Fade the content out exactly where the floating header sits over it.
           Only the top edge: nothing floats over the bottom, so nothing fades there. */
        .see-scroll {
          --fade: 56px;
          mask-image: linear-gradient(
            to bottom,
            transparent 0,
            black var(--fade),
            black 100%
          );
          -webkit-mask-image: linear-gradient(
            to bottom,
            transparent 0,
            black var(--fade),
            black 100%
          );
        }
      `}</style>

      <div className="relative h-56 overflow-hidden rounded-lg border border-border bg-card">
        {/* Floating chrome: translucent, no divider. The seam is made of depth, not a line. */}
        <div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between bg-card/60 px-4 py-3 backdrop-blur-md">
          <span className="text-sm font-semibold text-foreground">Changelog</span>
          <span className="text-xs text-muted-foreground">v4.2</span>
        </div>

        <div className="see-scroll h-full overflow-y-auto px-4 pb-4 pt-14">
          {Array.from({ length: 10 }).map((_, i) => (
            <p key={i} className="mb-3 text-sm leading-relaxed text-foreground">
              <span className="font-medium">4.2.{10 - i}</span> — Scroll this list. Each line dissolves as
              it passes under the header instead of being cut by it.
            </p>
          ))}
        </div>
      </div>

      <p className="text-xs text-success">
        A <code>mask-image</code> gradient fades the last ~56px of the scroll container to transparent,
        precisely where the floating header overlaps it, so content recedes under the chrome rather than
        being sliced by it — and no divider is needed to sell the layering. Note the constraint that keeps
        this from becoming decoration: mask <strong>only where floating UI actually overlaps content</strong>.
        The bottom edge has nothing floating over it, so it gets no fade; a gratuitous one there would just
        eat readable text.
      </p>
    </div>
  );
}
```

## References

- [emilkowalski/skills — apple-design](https://github.com/emilkowalski/skills/tree/main/skills/apple-design)
- [MDN: mask-image](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/mask-image)
