FairGarden Design

Scroll Area

A Base UI Scroll Area for bounded overflow: popup lists, dialog bodies, wide tables, carousels and removable-chip rows. Never use it for a whole page or a reading column.

import { ScrollArea } from '@fairgarden-private/design/components/ScrollArea'

Overflow is shown by a hard 2 px rule on each side that has hidden content, never by a fade. The scrollbar is a hairline track with a rule-colored thumb that widens on hover and darkens while dragged. The viewport is focusable whenever it scrolls; give it a label to make it a named region. Set focusable={false} when the content's own items are the focus stops and scroll themselves into view, as in a tab list.

Panel, wide and rail

Panel, wide and rail

panel is the default: it scrolls vertically, its scrollbar shows on hover, focus and scroll, and it caps at half the viewport height; maxBlockSize sets another cap, such as the space a popup's positioner reports. wide scrolls horizontally with the scrollbar always visible; the Table uses it. rail is the 2 px position rail for carousels, whose thumb equals the visible fraction; pair it with Prev and Next buttons and an "n of N" count. With fitContent={false} the content keeps the viewport's width and its children overflow it, so a carousel track's slide percentages resolve against the viewport.

Panel

Wide

Rail

ScrollAreaKinds.tsx
import { ScrollArea } from '@fairgarden-private/design/components/ScrollArea'
import styles from './kinds.module.css'

const species = [
  'American Goldfinch',
  'Barn Swallow',
  'Belted Kingfisher',
  'Black-capped Chickadee',
  'Cedar Waxwing',
  'Common Yellowthroat',
  'Eastern Bluebird',
  'Great Blue Heron',
  'Indigo Bunting',
  'Northern Cardinal',
  'Red-winged Blackbird',
  'Song Sparrow',
  'Tree Swallow',
  'Wood Thrush',
]

const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']

/** Panel (vertical), wide (horizontal) and rail (the 2 px position rail). */
export function ScrollAreaKinds() {
  return (
    <div className={styles.stack}>
      <section className={styles.example}>
        <h3 className={styles.name}>Panel</h3>
        <ScrollArea label="Species seen" className={styles.panel}>
          <ul className={styles.list}>
            {species.map((name) => (
              <li key={name}>{name}</li>
            ))}
          </ul>
        </ScrollArea>
      </section>
      <section className={styles.example}>
        <h3 className={styles.name}>Wide</h3>
        <ScrollArea kind="wide" label="Monthly visits">
          <ol className={styles.row}>
            {months.map((month) => (
              <li key={month} className={styles.cell}>
                {month}
              </li>
            ))}
          </ol>
        </ScrollArea>
      </section>
      <section className={styles.example}>
        <h3 className={styles.name}>Rail</h3>
        <ScrollArea kind="rail" label="Featured preserves">
          <ol className={styles.row}>
            {species.slice(0, 8).map((name) => (
              <li key={name} className={styles.slide}>
                {name}
              </li>
            ))}
          </ol>
        </ScrollArea>
      </section>
    </div>
  )
}

In print

Overflow is removed: the content prints at full height and width, and the scrollbars, rails and edge rules are hidden.

API Reference

A Base UI Scroll Area. Overflow is shown by a hard --border-size-2 --role-rule edge on each side with hidden content, never a fade [D21, D68], and by a hairline track with a --role-rule thumb. Content prints at full height with the scrollbars and edges hidden. Don’t nest two scroll areas on the same axis.

PropTypeDescription
label
string | undefined

Accessible name for the scrollable region. Given, the viewport becomes a named region; the viewport is focusable whenever it scrolls.

contentClassName
string | undefined

Class for the content wrapper, the layout box of the children.

fitContent
boolean | undefined

true (default): the content is at least as wide as its children, so wide content scrolls. false: the content keeps the viewport’s width and its children overflow it, so their percentages resolve against the viewport (a carousel track).

focusable
boolean | undefined

true (default): the viewport joins the tab order whenever it scrolls, so a keyboard can scroll it (§10.19). false keeps it out, for content whose own items are the focus stops and scroll themselves into view (a tab list).

kind
'panel' | 'wide' | 'rail' | null | undefined

panel (default): vertical; the scrollbar shows on hover, focus and scroll; the area caps at 50% of the viewport height (maxBlockSize changes the cap). wide: horizontal, the scrollbar always visible (wide tables). rail: horizontal, a 2 px progress rail whose thumb equals the visible fraction (carousels and removable-chip rows).

maxBlockSize
string | number | undefined

The area’s maximum block size, a CSS length such as calc(var(--available-height) - 2px) (a number is px). Default: 50dvh for panel, none for wide and rail.

primary
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'ruby'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| null
| undefined

Primary Radix scale: every part (track, thumb, edges, focus ring). Never defaulted; omitted, it inherits the scope [D133].

secondary
| 'olive'
| 'sage'
| 'slate'
| 'sand'
| 'gray'
| 'mauve'
| 'brown'
| 'bronze'
| 'gold'
| 'red'
| 'ruby'
| 'crimson'
| 'tomato'
| 'pink'
| 'plum'
| 'indigo'
| 'iris'
| 'violet'
| 'purple'
| 'amber'
| 'blue'
| 'cyan'
| 'grass'
| 'green'
| 'jade'
| 'lime'
| 'mint'
| 'orange'
| 'sky'
| 'teal'
| 'yellow'
| null
| undefined

Secondary Radix scale: unused by the scroll area. Never defaulted.

viewportRef
React.Ref<HTMLDivElement> | undefined

Ref to the scrolling viewport, e.g. to scroll it from Prev/Next buttons.

scrollArea
ScrollAreaProps

Props for ScrollArea: Base UI Scroll Area Root props plus the kind and color axes.

type ScrollAreaProps = ScrollAreaRootProps & {
  kind?: 'panel' | 'wide' | 'rail' | null;
  primary?:
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'ruby'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | null;
  secondary?:
    | 'olive'
    | 'sage'
    | 'slate'
    | 'sand'
    | 'gray'
    | 'mauve'
    | 'brown'
    | 'bronze'
    | 'gold'
    | 'red'
    | 'ruby'
    | 'crimson'
    | 'tomato'
    | 'pink'
    | 'plum'
    | 'indigo'
    | 'iris'
    | 'violet'
    | 'purple'
    | 'amber'
    | 'blue'
    | 'cyan'
    | 'grass'
    | 'green'
    | 'jade'
    | 'lime'
    | 'mint'
    | 'orange'
    | 'sky'
    | 'teal'
    | 'yellow'
    | null;
  label?: string;
  viewportRef?: React.Ref<HTMLDivElement>;
  contentClassName?: string;
  maxBlockSize?: string | number;
  focusable?: boolean;
  fitContent?: boolean;
}

Specification: DESIGN-SYSTEM.md §10.19 (scroll area).