Skip to content
Domphy

Scroll Area

Apply the scrollArea patch to any block element to replace the default browser scrollbar with a thin, themed overlay scrollbar. Sets overflow: auto and styles ::-webkit-scrollbar pseudo-elements (Chrome/Safari/Edge) and scrollbar-width/scrollbar-color (Firefox).

Props

PropTypeDefaultDescription
colorValueOrState<ThemeColor>"neutral"Theme color for the scrollbar thumb.
labelstring—Accessible name for the scroll region. Setting it makes the host a tabbable role="region".

Example

import { scrollArea } from "@domphy/ui";

const List = {
  div: [...longContent],
  $: [scrollArea()],
  style: { maxHeight: "300px" },
};

Keyboard-scrollable regions

A region that scrolls but contains nothing focusable can only be scrolled with a pointer — a WCAG 2.1.1 failure, reported by axe as scrollable-region-focusable. A wide table, a code block or a diagram is exactly that case. Pass label and the host becomes a named, tabbable region (role="region" + aria-label + tabindex="0"), the wrapper pattern GOV.UK and shadcn use around overflowing tables:

import { scrollArea, table } from "@domphy/ui";

const Wide = {
  div: [{ table: rows, $: [table()] }],
  $: [scrollArea({ label: "Quarterly revenue" })],
};

The wrapper is what carries role="region", never the <table> itself: a role on the table would override its implicit table role and strip the row/column semantics screen readers rely on.

Leave label off when the content already holds focusable elements — a nav list, a menu, a form. Those are reachable with Tab already, and a region tab stop in front of them is one extra keystroke per list.

Customization

Must see the source of patch at the bottom of each patch page to understand the structure then code it still code as html native element.

There are four levels of customization, in increasing order of effort:

  1. Patch props. Each patch exposes a small, stable set of props—typically fewer than five. Lowest friction.
  2. Context attributes. Use dataTone, dataSize, and dataDensity on a container to shift tone, size, or density for an entire subtree without touching individual elements.
  3. Inline override. Native-wins merge strategy: any property set directly on the element overrides the patch value.
  4. Create a variant. Clone a similar patch and edit it. Use this only when you need a reusable custom version.
Formulas

Unit - U = fontSize / 4 - convert final values with themeSpacing(n).

Size - n = intrinsic text lines, w = wrapping level, d = density factor:

height        = (n * 6 + 2 * d * w) * U
paddingBlock  = d * w * U
paddingInline = ceil(3 / w) * d * w * U
radius        = d * w * U

Base density d = 1.5:

Uw=0w=1w=2w=3
height (n = 1)691215
paddingBlock01.534.5
paddingInline34.564.5
radius01.534.5

Tone - K = N / 2 where N is the palette length. For N = 18, K = 9.

RoleShiftn=0
Backgroundparent +/- n0
Textbg + K6
Borderbg + K/23
Hoverbg + 2K/34
Selected / Focusabove +/- K/32-4

State shift range: K/3 <= delta <= 2K/3.

import type { PartialElement, StyleObject } from "@domphy/core";
import { toState, type ValueOrState } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

/**
 * Applies thin, themed overlay scrollbars to any scrollable container.
 * Covers WebKit (Chrome/Safari/Edge) via `::-webkit-scrollbar` pseudo-elements
 * and Firefox via `scrollbar-width`/`scrollbar-color`. Sets `overflow: auto`.
 * No host-tag check; apply to any block element.
 *
 * Pass `label` when the scrollable content is NOT itself keyboard-reachable —
 * a wide table, a code block, a diagram. Such a region can only be scrolled
 * with a pointer, which fails WCAG 2.1.1 (axe `scrollable-region-focusable`),
 * so `label` makes the host a named, tabbable region: `role="region"` +
 * `aria-label` + `tabindex="0"`, the wrapper pattern GOV.UK and shadcn use
 * around overflowing tables. Leave it off when the content already holds
 * focusable elements (a nav list, a form) — those are reachable by Tab
 * already, and a region tab stop in front of them is one keystroke of noise
 * per list.
 *
 * @param props.color - Theme color for the scrollbar thumb. Accepts a value or
 *   reactive state. Defaults to `"neutral"`.
 * @param props.label - Accessible name for the scroll region. Setting it makes
 *   the host a tabbable `role="region"`. Optional; a host-declared
 *   `role`/`ariaLabel`/`tabIndex` still wins (native over patch).
 * @example { div: [...], style: { maxHeight: "300px" }, $: [scrollArea()] }
 * @example { div: [wideTable], $: [scrollArea({ label: "Quarterly revenue" })] }
 */
function scrollArea(
  props: { color?: ValueOrState<ThemeColor>; label?: string } = {},
): PartialElement {
  const color = toState(props.color ?? "neutral", "color");

  return {
    // `role="region"` with no accessible name is dropped from the
    // accessibility tree, and a tab stop with no name is a dead end — so the
    // three only ever ship together, or not at all.
    ...(props.label
      ? { role: "region", ariaLabel: props.label, tabIndex: 0 }
      : {}),
    style: {
      overflow: "auto",
      // Container text color so theme token usage also carries a reactive `color`.
      color: (l) => themeColor(l, "text", color.get(l)),
      "&::-webkit-scrollbar": {
        width: themeSpacing(2),
        height: themeSpacing(2),
      },
      "&::-webkit-scrollbar-track": {
        background: "transparent",
      },
      "&::-webkit-scrollbar-thumb": {
        backgroundColor: (l) => themeColor(l, "shift-5", color.get(l)),
        borderRadius: themeSpacing(999),
        // Transparent border creates visual padding around the thumb.
        border: `${themeSpacing(0.5)} solid transparent`,
        backgroundClip: "content-box",
      },
      "&::-webkit-scrollbar-thumb:hover": {
        backgroundColor: (l) => themeColor(l, "shift-7", color.get(l)),
      },
      // Firefox thin scrollbar with matching thumb/track colors.
      scrollbarWidth: "thin",
      scrollbarColor: (l) =>
        `${themeColor(l, "shift-5", color.get(l))} transparent`,
    } as StyleObject,
  };
}

export { scrollArea };