Scroll Area
Themeable scroll container with consistent scrollbars across browsers and OSes.
Overview
ScrollArea is a Radix-backed wrapper that swaps the native scrollbar for a styled overlay you control via tokens. Use it when the default OS scrollbar would clash with the surface around it: a card with rounded corners, a dark theme on macOS where the native bar bleaches, a sidebar that needs the bar to stay flush with brand chrome.
For everyday overflow inside the body or a generic panel, native scroll is still the right answer. ScrollArea costs an extra wrapper and an event handler, so reach for it only when the default look-and-feel would break the surface.
Preview
'use client';import { ScrollArea } from '@gremorie/rx-containers';const TAGS = Array.from({ length: 40 }).map((_, i) => `Tag ${i + 1}`);export function ScrollAreaPreview() { return ( <ScrollArea className="h-48 w-full max-w-sm rounded-md border p-4"> <div className="flex flex-col gap-2"> {TAGS.map((t) => ( <div key={t} className="text-sm"> {t} </div> ))} </div> </ScrollArea> );}Anatomy
ScrollArea root: viewport + a default vertical ScrollBar + corner
└─ ScrollBar a styled scrollbar; mount one explicitly per axis you needInstallation
bash npx gremorie@latest add rx-scroll-area bash pnpm dlx gremorie@latest add rx-scroll-area
bash yarn dlx gremorie@latest add rx-scroll-area
bash bunx --bun gremorie@latest add rx-scroll-area
Usage
import { ScrollArea } from "@gremorie/rx-containers";
export function TagList({ tags }) {
return (
<ScrollArea className="h-72 w-48 rounded-md border">
<div className="p-4">
{tags.map((tag) => (
<div key={tag} className="py-2 text-sm">
{tag}
</div>
))}
</div>
</ScrollArea>
);
}npx gremorie@latest add ng-scroll-areaimport { Component } from '@angular/core';
import { ScrollArea } from '@gremorie/ng-containers';
@Component({
selector: 'app-example',
standalone: true,
imports: [ScrollArea],
template: `
<gr-scroll-area class="h-72 w-48 rounded-md border">
<div class="p-4">
@for (tag of tags; track tag) {
<div class="py-2 text-sm">{{ tag }}</div>
}
</div>
</gr-scroll-area>
`,
})
export class ExampleComponent {
readonly tags = ['v1.2.0-beta.50', 'v1.2.0-beta.49', 'v1.2.0-beta.48'];
}The Angular edition is a standalone gr-scroll-area component that implements its own overlay scrollbar natively (signals + ResizeObserver, with no external dependency) - the native bar is hidden and a --border-colored pill thumb floats over the content, taking no layout space and fading in on hover, matching the React edition's default type="hover" behaviour.
API
<ScrollArea>
The root and the most common entry point. Composes a Radix Root with a Viewport for the scrollable content, an auto-mounted vertical ScrollBar, and a Corner for the bottom-right intersection. Pass children directly - they end up inside the viewport.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Applied to the root. The root must declare a fixed height (and optionally a width) for scrolling to engage. |
type | "auto" | "always" | "scroll" | "hover" | "hover" | Visibility behaviour of the scrollbar. hover shows on hover, scroll shows during scroll then fades, always keeps it visible, auto matches the platform. |
scrollHideDelay | number | 600 | Milliseconds before the scrollbar fades when type="scroll" or type="hover". |
dir | "ltr" | "rtl" | inherited | Reading direction. Flips scrollbar placement when set to rtl. |
...props | React.ComponentProps<typeof ScrollAreaPrimitive.Root> | - | All Radix Root attributes. |
<ScrollBar>
The styled track plus thumb. The root mounts a vertical bar automatically. Mount a second ScrollBar with orientation="horizontal" whenever the content scrolls sideways.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "vertical" | "horizontal" | "vertical" | Which axis the bar controls. Mount one per axis. |
className | string | - | Applied to the scrollbar element. Touch-target sizing comes from the primitive; override carefully. |
...props | React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> | - | All Radix Scrollbar attributes. |
Composition
<ScrollArea>is the container. Give it a height (h-72,max-h-96, etc.) and an optional rounded border to match the surrounding card.- Children sit inside an automatic viewport that fills the root and rounds with
rounded-[inherit]so the scrolling content cannot bleed past the border. <ScrollBar orientation="horizontal" />is added as a sibling whenever the content scrolls horizontally.- The corner between two scrollbars is rendered automatically.
The root scopes focus styling: when the viewport receives keyboard focus, the focus ring uses focus-visible:ring-ring/50 so the scroll surface is itself a discoverable target.
Variations
Vertical scroll inside a card
<ScrollArea className="h-72 w-full rounded-md border p-4">
<div className="flex flex-col gap-2 text-sm">
{teams.map((team) => (
<div key={team.id}>{team.name}</div>
))}
</div>
</ScrollArea>The most common shape. Default type="hover" keeps the scrollbar quiet until the user reaches in.
Horizontal strip
<ScrollArea className="w-full whitespace-nowrap rounded-md border">
<div className="flex w-max gap-3 p-4">
{covers.map((cover) => (
<figure key={cover.id} className="shrink-0">
<img
src={cover.src}
alt={cover.alt}
className="h-32 w-48 rounded-md object-cover"
/>
</figure>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>For carousel-style strips. Mount the horizontal bar explicitly and wrap the row in w-max so it can overflow the container.
Always-visible scrollbar for code blocks
<ScrollArea type="always" className="h-72 w-full rounded-md border bg-muted">
<pre className="p-4 text-sm">{snippet}</pre>
</ScrollArea>Use type="always" when the absence of a scrollbar would imply the content fits, even when it does not - common with code blocks and long log output.
Accessibility
- Native semantics preserved: the viewport keeps
overflowscrolling, so keyboard scroll (PageUp/PageDown, Arrow keys, Home/End) works without extra wiring. - Focusable surface: the viewport sets
outline-noneplusfocus-visible:ring-[3px] focus-visible:ring-ring/50so users navigating byTabget a clear ring when the scroll region receives focus. - Pointer dragging: the thumb is draggable with the pointer. Touch users get native momentum scrolling on the viewport itself.
- Screen readers: Radix exposes the scrollbar with proper roles. Provide a meaningful
aria-labelon the root if the scroll surface needs an announcement of its own (e.g."Team list"). - Reduced motion: scroll behaviour respects
prefers-reduced-motionvia the underlying browser scroll, including smooth-scroll cues.