component — layout

Scroll Area

Scrollable viewport with the quebi native scrollbar — a 6px ink pill hugging the edge, no stepper arrows and no padding around it — styled with the quebi design system. On the rare rounded surface (a floating surface, a stage frame) the bar is clipped to the surface's own rounded rect, so its ends curve away with the corner instead of running out through the arc. Supports vertical, horizontal, or both axes, four scrollbar variants (`flush`, `floating`, `none`, and `arrows` — steppers for a square-cornered surface), optional edge fading, and a reserved scrollbar gutter. `className` lands on the scroll container itself, so padding on a ScrollArea insets the content while the scrollbar still hugs the container's edge.

  • layout
  • scroll
  • overflow
  • container
  • viewport

07 examples

Vertical

A fixed-height container that scrolls vertically. The bar hugs the frame's edge with no padding around it. Padding on the ScrollArea insets the content, not the bar.

1 — quebi keeps long lists tidy inside a bounded viewport.

2 — quebi keeps long lists tidy inside a bounded viewport.

3 — quebi keeps long lists tidy inside a bounded viewport.

4 — quebi keeps long lists tidy inside a bounded viewport.

5 — quebi keeps long lists tidy inside a bounded viewport.

6 — quebi keeps long lists tidy inside a bounded viewport.

7 — quebi keeps long lists tidy inside a bounded viewport.

8 — quebi keeps long lists tidy inside a bounded viewport.

9 — quebi keeps long lists tidy inside a bounded viewport.

10 — quebi keeps long lists tidy inside a bounded viewport.

11 — quebi keeps long lists tidy inside a bounded viewport.

12 — quebi keeps long lists tidy inside a bounded viewport.

13 — quebi keeps long lists tidy inside a bounded viewport.

14 — quebi keeps long lists tidy inside a bounded viewport.

15 — quebi keeps long lists tidy inside a bounded viewport.

16 — quebi keeps long lists tidy inside a bounded viewport.

17 — quebi keeps long lists tidy inside a bounded viewport.

18 — quebi keeps long lists tidy inside a bounded viewport.

19 — quebi keeps long lists tidy inside a bounded viewport.

20 — quebi keeps long lists tidy inside a bounded viewport.

Horizontal

Set orientation to horizontal to scroll a row of items sideways.

design-systemsreact-ariatailwindaccessibilitytypescriptcomponentstokensthemingformsoverlaysnavigationfeedback

Scrollbar variants

flush is the default: a 6px pill against the edge, with no padding around it. floating puts that pill inside a 12px track, 3px clear of every edge — for content that scrolls under the bar, and for a bar a pointer user is expected to drag rather than wheel, where 12px of track is twice the grab width. none removes the bar for a surface whose scroll position something else already shows. arrows is floating with a stepper at each end, and it is the one variant that asks something of the surface: square corners. A stepper sits exactly where the corner clip cuts, so arrows drops that clip — which costs a square frame, the default here, nothing. Chromium and Safari draw the steppers; Firefox has none to give at any width and shows the pill alone.

1 — a bounded viewport.

2 — a bounded viewport.

3 — a bounded viewport.

4 — a bounded viewport.

5 — a bounded viewport.

6 — a bounded viewport.

7 — a bounded viewport.

8 — a bounded viewport.

9 — a bounded viewport.

10 — a bounded viewport.

11 — a bounded viewport.

12 — a bounded viewport.

13 — a bounded viewport.

14 — a bounded viewport.

15 — a bounded viewport.

16 — a bounded viewport.

flush — the default — hugs the edge

1 — a bounded viewport.

2 — a bounded viewport.

3 — a bounded viewport.

4 — a bounded viewport.

5 — a bounded viewport.

6 — a bounded viewport.

7 — a bounded viewport.

8 — a bounded viewport.

9 — a bounded viewport.

10 — a bounded viewport.

11 — a bounded viewport.

12 — a bounded viewport.

13 — a bounded viewport.

14 — a bounded viewport.

15 — a bounded viewport.

16 — a bounded viewport.

floating — 3px clear of every edge

1 — a bounded viewport.

2 — a bounded viewport.

3 — a bounded viewport.

4 — a bounded viewport.

5 — a bounded viewport.

6 — a bounded viewport.

7 — a bounded viewport.

8 — a bounded viewport.

9 — a bounded viewport.

10 — a bounded viewport.

11 — a bounded viewport.

12 — a bounded viewport.

13 — a bounded viewport.

14 — a bounded viewport.

15 — a bounded viewport.

16 — a bounded viewport.

none — no bar, still scrollable

1 — a bounded viewport.

2 — a bounded viewport.

3 — a bounded viewport.

4 — a bounded viewport.

5 — a bounded viewport.

6 — a bounded viewport.

7 — a bounded viewport.

8 — a bounded viewport.

9 — a bounded viewport.

10 — a bounded viewport.

11 — a bounded viewport.

12 — a bounded viewport.

13 — a bounded viewport.

14 — a bounded viewport.

15 — a bounded viewport.

16 — a bounded viewport.

arrows — steppers — wants square corners

The corners it follows, and the one it cannot

A scrollbar is painted inside the border box, and neither overflow nor border-radius clips it — so a flush bar would run out through the corner arc and square off against the top and bottom edges. Surfaces in quebi are square, so most of the time there is nothing to follow; where one is rounded — a small floating surface, the outer frame of an embedded stage — the ScrollArea clips itself to its own rounded rect, so the bar's ends curve away with the corner, and a horizontal bar meets the two bottom corners the same way. Past roughly a quarter of the surface's height the arc is longer than the straight run the bar travels along and eats most of it — a capsule-shaped scroll surface wants scrollbar="floating", or a smaller radius.

1 — scroll me.

2 — scroll me.

3 — scroll me.

4 — scroll me.

5 — scroll me.

6 — scroll me.

7 — scroll me.

8 — scroll me.

9 — scroll me.

10 — scroll me.

11 — scroll me.

12 — scroll me.

13 — scroll me.

14 — scroll me.

rounded-none — square — nothing to follow

1 — scroll me.

2 — scroll me.

3 — scroll me.

4 — scroll me.

5 — scroll me.

6 — scroll me.

7 — scroll me.

8 — scroll me.

9 — scroll me.

10 — scroll me.

11 — scroll me.

12 — scroll me.

13 — scroll me.

14 — scroll me.

rounded-quebi-s — 6px — a floating surface

1 — scroll me.

2 — scroll me.

3 — scroll me.

4 — scroll me.

5 — scroll me.

6 — scroll me.

7 — scroll me.

8 — scroll me.

9 — scroll me.

10 — scroll me.

11 — scroll me.

12 — scroll me.

13 — scroll me.

14 — scroll me.

rounded-quebi-l — 22px — a stage frame

1 — scroll me.

2 — scroll me.

3 — scroll me.

4 — scroll me.

5 — scroll me.

6 — scroll me.

7 — scroll me.

8 — scroll me.

9 — scroll me.

10 — scroll me.

11 — scroll me.

12 — scroll me.

13 — scroll me.

14 — scroll me.

rounded-full — too far — the arc eats the bar

Both axes

orientation defaults to both. The two bars share the bottom-right corner, and the square where they meet is transparent — so what shows through it is the surface's own corner rather than a notch cut out of it.

1 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

2 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

3 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

4 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

5 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

6 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

7 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

8 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

9 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

10 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

11 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

12 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

13 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

14 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

15 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

16 — a row that runs well past the right edge of this viewport, so the horizontal bar has a decent run of track to travel along.

Edge fade

Enable scrollFade to softly mask content at the scrolled edges.

1 — the fade hints there is more above and below.

2 — the fade hints there is more above and below.

3 — the fade hints there is more above and below.

4 — the fade hints there is more above and below.

5 — the fade hints there is more above and below.

6 — the fade hints there is more above and below.

7 — the fade hints there is more above and below.

8 — the fade hints there is more above and below.

9 — the fade hints there is more above and below.

10 — the fade hints there is more above and below.

11 — the fade hints there is more above and below.

12 — the fade hints there is more above and below.

13 — the fade hints there is more above and below.

14 — the fade hints there is more above and below.

15 — the fade hints there is more above and below.

16 — the fade hints there is more above and below.

17 — the fade hints there is more above and below.

18 — the fade hints there is more above and below.

19 — the fade hints there is more above and below.

20 — the fade hints there is more above and below.

Scrollbar gutter

scrollbarGutter sets scrollbar-gutter: stable, holding the bar's space whether or not it shows, so content never shifts. With a flush bar that reserved space is the bar's own 6px.

1 — the gutter keeps the right edge aligned.

2 — the gutter keeps the right edge aligned.

3 — the gutter keeps the right edge aligned.

4 — the gutter keeps the right edge aligned.

5 — the gutter keeps the right edge aligned.

6 — the gutter keeps the right edge aligned.

7 — the gutter keeps the right edge aligned.

8 — the gutter keeps the right edge aligned.

9 — the gutter keeps the right edge aligned.

10 — the gutter keeps the right edge aligned.

11 — the gutter keeps the right edge aligned.

12 — the gutter keeps the right edge aligned.

13 — the gutter keeps the right edge aligned.

14 — the gutter keeps the right edge aligned.

15 — the gutter keeps the right edge aligned.

16 — the gutter keeps the right edge aligned.

source

scroll-area.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { useCallback, useLayoutEffect, useRef } from "react"
import { cn } from "@/lib/utils"

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

/**
 * How the bar meets the surface.
 *
 * - `flush` — the default, and the quebi bar: a 6px ink pill hugging the edge,
 *   no padding around it, clipped to the surface's corner if it has one.
 * - `floating` — a 6px pill inside a 12px track, 3px clear of every edge. For
 *   content that scrolls *under* the bar, and for a bar a pointer user is
 *   expected to drag: 12px of track is twice the grab width.
 * - `none` — no bar, still scrollable. Only where something else already shows
 *   the scroll position.
 * - `arrows` — the floating geometry plus a stepper button at each end. The one
 *   variant that asks something of the surface: give it square corners. It
 *   drops the corner clip the others take, because a stepper sits exactly where
 *   that clip cuts (see **The corner** below).
 */
type ScrollAreaScrollbar = "flush" | "floating" | "none" | "arrows"

export interface ScrollAreaProps extends React.ComponentPropsWithRef<"div"> {
  scrollFade?: boolean
  scrollbarGutter?: boolean
  orientation?: ScrollAreaOrientation
  scrollbar?: ScrollAreaScrollbar
}

const SCROLLBAR_VARIANTS: Record<ScrollAreaScrollbar, string> = {
  // The base utility is already flush; the rest are variable overrides on top
  // of it, so each variant is one class and no cascade race. The corner clip
  // rides along here rather than being unconditional, because it is the one
  // thing `arrows` cannot have: the clip trims the bar to the surface's arc and
  // a stepper is what sits at the end being trimmed. Square corners are what
  // `arrows` wants anyway, and a square surface has no arc to follow — so
  // dropping it costs that variant nothing and is not a caveat the consumer has
  // to remember.
  flush: "quebi-scrollbar-corners",
  floating: "quebi-scrollbar-corners quebi-scrollbar-floating",
  none: "quebi-scrollbar-corners quebi-scrollbar-none",
  arrows: "quebi-scrollbar-arrows",
}

/**
 * ScrollArea — quebi design system
 *
 * A scrollable viewport with the quebi native scrollbar: a 6px ink pill
 * hugging the edge, no stepper arrows and no padding around it (`scrollbar`
 * picks a different bar, arrows included — see `ScrollAreaScrollbar`).
 * Optionally fades content
 * at the scrolled edges (`scrollFade`) and reserves the bar's gutter so content
 * doesn't shift when it appears (`scrollbarGutter`). Self-contained: no Radix,
 * no portals — just a native overflow container with overflow-state data
 * attributes wired up for masking.
 *
 * **The corner.** A scrollbar is painted inside the border box but `overflow`
 * and `border-radius` do not clip it, so on a rounded surface (app surfaces
 * take the theme's radius; editorial ones are square) the bar would
 * run out through the arc and square off against the top and bottom edges. The
 * viewport carries `quebi-scrollbar-corners`, whose `clip-path: border-box` is
 * the same rounded rect the border draws — so the bar's ends curve away with
 * the corner. It follows whatever radius the surface actually has, including
 * the `rounded-[inherit]` this element takes from its parent, which is why
 * there is no radius prop to keep in sync. Two consequences worth knowing:
 * the clip also trims anything painted *outside* the border box, so a shadow
 * or an `outline` ring on the ScrollArea itself is cut by it; and past roughly
 * `rounded-[calc(infinity*1px)]` the arc is longer than the bar's travel, so a
 * capsule-shaped scroll surface wants `scrollbar="floating"` instead.
 *
 * `scrollbar="arrows"` is the one variant without that clip. A stepper button
 * sits at the very end of the bar, which is the part the arc cuts away, so an
 * arrowed bar on a rounded surface loses a bite out of its arrow — and without
 * the clip it runs straight out through the corner, which is the failure the
 * clip exists to fix. Both disappear on a square-cornered surface, which is
 * what steppers have always squared off against, so that is the surface to give
 * it: `rounded-none`, or a panel whose corners are someone else's.
 *
 * `className` lands on the scroll container itself, which is the only place it
 * can land: the browser paints the scrollbar at the inner edge of the scroll
 * container's border box, so padding declared on a wrapper *around* it insets
 * the bar from the card edge and leaves a dead strip beyond it. Pad the
 * ScrollArea — `<ScrollArea className="p-4">` — and the padding reads as content
 * inset while the bar still hugs the edge. This matches every other scrollable
 * quebi surface (Menu, ListBox, the select popovers), which pad their overflow
 * container directly.
 */
export function ScrollArea({
  ref: forwardedRef,
  className,
  children,
  scrollFade = false,
  scrollbarGutter = false,
  orientation = "both",
  scrollbar = "flush",
  ...props
}: ScrollAreaProps) {
  const viewportRef = useRef<HTMLDivElement>(null)
  const rafRef = useRef<number | null>(null)
  const isThrottledRef = useRef(false)

  const allowY = orientation === "vertical" || orientation === "both"
  const allowX = orientation === "horizontal" || orientation === "both"

  // One element, two refs: the effect below needs the node and so does the
  // consumer, and a consumer's ref on a ScrollArea is only useful if it points
  // at the thing that scrolls.
  const setViewport = useCallback(
    (node: HTMLDivElement | null) => {
      viewportRef.current = node
      if (typeof forwardedRef === "function") forwardedRef(node)
      else if (forwardedRef) forwardedRef.current = node
    },
    [forwardedRef],
  )

  useLayoutEffect(() => {
    const el = viewportRef.current
    if (!el) return

    const update = () => {
      const rawHasY = el.scrollHeight > el.clientHeight + 1
      const rawHasX = el.scrollWidth > el.clientWidth + 1

      const hasY = allowY && rawHasY
      const hasX = allowX && rawHasX

      el.toggleAttribute("data-has-overflow-y", hasY)
      el.toggleAttribute("data-has-overflow-x", hasX)

      const yStart = hasY ? Math.max(0, el.scrollTop) : 0
      const yEnd = hasY ? Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop) : 0
      const xStart = hasX ? Math.max(0, el.scrollLeft) : 0
      const xEnd = hasX ? Math.max(0, el.scrollWidth - el.clientWidth - el.scrollLeft) : 0

      el.style.setProperty("--scroll-area-overflow-y-start", `${yStart}px`)
      el.style.setProperty("--scroll-area-overflow-y-end", `${yEnd}px`)
      el.style.setProperty("--scroll-area-overflow-x-start", `${xStart}px`)
      el.style.setProperty("--scroll-area-overflow-x-end", `${xEnd}px`)
    }

    const scheduleUpdate = () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current)
      rafRef.current = requestAnimationFrame(update)
    }

    const throttledScrollUpdate = () => {
      if (isThrottledRef.current) return
      isThrottledRef.current = true
      scheduleUpdate()
      setTimeout(() => {
        isThrottledRef.current = false
      }, 16)
    }

    const ro = new ResizeObserver(scheduleUpdate)
    ro.observe(el)

    el.addEventListener("scroll", throttledScrollUpdate, { passive: true })
    update()

    return () => {
      el.removeEventListener("scroll", throttledScrollUpdate)
      ro.disconnect()
      if (rafRef.current) cancelAnimationFrame(rafRef.current)
    }
  }, [allowX, allowY])

  return (
    <div
      ref={setViewport}
      className={cn(
        "size-full min-h-0 overscroll-auto rounded-[inherit] outline-none transition-shadow",
        "data-has-overflow-y:overscroll-y-contain data-has-overflow-x:overscroll-x-contain",
        // The shared quebi native scrollbar, clipped to this surface's own
        // corner unless the variant is the one that cannot be.
        "quebi-scrollbar",
        SCROLLBAR_VARIANTS[scrollbar],
        orientation === "vertical"
          ? "overflow-x-hidden overflow-y-auto"
          : orientation === "horizontal"
            ? "overflow-x-auto overflow-y-hidden"
            : "overflow-auto",
        scrollFade && [
          allowY &&
            "mask-t-from-[calc(100%-min(var(--fade-size,--spacing(6)),var(--scroll-area-overflow-y-start,0)))] mask-b-from-[calc(100%-min(var(--fade-size,--spacing(6)),var(--scroll-area-overflow-y-end,0)))]",
          allowX &&
            "mask-l-from-[calc(100%-min(var(--fade-size,--spacing(6)),var(--scroll-area-overflow-x-start,0)))] mask-r-from-[calc(100%-min(var(--fade-size,--spacing(6)),var(--scroll-area-overflow-x-end,0)))]",
        ],
        // The CSS property, not padding: padding is the consumer's to spend on
        // content inset, and a gutter added only once the bar is already there
        // reserves nothing. `stable` holds the space whether it shows or not.
        scrollbarGutter && "[scrollbar-gutter:stable]",
        className,
      )}
      data-slot="scroll-area-viewport"
      {...props}
    >
      {children}
    </div>
  )
}