ScrollArea
A box that scrolls on its own, with thin scrollbars in the theme's colours, optional fades at the edges that hide more, and a tab stop once it overflows.
- Hover the commits: a thin bar in the theme's colours fades in, and its thumb drags.
- Tab reaches each box because it scrolls; the arrow keys, Page Up, Page Down, Home and End scroll it.
- Long messages end in an ellipsis instead of widening the box; the fades show which edges hide more.
Field marks
- Import
import { ScrollArea } from "cennge-ui";- Size
- 1.7 KB gzip for this import, its dependencies aside
- Pattern
- Native scrolling under drawn scrollbars; a named
regiononce it overflows - Keyboard
- Arrow up Arrow down Arrow left Arrow right PageUpPageDownHomeEnd
- SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Touch
- No hover on touch screens, so the bars show while the finger scrolls
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: scroll-area
When to use#
Use a ScrollArea for a part of the page that scrolls on its own: a commit list in a panel, a log, a row of filters. It keeps the browser's own scrolling, with its momentum, wheel and touch, and draws the scrollbars in the theme's colours on every system.
Similar#
- VirtualList renders only the rows in view; use it for thousands of rows. A ScrollArea renders all of its content.
- Carousel snaps slides into place as they scroll sideways, with controls to step through them.
A list with long lines#
Vertical content keeps the width of the box, so truncate ends a long line with an ellipsis instead of making the box scroll sideways. fadeEdges fades the content out at each edge that still hides some of it; fadeSize sets how far the fade reaches.
import { ScrollArea } from "cennge-ui";
const commits = [
["881e770", "test(next): look from both sides of the server's time zone on every run"],
["4baadf3", "fix(playground): read the repositories' update dates in UTC"],
["28a4731", "test(map): set the example maps in New York"],
["055d5ef", "fix(kbd): draw the key symbols at the size of the cap"],
["f614e25", "fix(dialog): draw the backdrop beside the dialog, not around it"],
["9b1a5a4", "feat(charts): Sparkline"],
["cdb8e4e", "feat(charts): DonutChart"],
["5696189", "feat(charts): LineChart and AreaChart"],
["db78740", "feat(charts): BarChart, in a new entry cennge-ui/charts"],
["32b28a0", "feat(theme): ThemeScript, useTheme and ThemeSwitch"],
] as const;
export default function Commits() {
return (
<ScrollArea aria-label="Commits" fadeEdges className="h-56 w-full max-w-md rounded-large border border-border bg-surface-1">
<ol className="flex flex-col p-2">
{commits.map(([sha, message]) => (
<li key={sha} className="flex items-baseline gap-3 px-2 py-1.5 text-sm">
<code className="shrink-0 text-xs text-muted">{sha}</code>
<span className="truncate">{message}</span>
</li>
))}
</ol>
</ScrollArea>
);
}Both ways#
orientation="horizontal" scrolls a row sideways, as the repositories on the plate do; "both" lets wide and tall content scroll either way, and a corner joins the two bars. type says when the bars show: on hover (the default), while scrolling, whenever the content overflows (auto), or always.
import { ScrollArea } from "cennge-ui";
const log = `881e770d9d4fb09e61eae8ce6069b29b654aa65f 2026-11-01 test(next): look from both sides of the server's time zone on every run
4baadf3249a748daa07dbdedeb1f05da99e91eee 2026-11-01 fix(playground): read the repositories' update dates in UTC
c2d6d1bf9c74ae87b8fbcf76431c832756c3d3fb 2026-10-31 chore: release
20668ec40bb6cad661b45a49c1b610174fc0a207 2026-10-31 docs(changeset): release the README with the New York maps
28a47314cc0a7a3d4bb833b58c60af625be23a26 2026-10-31 test(map): set the example maps in New York
5b54e0607e0462cd8a45f372a8ec85f535d22ae9 2026-10-31 chore: release
055d5ef8c1f6617d6d81eb04e8816f79b509cfde 2026-10-31 fix(kbd): draw the key symbols at the size of the cap
29dd316ec890cb1d7532b19148a99d4f94a95569 2026-10-31 test(theme): run on React 17 and Testing Library 12 too
f614e2513f4b8ec77d3ef7cba02188ce0c0f9c96 2026-10-31 fix(dialog): draw the backdrop beside the dialog, not around it`;
export default function GitLog() {
return (
<ScrollArea aria-label="git log" orientation="both" type="auto" className="h-48 w-full rounded-large border border-border bg-surface-1">
<pre className="p-4 font-mono text-xs leading-6">{log}</pre>
</ScrollArea>
);
}Scroll from code#
viewportRef hands you the element that scrolls, for scrollTo, for measuring, or for a virtual list of your own.
import { useRef } from "react";
import { Button, ScrollArea } from "cennge-ui";
const releases = [
{ version: "0.3.1", date: "2026-11-01" },
{ version: "0.3.0", date: "2026-10-31" },
{ version: "0.2.0", date: "2026-10-21" },
{ version: "0.1.1", date: "2026-10-20" },
{ version: "0.1.0", date: "2026-10-19" },
];
export default function JumpToEnd() {
const viewport = useRef<HTMLDivElement>(null);
const scrollTo = (top: number) => viewport.current?.scrollTo({ top, behavior: "smooth" });
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<ScrollArea aria-label="Releases" viewportRef={viewport} type="always" className="h-40 rounded-large border border-border bg-surface-1">
<ol className="flex flex-col p-2">
{releases.map((release) => (
<li key={release.version} className="flex items-baseline justify-between gap-3 rounded-small px-3 py-2.5">
<span className="text-sm font-medium">cennge-ui {release.version}</span>
<time dateTime={release.date} className="text-xs text-muted tabular-nums">
{release.date}
</time>
</li>
))}
</ol>
</ScrollArea>
<div className="flex gap-2">
<Button size="sm" variant="flat" onClick={() => scrollTo(0)}>
Newest
</Button>
<Button size="sm" variant="flat" onClick={() => scrollTo(viewport.current?.scrollHeight ?? 0)}>
Oldest
</Button>
</div>
</div>
);
}Accessibility#
A box whose content fits is not a tab stop and has no role. Once the content overflows it takes focus from Tab, so keyboard users can scroll it, and with an aria-label it becomes a region that screen readers list. Name every ScrollArea that may overflow.
Styling#
classNames reaches the parts: base (the box), viewport (the element that scrolls), scrollbar and thumb. Give the box its height, border and background with className.
Props#
Also takes every attribute of <div>, except dir, which mean something else here.