Menus & FeedbackScroll Area

Custom scroll chrome that earns the replacement — hover rails, faithful gestures.

Preview
initial tokensv0.1.0
button + badgev0.2.0
dark ladderv0.3.0
sound layerv0.4.0
focus ring auditv0.4.1
card + avatarv0.5.0
field hairlinesv0.6.0
motion vocabularyv0.7.0
kbd + separatorv0.7.1
skeleton shimmerv0.8.0
switch + checkboxv0.9.0
dialog + drawerv0.10.0
tooltip timingv0.10.1
tabs + togglev0.11.0
popover origin fixv0.11.1
accordionv0.12.0
menusv0.13.0
select popperv0.13.1
scroll chromev0.14.0
toast + notifyv0.15.0

The thinking

A custom scrollbar is usually a trade: the designer gets the look and the user quietly loses a gesture somewhere. So the one decision everything else hangs on is that this component refuses to own scrolling. Radix keeps the viewport natively scrollable and only paints the chrome on top, which means wheel, drag, keyboard and touch all still belong to the browser and the swap costs nothing but the default look. I'd rather ship a scrollbar nobody notices than a pretty one that drops a gesture. (The look was never the point; not losing anything was.)

Interface design

The rail is ten real pixels — w-2.5 vertical, h-2.5 horizontal — a touch target rather than the decorative sliver most replacements settle for, and the thumb sits inside it with a p-px inset, one pixel of breathing room so it reads as a thing riding a channel. The thumb's ink is bg-border-hover, the emphasized hairline the system already ships, so it stays visible on both themes without inventing a one-off gray. The thumb is rounded-full, the viewport is rounded-[inherit] so the chrome never pokes out of a rounded container, and a Corner piece sits where the two rails would otherwise collide. No new ink, no new radius.

Interaction

type="hover" is the intent inference: the rail stays gone until your pointer enters the area and fades 600ms after it leaves — Radix's stock hide delay, untouched. Chrome that's always visible is furniture; chrome that appears when needed is a tool. The fade is data-[state=visible]:animate-overlay-in and data-[state=hidden]:animate-overlay-out — pure opacity keyframes on the overlay curve, 200ms in and 150ms out, because a surface should leave faster than it arrived. Radix holds the bar mounted through the hidden state, so the exit actually plays instead of the node vanishing mid-fade. And since the viewport scrolls natively, none of this timing ever sits between your finger and the content — the animation decorates the scroll, it never gates it.

Sound design

A scroll area says nothing, and that silence is the system working. The global SoundLayer plays its tap through one capturing click listener that only answers elements carrying an interactive role — button, tab, option, switch — and nothing in this file carries one: the rail and thumb are painted divs, the viewport is a scrollable box. So the selector never matches and there's no need for the data-silent escape hatch either. Scrolling is a continuous gesture, not a commitment, and a sound per wheel tick would be a metronome, not feedback.

Empathy

Reduced motion gets motion-reduce:animate-none — the rail still appears and hides on the same intent, it just snaps instead of fading; the states survive, only the theatre goes. The scrollbar is touch-none and select-none, so dragging the thumb never fights the browser's own touch scrolling and never smears a text selection behind it. Keyboard and screen-reader access I deliberately left alone: I wire no aria here, because the viewport scrolls natively and assistive tech should meet the browser's scrolling, not my reimplementation of it. There's no disabled state and I don't want one — a scroll area you could disable is content you're not allowed to read. Long content is the job description: the root clips with overflow-hidden, both orientations get a rail, and the Corner keeps them from colliding when something overflows both ways.
scroll-area.tsx
"use client";

import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";

import { cn } from "@/lib/utils";

const ScrollArea = ({
	ref,
	className,
	children,
	// hover: the rail appears on scroll intent and fades when the intent
	// passes — chrome that's always visible is furniture
	type = "hover",
	...props
}: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & {
	ref?: React.Ref<React.ComponentRef<typeof ScrollAreaPrimitive.Root>>;
}) => (
	<ScrollAreaPrimitive.Root
		ref={ref}
		type={type}
		className={cn("relative overflow-hidden", className)}
		{...props}
	>
		<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
			{children}
		</ScrollAreaPrimitive.Viewport>
		<ScrollBar />
		<ScrollBar orientation="horizontal" />
		<ScrollAreaPrimitive.Corner />
	</ScrollAreaPrimitive.Root>
);
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;

const ScrollBar = ({
	ref,
	className,
	orientation = "vertical",
	...props
}: React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Scrollbar> & {
	ref?: React.Ref<React.ComponentRef<typeof ScrollAreaPrimitive.Scrollbar>>;
}) => (
	<ScrollAreaPrimitive.Scrollbar
		ref={ref}
		orientation={orientation}
		// the rail is 10px — a real touch target, not a decorative sliver.
		// radix keeps it mounted through the exit animation, so the fade
		// is pure opacity keyframes, in at 200ms and out faster.
		className={cn(
			"flex touch-none select-none p-px data-[state=visible]:animate-overlay-in data-[state=hidden]:animate-overlay-out motion-reduce:animate-none",
			orientation === "vertical" && "h-full w-2.5",
			orientation === "horizontal" && "h-2.5 flex-col",
			className
		)}
		{...props}
	>
		{/* border-hover ink: visible on both themes without inventing a gray */}
		<ScrollAreaPrimitive.Thumb className="relative flex-1 rounded-full bg-border-hover" />
	</ScrollAreaPrimitive.Scrollbar>
);
ScrollBar.displayName = ScrollAreaPrimitive.Scrollbar.displayName;

export { ScrollArea, ScrollBar };