Layout

Scroll Area

Scrollable viewport with a slim, cyan-tinted scrollbar, styled with the quebi design system. Supports vertical, horizontal, or both axes, optional edge fading, and a reserved scrollbar gutter.

layoutscrolloverflowcontainerviewport

Vertical

A fixed-height container that scrolls vertically with a slim cyan scrollbar.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Row 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

Edge fade

Enable scrollFade to softly mask content at the scrolled edges.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Scrollbar gutter

scrollbarGutter reserves space so content doesn't shift when the bar appears.

Line 1 — the gutter keeps the right edge aligned.

Line 2 — the gutter keeps the right edge aligned.

Line 3 — the gutter keeps the right edge aligned.

Line 4 — the gutter keeps the right edge aligned.

Line 5 — the gutter keeps the right edge aligned.

Line 6 — the gutter keeps the right edge aligned.

Line 7 — the gutter keeps the right edge aligned.

Line 8 — the gutter keeps the right edge aligned.

Line 9 — the gutter keeps the right edge aligned.

Line 10 — the gutter keeps the right edge aligned.

Line 11 — the gutter keeps the right edge aligned.

Line 12 — the gutter keeps the right edge aligned.

Line 13 — the gutter keeps the right edge aligned.

Line 14 — the gutter keeps the right edge aligned.

Line 15 — the gutter keeps the right edge aligned.

Line 16 — the gutter keeps the right edge aligned.

Source

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

"use client"

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

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

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

/**
 * ScrollArea — quebi design system
 *
 * A scrollable viewport with a slim, cyan-tinted scrollbar (thumb cyan-500/20).
 * Optionally fades content at the scrolled edges (`scrollFade`) and reserves a
 * gutter so content doesn't shift when the scrollbar appears (`scrollbarGutter`).
 * Self-contained: no Radix, no portals — just a native overflow container with
 * overflow-state data attributes wired up for masking.
 */
export function ScrollArea({
  ref: forwardedRef,
  className,
  children,
  scrollFade = false,
  scrollbarGutter = false,
  orientation = "both",
  ...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"

  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={forwardedRef} className={cn("size-full min-h-0", className)} {...props}>
      <div
        ref={viewportRef}
        className={cn(
          "h-full overscroll-auto rounded-[inherit] outline-none transition-shadow",
          "data-has-overflow-y:overscroll-y-contain data-has-overflow-x:overscroll-x-contain",
          // Slim, cyan-tinted scrollbar — quebi tokens.
          "[scrollbar-width:thin] [scrollbar-color:color-mix(in_oklab,var(--color-cyan-500)_20%,transparent)_transparent]",
          "[&::-webkit-scrollbar]:size-2",
          "[&::-webkit-scrollbar-track]:bg-transparent",
          "[&::-webkit-scrollbar-thumb]:rounded-quebi-sm [&::-webkit-scrollbar-thumb]:bg-cyan-500/20",
          "[&::-webkit-scrollbar-thumb:hover]:bg-cyan-500/30",
          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)))]",
          ],
          scrollbarGutter && [
            allowY && "data-has-overflow-y:pe-2.5",
            allowX && "data-has-overflow-x:pb-2.5",
          ],
        )}
        data-slot="scroll-area-viewport"
      >
        {children}
      </div>
    </div>
  )
}