Scroll Area React component for Tailwind CSS v4
v1.0.2Updated in 1.3.1
A React scroll area with slim custom scrollbars, edge fades that ease out at the ends, snap scrolling and prev / next buttons for rails.
Scroll with a wheel, a trackpad or a finger: momentum stays native, the content fades out toward every edge that has more to see, and each fade melts away as you reach the end. With a mouse the slim bars appear on hover and the thumb can be dragged or the track clicked to jump. On a phone the bars show while you scroll and are only indicators, like on iOS and Android, so a swipe near the edge always moves the content. Tab to the area to scroll it with the arrows, Page Up / Down, Home / End and Space; a focus ring and the bars show while it has focus, and a focused rail takes Home, End, Page keys and Space sideways too. Rails can snap card by card and, with a mouse, show round prev / next buttons on the sides with more content; they fade out at the ends, and one that hides under keyboard focus hands focus to the rail.
Scroll Area is a Pro React component in the React layout components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add scroll-area, together with Button, useMediaQuery, useScrollEdges, Motion presets and UIProvider and the npm packages @radix-ui/react-scroll-area and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add scroll-areaScroll Area is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import ScrollArea from @/components/ui/scroll-area.
import { ScrollArea } from "@/components/ui/scroll-area"
const listings = [
{ id: "1", title: "Cube Aim bike", city: "Brooklyn", price: "$450" },
{ id: "2", title: "iPhone 13, 128 GB", city: "Boston", price: "$460" },
{ id: "3", title: "Sofa bed", city: "Portland", price: "$280" },
]
export function Notifications({ items }: { items: { id: string; title: string; time: string }[] }) {
return (
<ScrollArea label="Notifications" maxHeight={320} className="rounded-2xl bg-surface shadow-raised">
<ul className="divide-y divide-foreground/10">
{items.map((item) => (
<li key={item.id} className="flex min-h-14 flex-col justify-center px-4 py-2.5">
<span className="text-[15px] font-medium">{item.title}</span>
<span className="text-[13px] text-muted-foreground">{item.time}</span>
</li>
))}
</ul>
</ScrollArea>
)
}
export function Recommended() {
return (
<ScrollArea
orientation="horizontal"
snap="mandatory"
controls
label="Recommended"
classNames={{ viewport: "scroll-px-4" }}
>
<div className="flex w-max gap-3 px-4 pb-3">
{listings.map((listing) => (
<article key={listing.id} className="w-56 shrink-0 snap-start rounded-2xl bg-surface p-4 shadow-raised">
<h3 className="truncate text-sm font-medium">{listing.title}</h3>
<p className="text-xs text-muted-foreground">{listing.city}</p>
<p className="mt-2 text-[15px] font-semibold tabular-nums">{listing.price}</p>
</article>
))}
</div>
</ScrollArea>
)
}Props
orientationdefault"vertical""vertical" | "horizontal" | "both"Which scrollbars render and which edges fade. horizontal turns the area into a rail (the demo swaps the chat for listing cards), both scrolls a wide table either way.
typedefault"hover""hover" | "scroll" | "auto" | "always"When the bars show: while the pointer is over the area, while it scrolls, whenever the content overflows, or always. On touch screens "hover" behaves like "scroll", since a finger never hovers. Content that fits shows no bars in any mode.
scrollHideDelaydefault600numberMilliseconds before the bars fade out after the pointer leaves ("hover") or scrolling stops ("scroll").
fadedefaulttruebooleanFades the content out toward every edge with more to scroll. It is a mask, not an overlay, so it works on any surface in both themes and never covers the bars.
fadeSizedefault24numberThe largest fade in px. Each fade grows with the distance to its edge, so it eases out as you reach the end instead of popping.
scrollbarSizedefault"md""sm" | "md"Bar thickness, 8 or 10px. The thumb fills it inside a 2px inset (4 or 6px). On touch screens the bars are indicators only, so a swipe that lands on the thumb still scrolls the content.
snapdefault"none""none" | "mandatory" | "proximity"CSS scroll snap along the orientation. mandatory always rests on an item (card rails), proximity only near one (chips, long lists). The children set snap-start or snap-center.
controlsdefaultfalsebooleanHorizontal and both: floating prev / next buttons on the sides with more content. Mouse and trackpad only (a finger swipes); they fade out at each end and leave the tab order while hidden.
scrollStepdefault0.8numberShare of the viewport width one control click, or Page Up / Down or Space on a focused rail, scrolls. Smooth, and instant under reduced motion.
labeldefault""stringAccessible name of the scrollable region, like "Messages". With a label the viewport is announced as a region. The demo uses a sample while this is empty.
focusabledefaulttruebooleanMakes the viewport a tab stop while it overflows, so keyboard users can scroll it; the focus ring sits just inside the edge and the bars show while it has focus. Turn it off when the content is itself focusable (a list of links).
maxHeightdefault""number | stringConvenience max height of the viewport: a number (or numeric string) in px, or any CSS length like "50dvh". The area grows with its content up to it. Otherwise size the root with a class (h-72, flex-1 min-h-0). The demo caps the chat with it.
viewportRefRef<HTMLDivElement> (a useRef object or an (el) => void callback)The scrolling element, for scrollTo, scrollIntoView or measuring. The demo uses it to start the chat at the latest message.
controlLabelsdefault"{ previous: "Scroll back", next: "Scroll forward" }"{ previous?: string; next?: string }aria-labels of the prev / next buttons.
onEdgesChange(edges: { top: boolean; bottom: boolean; left: boolean; right: boolean }) => voidWhich edges have more content, called when one flips (never per scroll frame). Handy for a header hairline that appears once the list scrolls under it.
classNamesPartial<Record<"viewport" | "scrollbar" | "thumb" | "controls", string>>Extra classes for the inner parts. viewport takes scroll padding (scroll-px-4) for rails, thumb a custom color (bg-brand/50), controls a different vertical position.
classNamestringExtra classes for the root, merged last: its size (h-72, flex-1 min-h-0) and radius, which the viewport inherits. Other props (style, aria-*, dir) go to the Radix root too; dir="rtl" puts the vertical bar on the left and mirrors the fades, the controls and the Home / End keys of a rail.
childrenReactNodeThe content. Rails lay their items out in a w-max flex row of snap-start items. The file also exports ScrollBar (orientation, size "sm" | "md", classNames.thumb), the same bar as a part for compositions on the Radix root.
Parts
ScrollBar
componentOne slim scrollbar with its thumb, for compositions built on the Radix scroll area root. ScrollArea renders these for you.
orientationdefault"vertical""vertical" | "horizontal"Which axis the bar scrolls.
sizedefault"md""sm" | "md"8px (sm) or 10px (md) thick.
classNamesPartial<Record<"thumb", string>>Extra classes for the thumb, e.g. a custom color.
ScrollAreaProps
typeThe props of ScrollArea, for wrappers like a horizontal rail or a chat log.
Examples
Vertical list with fades
maxHeight caps it; onEdgesChange draws the header hairline once the list scrolls under it.
Listing rail
orientation="horizontal", snap="mandatory" and controls: cards settle one by one, the arrows hide at each end.
Both axes
A 12-column price table in a small box; the fades follow all four edges.
Always vs on hover
type="always" keeps the bar; "hover" shows it while you point at the list, while it has keyboard focus, and while scrolling on touch.
Chips on a phone
snap="proximity" rests near a chip without forcing it; no controls on touch, a swipe is enough.
Inside a popover
maxHeight, label="Results", a slim bar, and focusable={false} since every row is a button already.
Slim bar, brand thumb
scrollbarSize="sm", a longer fade and classNames.thumb in the brand color.
Dependencies
Files
- components/ui/scroll-area.tsx
FAQ
Is the React Scroll Area component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Scroll Area?
Run npx wingo-ui@latest add scroll-area. It also adds the Wingo UI items it builds on (Button, useMediaQuery, useScrollEdges, Motion presets and UIProvider) and the npm packages @radix-ui/react-scroll-area and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/scroll-area.
How does Scroll Area behave on phones?
On a phone the bars show while you scroll and are only indicators, like on iOS and Android, so a swipe near the edge always moves the content.
