Scroll Area
pnpm dlx shadcn@latest add …/r/scroll-area.jsonAn accessible React scroll area for Next.js and Tailwind CSS: native scrolling with a slim scrollbar and soft fades at the edges, built on Radix.
- Vertical
- Horizontal
import { ScrollArea } from "@/components/ui/scroll-area"
export function Tags({ tags }: { tags: string[] }) {
return (
<ScrollArea aria-label="Tags" className="h-72 w-48 rounded-lg">
<div className="p-4">
{tags.map((tag) => (
<div key={tag} className="text-sm">
{tag}
</div>
))}
</div>
</ScrollArea>
)
}import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { FileTextIcon } from "lucide-react";
import { Fragment } from "react";
const tags = Array.from({ length: 40 }, (_, index) => `v1.2.0-beta.${40 - index}`);
const files = [
{ name: "Q3 report.pdf", size: "2.4 MB" },
{ name: "Roadmap.fig", size: "18 MB" },
{ name: "Invoices.csv", size: "84 KB" },
{ name: "Brand guide.pdf", size: "9.1 MB" },
{ name: "Logo.svg", size: "6 KB" },
{ name: "Launch notes.md", size: "3 KB" },
{ name: "Pitch.key", size: "42 MB" },
];
/*
* Both directions side by side, named underneath. Scroll either one and a soft fade appears at the edge you
* left; point at it and the scrollbar shows, thickening under the pointer.
*/
export default function ScrollAreaDirections() {
return (
<ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
<li className="flex flex-col items-center gap-2">
<ScrollArea aria-label="Tags" className="h-64 w-48 rounded-lg">
<div className="p-4">
<p className="mb-3 text-sm font-medium">Tags</p>
{tags.map((tag, index) => (
<Fragment key={tag}>
{index > 0 ? <div aria-hidden className="my-2 h-px bg-border" /> : null}
<p className="text-sm">{tag}</p>
</Fragment>
))}
</div>
</ScrollArea>
<span className="text-xs text-muted-foreground">Vertical</span>
</li>
<li className="flex flex-col items-center gap-2">
<ScrollArea aria-label="Recent files" className="w-72 rounded-lg">
<ul className="flex w-max gap-3 p-4">
{files.map((file) => (
<li key={file.name} className="flex w-32 flex-col gap-3 rounded-md bg-muted p-3">
<FileTextIcon aria-hidden className="size-(--size-icon-lg) text-muted-foreground" />
<span className="flex flex-col gap-0.5">
<span className="truncate text-sm font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">{file.size}</span>
</span>
</li>
))}
</ul>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<span className="text-xs text-muted-foreground">Horizontal</span>
</li>
</ul>
);
}Installation
Install the dependencies.
$ pnpm add radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Copy the source into your project.
"use client"; import { cn } from "@/lib/utils"; import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"; import { type ComponentProps, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; /* * Scroll Area (owner's roadmap, 2026-10-16: "Scrolling with soft fades at the edges"), on Radix Scroll Area, with * shadcn's parts: ScrollArea and ScrollBar. * - Native scrolling (wheel, touch, keyboard), with a slim scrollbar laid over the content in place of the * browser's. It fades in while the pointer is over the area and out after (Radix's "hover" type). * - The thumb is a thin line that thickens when you point at the bar or drag it, so it's easy to grab. It grows * from the area's edge with a scale (a transform), never a width change. * - Soft fades at the edges that can scroll, as in Select's list: one per side, shown only while there's more * that way. They're overlays outside the scroller (inside, they'd scroll with the content and add to its size) * and take the area's own background, so on a card or a popover give it that colour (`bg-card`, * `bg-popover`). Scroll-edge fades are a plain fade (ADR 0011), and the first paint shows them without fading. * - Named with aria-label or aria-labelledby, the scroller becomes a focusable region, so keyboard users can reach * it and scroll even when nothing inside can take focus. */ type Edges = { top: boolean; bottom: boolean; left: boolean; right: boolean }; /** Which sides have more to scroll to. In right-to-left, scrollLeft runs from 0 (the right end) to negative. */ function edgesOf(node: HTMLElement): Edges { const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = node; const wide = scrollWidth - clientWidth; const fromLeft = node.closest("[dir]")?.getAttribute("dir") === "rtl" ? wide + scrollLeft : scrollLeft; return { top: scrollTop > 1, bottom: scrollTop < scrollHeight - clientHeight - 1, left: fromLeft > 1, right: fromLeft < wide - 1, }; } const sameEdges = (a: Edges, b: Edges) => a.top === b.top && a.bottom === b.bottom && a.left === b.left && a.right === b.right; /** Each fade: where it lies, and which way it thins out (a static mask over the area's own colour) */ const fades = { top: "inset-x-0 top-0 h-8 mask-b-from-0%", bottom: "inset-x-0 bottom-0 h-8 mask-t-from-0%", left: "inset-y-0 left-0 w-8 mask-r-from-0%", right: "inset-y-0 right-0 w-8 mask-l-from-0%", } as const; export type ScrollAreaProps = ComponentProps<typeof ScrollAreaPrimitive.Root>; /** * A scrolling box with a slim scrollbar and soft fades at the edges that can scroll. Give it a height (or a width * for sideways scrolling, with a horizontal ScrollBar inside) and a name with aria-label. */ function ScrollArea({ className, children, "aria-label": label, "aria-labelledby": labelledBy, ...props }: ScrollAreaProps) { const viewport = useRef<HTMLDivElement>(null); const [edges, setEdges] = useState<Edges>({ top: false, bottom: false, left: false, right: false }); // The fades fade only once the first measure has shown them, so nothing animates on first paint const [settled, setSettled] = useState(false); const named = label !== undefined || labelledBy !== undefined; const update = useCallback(() => { const node = viewport.current; if (!node) return; const next = edgesOf(node); setEdges((current) => (sameEdges(current, next) ? current : next)); }, []); // Measure before the first paint, and again whenever the area or its content changes size useLayoutEffect(() => { const node = viewport.current; if (!node) return; update(); const observer = new ResizeObserver(update); observer.observe(node); if (node.firstElementChild) observer.observe(node.firstElementChild); return () => observer.disconnect(); }, [update]); useEffect(() => { const frame = requestAnimationFrame(() => setSettled(true)); return () => cancelAnimationFrame(frame); }, []); return ( <ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn("relative isolate overflow-hidden bg-background", className)} {...props} > <ScrollAreaPrimitive.Viewport ref={viewport} data-slot="scroll-area-viewport" onScroll={update} {...(named ? { role: "region", "aria-label": label, "aria-labelledby": labelledBy, tabIndex: 0 } : {})} // The focus ring sits inside the edge, where the area's rounded corners don't clip it className="size-full outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:-outline-offset-(--focus-ring-width)" style={{ borderRadius: "inherit" }} > {children} </ScrollAreaPrimitive.Viewport> {(Object.keys(fades) as (keyof Edges)[]).map((side) => ( <div key={side} aria-hidden data-slot="scroll-area-fade" data-side={side} data-state={edges[side] ? "visible" : "hidden"} className={cn( "pointer-events-none absolute bg-inherit data-[state=hidden]:opacity-0", settled && "transition-opacity duration-(--duration-fast) ease-out", fades[side], )} /> ))} <ScrollBar /> <ScrollAreaPrimitive.Corner data-slot="scroll-area-corner" /> </ScrollAreaPrimitive.Root> ); } /** * The scrollbar. ScrollArea has a vertical one; add `<ScrollBar orientation="horizontal" />` inside it to scroll * sideways. It stays mounted, so it can fade in and out. */ function ScrollBar({ className, orientation = "vertical", ...props }: ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) { const vertical = orientation === "vertical"; return ( <ScrollAreaPrimitive.ScrollAreaScrollbar data-slot="scroll-area-scrollbar" orientation={orientation} forceMount className={cn( // Above the fades, so its ends never fade with the content "group/scrollbar z-10 flex touch-none p-px select-none", "transition-opacity duration-(--duration-base) ease-out data-[state=hidden]:pointer-events-none data-[state=hidden]:opacity-0", vertical ? "h-full w-2.5" : "h-2.5 flex-col", className, )} {...props} > {/* Radix moves the thumb with a transform, so the line we see is its ::before, which scales on its own: half its width at rest, at the area's edge, and the full 8px while you point at the bar or drag it */} <ScrollAreaPrimitive.ScrollAreaThumb data-slot="scroll-area-thumb" className={cn( "relative flex-1 rounded-full", "before:absolute before:inset-0 before:rounded-full before:bg-border before:transition before:duration-(--duration-fast) before:ease-out", "group-hover/scrollbar:before:bg-muted-foreground/50 group-active/scrollbar:before:bg-muted-foreground/50", vertical ? "before:origin-right before:scale-x-50 group-hover/scrollbar:before:scale-x-100 group-active/scrollbar:before:scale-x-100 rtl:before:origin-left" : "before:origin-bottom before:scale-y-50 group-hover/scrollbar:before:scale-y-100 group-active/scrollbar:before:scale-y-100", )} /> </ScrollAreaPrimitive.ScrollAreaScrollbar> ); } export { ScrollArea, ScrollBar };Update the import paths to match your project.
API reference
ScrollArea
The scrolling box, with a vertical scrollbar. Give it a height (or a width to scroll sideways). Also accepts every Radix Scroll Area Root prop.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label? | string | – | Names the area. A named area is a focusable region, so keyboard users can reach it and scroll with the arrow keys. |
aria-labelledby? | string | – | Names the area after a visible heading's id, with the same effect as aria-label. |
type? | "hover" | "scroll" | "auto" | "always" | "hover" | When the scrollbar shows: while the pointer is over the area, while scrolling, whenever there's more to scroll, or always. |
scrollHideDelay? | number | 600 | Milliseconds before the scrollbar fades out, for the hover and scroll types. |
className? | string | – | The fades take the area's background, bg-background by default. On a card or a popover, add bg-card or bg-popover so they match. |
ScrollBar
A scrollbar. ScrollArea already has the vertical one; put a horizontal one inside it to scroll sideways. Also accepts every Radix Scroll Area Scrollbar prop.
| Prop | Type | Default | Description |
|---|---|---|---|
orientation? | "vertical" | "horizontal" | "vertical" | Which way it scrolls. |
Related components
Invoice Status Amount INV-1048 Paid $1,250.00 INV-1047 Pending $480.00 INV-1046 Overdue $2,900.00 An accessible React table for Next.js and Tailwind CSS: rows and columns with sortable headers, selectable rows and rows that glide when sorted.
An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.
- OMJLIN+3
An accessible React avatar for Next.js and Tailwind CSS: a picture or initials in three sizes, alone or in an overlapping group, built on Radix Avatar.