Scroll Area
A themed scroll container that keeps native scrolling untouched — the platform bar is hidden and a draggable thumb is drawn from scrollTop/scrollHeight, with optional edge fades and an end-reached callback.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scroll-area.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ScrollArea" component with no scrolling
library and no wheel/touch interception — the browser keeps doing all the
scrolling, you only draw the bar.
Contract
- export interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement>:
children: ReactNode; orientation?: "vertical" | "horizontal" | "both"
(default "vertical"); scrollbar?: "hover" | "always" | "auto-hide" (default
"hover"); type?: same union (Radix-compatible alias, `scrollbar` wins when
both are set — resolve as `scrollbar ?? type ?? "hover"` so a plain default
parameter doesn't shadow the alias); fadeEdges?: boolean (default false);
onScrollEnd?: () => void; viewportClassName?: string.
- forwardRef to the positioning root; the rest of the props spread onto it.
`onScroll` is destructured out and re-attached to the viewport (scroll events
don't bubble, so a consumer handler left on the root would never fire).
- Constants, not props: BAR_SIZE 12 (track thickness = pointer hit area),
THUMB_SIZE 6, TRACK_PAD 2, THUMB_MIN 24, EDGE_FADE 28px, AUTO_HIDE_MS 700.
Behavior
- Structure: a `relative` root; inside it one viewport div with real
`overflow-*-auto` (`overflow-y-auto overflow-x-hidden` / `overflow-x-auto
overflow-y-hidden` / `overflow-auto` by orientation) whose native bars are
hidden with the Tailwind arbitrary utilities `[scrollbar-width:none]` and
`[&::-webkit-scrollbar]:hidden`; inside that a content wrapper
(`min-w-full`, plus `w-max` whenever horizontal scrolling is enabled so a
wide track sizes to its content); and the drawn bars as siblings of the
viewport (never children — children would scroll away with the content and
would also be clipped by the edge-fade mask).
- Never touch wheel, touch or key events, and never set `scroll-behavior:
smooth`. Keeping the viewport a plain scroll container is what preserves
trackpad inertia, PageUp/PageDown/Home/End, find-in-page, scrollIntoView,
scroll anchoring and whatever the user's own reduced-motion setting implies.
- Thumb geometry, per axis, from the three numbers the DOM already has:
trackLength = clientLength - 2*TRACK_PAD - (perpendicular bar visible ?
BAR_SIZE : 0) // give up the corner square so two thumbs can't overlap
thumbSize = clamp(clientLength / scrollLength * trackLength, THUMB_MIN,
trackLength)
travel = trackLength - thumbSize
thumbOffset = travel * (scrollOffset / (scrollLength - clientLength))
Render the thumb with `height`/`width` = thumbSize and
`transform: translateY|X(thumbOffset)`.
- An axis only counts as scrollable when scrollLength - clientLength > 1px:
sub-pixel layout rounding routinely leaves a fraction of overflow on content
that visibly fits, and a 0-tolerance test renders a phantom full-height thumb.
When an axis isn't scrollable, render no track and no thumb at all (not a
hidden one) — "content fits, DOM is clean" is a stated guarantee.
- Measurement loop: one ResizeObserver observes both the viewport (its own box
changed) and the content wrapper (rows appended, image loaded, text
reflowed); the viewport's onScroll handler drives the same recompute. Both go
through a requestAnimationFrame throttle (one pending frame at a time), so a
fling collapses into one measurement per frame. ResizeObserver calls back
once immediately on observe(), which doubles as the first measurement — that
is what keeps the initial size out of an effect body (no
setState-in-effect). Cancel the pending frame, clear the timer and
disconnect the observer on unmount.
- Only commit metrics that actually changed: compare the new per-axis record
against the last one (booleans exactly, numbers within 0.5px) and skip the
setState otherwise. Because `children` is a prop, re-rendering ScrollArea
never re-renders the consumer's subtree — React bails out on the identical
element — so the per-frame render is just a couple of wrapper divs.
- Thumb drag: pointerdown on the thumb stops propagation (so the track's paging
handler doesn't also fire), preventDefault (no text selection),
setPointerCapture(e.pointerId) and stores {pointerId, axis, origin
clientX/Y, startScroll, travel, maxScroll} in a ref. pointermove ignores any
event whose pointerId differs from the stored one — a second finger must not
hijack a captured drag — and otherwise writes
scrollTop/scrollLeft = startScroll + (delta / travel) * maxScroll. Writing
the scroll offset is the only thing the drag does; the native scroll event it
triggers is what re-draws the thumb, so there is no second source of truth.
pointerup/pointercancel with the matching id clear the ref and release the
capture. A pointerdown while a drag is already in flight is ignored.
- Track click pages: pressing the empty track before the thumb scrolls by
-clientLength, after the thumb by +clientLength, via scrollBy() with no
`behavior` override so the consumer's/user's scroll-behavior decides whether
that jump animates. Pressing the sliver of track under the thumb is a no-op.
- Scrollbar visibility: "always" = shown whenever the axis overflows; "hover" =
pure CSS, `opacity-0 pointer-events-none` flipped by
`group-hover/…` and `group-focus-within/…` on the root (so keyboard focus
reveals it too); "auto-hide" = state driven, shown while scrolling and hidden
by a 700ms idle timer restarted on every scroll event. In every mode an
in-flight drag forces the bar visible — with pointer capture the pointer can
leave the root, and losing group-hover mid-drag would make the thumb vanish
under the user's finger. Hidden bars are `pointer-events-none` so an
invisible track can't swallow clicks on content near the edge.
- fadeEdges: a mask-image on the viewport, one linear-gradient per scrolling
axis, with stops `transparent 0 → black EDGE_FADE` at the start side and
`black calc(100% - EDGE_FADE) → transparent 100%` at the end side. Each side
is included only when that side actually has scrolled-past content
(scrollOffset > 1px / scrollOffset < max - 1px), so a list at the very top
has a hard top edge and only fades at the bottom. Two axes fading at once
compose with `mask-composite: intersect` (+ `-webkit-mask-composite:
source-in`). The color keywords in a mask are alpha only — nothing there is
ever painted.
- onScrollEnd fires once per arrival at the end of the primary axis (vertical
for "vertical"/"both", horizontal for "horizontal"), armed by a ref that only
re-arms once the offset moves back out of a 2px end zone — appending a page
of content moves the end away and re-arms it automatically. It never fires
when the axis doesn't overflow. Reach it through a latest-ref updated on every
render, never through a dependency array: the natural call site is an inline
arrow, so a dependency would re-subscribe every render.
Rendering & styling
- Semantic tokens only: thumb `bg-foreground/25`, `hover:bg-foreground/45` and
the same 45% while dragging; the track itself paints nothing (it is a hit
area). Container chrome (border, bg-card, rounded, height) belongs to the
consumer's className; the viewport inherits it via `rounded-[inherit]`, plus
`max-h-[inherit] max-w-[inherit]` so a root sized with `max-h-*` still
bounds the scroller.
- cn() merges className onto the root and viewportClassName onto the viewport
(that is where padding, scroll-padding and overscroll-behavior belong —
padding on the root would sit outside the scrolling box).
- Motion: only the bar's opacity/color transitions animate, and both carry
`motion-reduce:transition-none` — under reduced motion the bar snaps between
states and every scrolling feature still works.
- Accessibility: the viewport carries the scroll semantics — `tabIndex={0}`
when either axis overflows (and -1 when nothing scrolls, so a static box
doesn't add a dead tab stop) plus `focus-visible:ring-2 ring-ring
ring-inset`, which is what gives keyboard users arrow/Page/Home/End
scrolling for free. The drawn bars are decoration: `aria-hidden`, not
focusable, and deliberately NOT `role="slider"` — a scroll container already
exposes its position to assistive tech, and a second slider control would
announce a duplicate, meaningless value. To name the region, put
`role="region" aria-label="…"` on the component (spread props land on the
root, which contains the focusable viewport).
Customization levers
- Bar dimensions: BAR_SIZE / THUMB_SIZE / TRACK_PAD are the whole visual
weight of the bar — 12/6/2 reads as a modern overlay bar; 16/10/3 for a
chunkier desktop feel; THUMB_MIN guards grabbability in very long content.
- Bar skin: swap `bg-foreground/25` for `bg-primary/40` to tint it with the
brand, or give the track a visible groove (`bg-muted rounded-full`) for a
classic inset scrollbar instead of an overlay one.
- EDGE_FADE: 16–24px for a subtle hint, 48–64px for a strong "there is more"
dissolve on hero rails; pair fadeEdges with orientation="horizontal" for
card carousels.
- AUTO_HIDE_MS: 400ms feels eager, 1500ms keeps the bar around long enough to
grab after a fling.
- Padding/scroll behavior: put `p-*`, `scroll-p-*`, `scroll-smooth` or
`overscroll-contain` on viewportClassName — e.g. `overscroll-contain` stops
a nested panel from scroll-chaining the page behind it.
- onScrollEnd threshold: the built-in trigger is the actual end. For a
prefetch-before-the-edge feel, compare `scrollHeight - scrollTop -
clientHeight` against your own threshold inside an `onScroll` handler
instead.Concepts
- Native scroll, drawn bar — the viewport stays an ordinary
overflow-autoelement, so inertia, PageUp/PageDown, find-in-page andscrollIntoVieware the browser's, not ours; the only custom part is a decorative thumb positioned from the scroll offsets. - Thumb geometry — thumb length is the visible fraction of the content (
clientHeight / scrollHeightof the track) and its offset is scroll progress projected onto the leftover travel; nothing about the content is measured directly. - Corner reservation — with both axes scrolling, each track gives up
BAR_SIZEat its far end so the two thumbs can never collide in the corner. - Pointer-id isolation — the drag stores the
pointerIdit captured and drops every move/up event carrying a different one, so a second finger can't hijack a gesture already in flight. - Observe both boxes — one ResizeObserver watches the viewport and the content wrapper, so "the panel got shorter" and "20 rows were appended" both recompute the thumb; its immediate first callback is also the initial measurement.
- Re-arming end trigger —
onScrollEndfires once on arrival at the end and only re-arms after the end moves away, which appending the next page does by itself — one call per page, no timers, no debounce. - Edge fade as alpha mask — a
mask-imagegradient dissolves only the sides that still have scrolled-past content, so the fade is a truthful affordance rather than permanent decoration.