Shadcn Scroll Area
Free shadcn/ui scroll area for React — a custom scrollbar built on Radix that looks consistent across browsers: a vertical list, a horizontal row, a notification feed, and long text.
Installation
{
"registries": {
"@designrevision": "https://registry.designrevision.com/r/{name}.json"
}
}
Add to your existing components.json. Requires shadcn/ui v2.3+.
Packages
Props
No props documented yet.
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
Examples
Basic
The canonical vertical scroll area — a tag list with a styled scrollbar.
Packages
Props
No props documented yet.
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
const tags = Array.from({ length: 40 }).map(
(_, i, a) => `v1.2.0-beta.${a.length - i}`
)
export default function ScrollAreaDemo01() {
return (
<ScrollArea className="h-72 w-48 rounded-md border">
<div className="p-4">
<h4 className="mb-4 text-sm leading-none font-medium">Tags</h4>
{tags.map((tag) => (
<div key={tag}>
<div className="text-sm">{tag}</div>
<Separator className="my-2" />
</div>
))}
</div>
</ScrollArea>
)
}
Horizontal
A horizontally-scrolling row of cards with a horizontal scrollbar.
Packages
Props
No props documented yet.
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
const works = [
{ id: 1, artist: "Ornella Binni", gradient: "from-rose-400 to-orange-300" },
{ id: 2, artist: "Tom Byrom", gradient: "from-sky-400 to-indigo-300" },
{ id: 3, artist: "Vladimir Malyavko", gradient: "from-emerald-400 to-teal-300" },
{ id: 4, artist: "Maya Reyes", gradient: "from-fuchsia-400 to-pink-300" },
{ id: 5, artist: "Leo Park", gradient: "from-amber-400 to-yellow-300" },
{ id: 6, artist: "Nia Cole", gradient: "from-violet-400 to-purple-300" },
]
export default function ScrollAreaHorizontal01() {
return (
<ScrollArea className="w-full max-w-md rounded-md border whitespace-nowrap">
<div className="flex w-max gap-4 p-4">
{works.map((work) => (
<figure key={work.id} className="shrink-0">
<div
className={`h-40 w-32 rounded-md bg-gradient-to-b ${work.gradient}`}
/>
<figcaption className="text-muted-foreground pt-2 text-xs">
Photo by{" "}
<span className="text-foreground font-medium">
{work.artist}
</span>
</figcaption>
</figure>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}
Notification list
A scrollable activity feed — the notification-panel pattern.
Packages
Props
No props documented yet.
import { ScrollArea } from "@/components/ui/scroll-area"
const notifications = Array.from({ length: 12 }).map((_, i) => ({
id: i,
title: [
"Your call has been confirmed.",
"You have a new message.",
"Your subscription is expiring soon.",
"A new device signed in.",
][i % 4],
time: `${(i + 1) * 5} min ago`,
}))
export default function ScrollAreaNotifications01() {
return (
<ScrollArea className="h-72 w-full max-w-sm rounded-md border">
<div className="p-2">
{notifications.map((n) => (
<div
key={n.id}
className="hover:bg-accent flex items-start gap-3 rounded-md p-2"
>
<span className="bg-primary mt-1.5 size-2 shrink-0 rounded-full" />
<div className="space-y-0.5">
<p className="text-sm leading-none font-medium">{n.title}</p>
<p className="text-muted-foreground text-xs">{n.time}</p>
</div>
</div>
))}
</div>
</ScrollArea>
)
}
Long text
A fixed-height scroll area for long prose like terms of service.
Packages
Props
No props documented yet.
import { ScrollArea } from "@/components/ui/scroll-area"
export default function ScrollAreaText01() {
return (
<ScrollArea className="h-64 w-full max-w-sm rounded-md border p-4">
<h4 className="mb-2 text-sm font-medium">Terms of Service</h4>
<div className="text-muted-foreground space-y-3 text-sm leading-relaxed">
<p>
By accessing or using this service, you agree to be bound by these
terms. If you do not agree to all of the terms, you may not use the
service.
</p>
<p>
You are responsible for safeguarding the credentials you use to
access the service and for any activities or actions under your
account.
</p>
<p>
The service and its original content, features, and functionality are
and will remain the exclusive property of the company and its
licensors.
</p>
<p>
We may terminate or suspend access to the service immediately, without
prior notice or liability, for any reason, including a breach of these
terms.
</p>
<p>
These terms shall be governed and construed in accordance with the
laws of the jurisdiction in which the company is established, without
regard to its conflict-of-law provisions.
</p>
<p>
We reserve the right to modify or replace these terms at any time. By
continuing to access the service after revisions become effective, you
agree to be bound by the revised terms.
</p>
</div>
</ScrollArea>
)
}
The Shadcn Scroll Area is a custom scrollbar for React — it replaces the native browser scrollbar with one that looks identical on macOS, Windows, and Linux, while keeping real scrolling, keyboard support, and touch. Built on Radix, it wraps any overflowing content and renders a consistent, styleable thumb. The examples below cover a vertical list, a horizontal row, a notification feed, and long text.
The shape
<ScrollArea className="h-72 w-48 rounded-md border">
{/* long content */}
</ScrollArea>
Give the ScrollArea a fixed height (or max-height) so the content can overflow — that bounded size is what makes it scroll. The styled scrollbar appears only when there's overflow.
Why not overflow: auto?
Native scrollbars differ across operating systems and overlay scrollbars hide until you scroll, which makes overflow hard to discover. ScrollArea renders a consistent thumb in every browser, so a scrolling region looks the same everywhere and stays on-brand — while still using native scrolling underneath for performance and accessibility.
Horizontal scrolling
The default scrollbar is vertical. For a sideways-scrolling row, lay the content out in a row and add a horizontal ScrollBar:
<ScrollArea className="w-96 whitespace-nowrap rounded-md border">
<div className="flex gap-4 p-4">{/* cards */}</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
Styling the scrollbar
Because the thumb is a real element — not the browser chrome — every property is yours. Render ScrollBar yourself and style its width, colour, and radius with Tailwind, or rely on the default that ScrollArea includes.
Common patterns
- Notification panel — a fixed-height activity feed (the Notification list example).
- Terms / long prose — a scrollable block of legal text in a dialog (the Long text example).
- Card rail — a horizontal row of items that scrolls sideways (the Horizontal example).
Accessibility
ScrollArea uses native scrolling underneath, so the wheel, trackpad, touch dragging, and keyboard (arrows, Page Up / Down) all behave as users expect. The custom thumb is purely visual — the scrolling mechanics stay native, which is what keeps it accessible.