Built on @radix-ui/react-scroll-area — a fully-styleable custom scrollbar in place of the browser's native one.
npx chandra-asri-ui add scroll-areaimport { ScrollArea } from "@/app/components/ui/scroll-area";
import { Separator } from "@/app/components/ui/separator";
const TAGS = Array.from({ length: 40 }, (_, i) => `Component ${i + 1}`);
export function ScrollAreaDemo() {
return (
<ScrollArea className="border-border h-72 w-56 rounded-md border">
<div className="p-4">
<h4 className="mb-4 text-sm leading-none font-medium">Components</h4>
{TAGS.map((tag) => (
<div key={tag}>
<div className="text-sm">{tag}</div>
<Separator className="my-2" />
</div>
))}
</div>
</ScrollArea>
);
}
This isn't just overflow-auto— the scrollbar itself is a real, custom-rendered HTML element (not the OS's native one), so it can be fully styled, something ::-webkit-scrollbar can't do consistently across browsers. It auto-hides when not hovered/scrolling (macOS-style), and ScrollAreaThumb is directly draggable for fast scrolling. Actual scrolling — wheel, keyboard, touch — still works exactly as normal; only the visuals change. Render ScrollBar a second time with orientation="horizontal" for a two-axis scroll area.
"use client";
import { cn } from "@/lib/utils";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
/**
* Built on @radix-ui/react-scroll-area. Swaps the browser's native
* scrollbar for a custom-rendered one (a real HTML element, not the OS's
* own), so it can be fully styled — color, thickness, radius — something
* `::-webkit-scrollbar` can't do consistently across browsers.
*
* Behavior that's all bawaan Radix, not reimplemented here:
* - Auto-hide: the custom scrollbar is hidden until the area is
* hovered/scrolled (macOS-style), then fades back out.
* - `ScrollAreaThumb` is directly draggable for fast scrolling, not just
* click-and-hold on the track.
* - Real scrolling (wheel, keyboard, touch) still works exactly as
* normal — this only replaces the scrollbar's *visuals*, not the
* scroll mechanism itself.
* - `ScrollAreaPrimitive.Corner` renders automatically to fill the
* bottom-right gap when both scrollbars are active at once.
*
* Render `ScrollBar` a second time with `orientation="horizontal"` for a
* two-axis scroll area.
*/
export const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
export const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="bg-border relative flex-1 rounded-full" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;