Display

Scroll Shadow

A scroll container whose edges dissolve wherever the content continues — all four sides judged independently, drawn as a mask on the scroller so nothing overlays the content.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

/**
 * Sub-pixel layout rounding routinely leaves a fraction of a pixel of overflow on content that
 * visibly fits; a zero-tolerance comparison turns that into a permanent fade on a box nobody can
 * scroll. One pixel of slack is the difference between a truthful affordance and decoration.
 */
const OVERFLOW_EPSILON = 1
const DEFAULT_SIZE = 40
const MAX_SIZE = 320

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/scroll-shadow.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ScrollShadow" component: a scroll container
that shows on every edge whether the content continues that way. No library, no
overlay element, no wrapper element — one div, and a mask on it.

Contract
- export interface ScrollShadowProps extends React.HTMLAttributes<HTMLDivElement>:
  orientation?: "vertical" | "horizontal" | "both" (default "vertical");
  size?: number (fade length per edge in px, default 40).
- forwardRef points at the scrolling element itself, never at a wrapper. Holding a
  ref to a scroll container is the whole reason to hold one (`ref.current.scrollTo`,
  `scrollTop = 0`, `scrollIntoView` on a child), and handing back a positioning div
  makes the consumer reach through it.
- The component renders exactly one element. className merges through cn(), style
  merges after the mask (so a consumer can override it), and every remaining prop —
  data-*, aria-*, onScroll, dir — spreads onto that same element.
- Constants, not props: OVERFLOW_EPSILON 1px, DEFAULT_SIZE 40, MAX_SIZE 320.

Behavior
- State is four independent booleans — top / bottom / left / right — each meaning
  "there is content past this edge", derived only from numbers the DOM already has:
    scrollableY = overflow allowed on Y && scrollHeight - clientHeight > 1px
    top    = scrollableY && scrollTop > 1px
    bottom = scrollableY && scrollTop < (scrollHeight - clientHeight) - 1px
  and the same shape on X. The 1px tolerance is not a fudge: scrollHeight is rounded
  to an integer, so sub-pixel layout rounding routinely reports one whole pixel of
  overflow on content that visibly fits. At zero tolerance those boxes fade forever
  and the cue stops meaning anything.
- `orientation` gates the axes as well as the CSS. "vertical" renders
  `overflow-y-auto overflow-x-hidden`, "horizontal" the mirror, "both"
  `overflow-auto`. A hidden axis still reports scrollWidth > clientWidth, so the
  gate is what stops the component advertising a direction the user cannot go.
- RTL: `scrollLeft` is 0 at the *right* edge and runs negative to the left, so
  compute "distance travelled" as |scrollLeft| and only then map start/end onto
  physical left/right through the computed `direction`. Sniffing `scrollLeft < 0`
  alone fails at the start position, where it is exactly 0.
- Three sources feed one measurement, because each is blind to what the others see:
    * a `scroll` listener, passive (the handler never preventDefaults);
    * a ResizeObserver on the element *and* on every direct child — the box got
      shorter, an image finished loading, text reflowed;
    * a MutationObserver on the subtree (childList + characterData) — rows appended
      below the fold resize nobody, so no ResizeObserver fires; its callback also
      hands newly added children to the ResizeObserver and drops removed ones, so
      the observed set stays in sync at one observe() call per appended row.
  Everything funnels through one requestAnimationFrame throttle (a fling produces
  dozens of scroll events per frame and only the last one matters), and the measure
  bails out before setState when the four booleans are unchanged. ResizeObserver
  fires once immediately on observe(), which doubles as the initial measurement.
- On unmount: remove the listener, disconnect both observers, cancel the pending
  frame. All four, or the component leaks a callback per mount.

Rendering & styling
- The cue is `mask-image` on the scroller: one linear-gradient per fading axis,
  `transparent 0 -> black min(size,50%)` at a start edge that has content behind it,
  `black calc(100% - min(size,50%)) -> transparent 100%` at an end edge that still
  has content ahead. Colors in a mask are read for alpha only, nothing there is ever
  painted — which is why this works over a card, a photo or a gradient without the
  component ever knowing the background color. `min(size, 50%)` keeps the two ramps
  of one axis from crossing and erasing the middle of a short box.
- Two axes fading at once compose with `mask-composite: intersect` (plus the legacy
  `-webkit-mask-composite: source-in`); without it the second layer paints the first
  one's transparent corners back in.
- When nothing scrolls, emit no mask at all — not a fully-opaque one. A box whose
  content fits keeps four hard edges, no stacking context and no compositing work.
- A mask, unlike an overlay, cannot intercept a pointer: the buttons under the fade
  stay clickable, and the cue adds no element, no overflow and no scrollbar-gutter
  width. Clamp `size` to 0…320 (0 turns the cue off); NaN survives every comparison,
  so special-case it back to the default rather than to "no cue".
- Semantic tokens only — the component paints no color of its own; border, radius,
  background, height and padding all come from the consumer's className through cn().
- Publish the state as `data-scroll-shadow="top bottom"` so CSS can hook it:
  `[data-scroll-shadow~="top"] .sticky-header { border-bottom-width: 1px }`.
- Accessibility: `tabIndex={0}` while the region scrolls and -1 when it does not, so
  keyboard users get arrow/Page/Home/End scrolling without a dead tab stop being left
  behind on a static box; pair it with `focus-visible:ring-2 ring-ring ring-inset`.
  Name the region from the outside with `role="region" aria-label="…"` — the props
  land on the scroller itself.
- Nothing animates: the mask is static CSS that changes with the scroll offset, so
  there is no transition to gate on `prefers-reduced-motion` and no motion to
  suppress. Do not add one — an interpolating gradient lags behind the scroll
  position and turns a truthful cue into a smear.

Customization levers
- size: 12–16px is a whisper for dense lists, 40px is the default, 96px+ a dramatic
  dissolve for hero rails and image carousels; 0 keeps the behaviour and the
  data-attribute while drawing nothing (useful when you want to style the edges
  yourself from `[data-scroll-shadow~="…"]`).
- orientation: "horizontal" for chip rows and card rails, "both" for wide tables.
- Container chrome lives entirely in className — `h-64 rounded-xl border bg-card p-4`,
  plus `overscroll-contain` to stop a nested panel scroll-chaining the page behind it
  or `scroll-smooth` if you want programmatic jumps animated.
- Want a painted shadow instead of a dissolve (content must stay fully opaque)? Wrap
  the scroller in a `relative` parent and render `pointer-events-none absolute inset-x-0
  h-10 bg-gradient-to-b from-background to-transparent` blocks driven by the same four
  booleans. It costs a wrapper, two extra nodes and knowledge of the background color —
  that trade is exactly why the mask is the default.
- Both the edge booleans and the DOM measurement are one small function; swapping the
  1px tolerance for a larger one (say 4px) makes the cue vanish slightly before the
  true end, which reads better on touch devices with rubber-band overscroll.

Concepts

  • Scrollability as an affordance — the four edges are not decoration but a read-out of scrollTop / scrollHeight / clientHeight: an edge is soft exactly while content lies past it, and goes hard the instant you arrive, so "there is more" never lies in either direction.
  • Alpha mask, not an overlay — the fade is a mask-image on the scroller itself, so it needs no extra element, adds no overflow, takes no scrollbar-gutter width, works over any background including images, and physically cannot swallow a click the way a covering div would.
  • Three observers, one measurement — a scroll listener sees position, a ResizeObserver sees the box and its children changing size, and a MutationObserver sees rows appended below the fold; drop any one and a real case goes dark (appending list items resizes nobody, and an image finishing its load mutates nothing).
  • Sub-pixel tolerancescrollHeight is an integer, so fractional layout rounding reports a whole pixel of overflow on content that visibly fits; the 1px threshold is what keeps a box that nobody can scroll from fading forever.
  • Composite intersect — when both axes fade, the two gradients are combined by intersection rather than addition, so a corner is the product of both ramps instead of one layer repainting the other's transparency.
  • Direction-agnostic travel — the horizontal edges are computed from |scrollLeft| and only mapped onto physical left/right at the end, so an RTL rail fades the side it actually came from.

On This Page