Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type ScrollAreaOrientation = "vertical" | "horizontal" | "both"

/**
 * `hover`     — bars fade in while the pointer is over the area or it holds focus.
 * `always`    — bars stay visible for as long as the content overflows.
 * `auto-hide` — bars appear while scrolling and fade out after a short idle.
 */
export type ScrollAreaScrollbar = "hover" | "always" | "auto-hide"

Installation

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

Prompt

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-auto element, so inertia, PageUp/PageDown, find-in-page and scrollIntoView are 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 / scrollHeight of 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_SIZE at its far end so the two thumbs can never collide in the corner.
  • Pointer-id isolation — the drag stores the pointerId it 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 triggeronScrollEnd fires 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-image gradient dissolves only the sides that still have scrolled-past content, so the fade is a truthful affordance rather than permanent decoration.

On This Page