Scroll area
Scroll container with overlay or space-reserving scrollbars, edge fades, and optional progressive blur.
Examples
Stable scrollbar gutter
Set scrollbarGutter="stable" to reserve space for each unlocked scrollbar, even when the content fits. Combine it with scrollbarVisibility="always" for persistent tracks. Both props override the skin defaults; Windows XP uses always-visible, space-reserving scrollbars.
Progressive blur
Enable blur to soften overflowing edges. Combine it with the default mask, or use mask={false} for blur alone. blurProps.style accepts the ProgressiveBlur knobs. Effects follow each unlocked edge without adding scroll listeners. Where non-round corner shapes are supported, those shapes take priority and disable scroll-edge blur; the mask remains available.
Composition
Scrollable content
- <ScrollArea>
- scrollable content
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
The Control UI source installs this primitive from src/registry/sources/control-ui/ui/scroll-area.tsx.
npx shadcn@latest add https://control-ui.dev/r/scroll-area.jsonDependencies
Raw code
Primary installed source
"use client";import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";import type { ComponentProps, CSSProperties, Ref } from "react";import type { ScrollAreaKnobStyle } from "@/components/control-ui/knob-contracts/scroll-area-knobs";import { cn } from "@/components/control-ui/lib/cn";import { useSkin } from "@/components/control-ui/skin-provider";import { ProgressiveBlur, type ProgressiveBlurProps } from "@/components/control-ui/ui/progressive-blur";export const scrollAreaScrollbarVisibilities = ["scroll", "hover", "always"] as const;export type ScrollAreaScrollbarVisibility = (typeof scrollAreaScrollbarVisibilities)[number];export const scrollAreaScrollbarGutters = ["auto", "stable"] as const;export type ScrollAreaScrollbarGutter = (typeof scrollAreaScrollbarGutters)[number];export type ScrollAreaLockAxis = "x" | "y" | "both";export type ScrollAreaViewportProps = Omit<ComponentProps<"div">, "children" | "className" | "ref"> & { "data-control-ui"?: string; "data-control-family"?: string; "data-slot"?: string; render?: ComponentProps<typeof ScrollAreaPrimitive.Viewport>["render"];};export type ScrollAreaProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & ScrollAreaKnobStyle } & { viewportClassName?: string; contentClassName?: string; viewportProps?: ScrollAreaViewportProps; viewportRef?: Ref<HTMLDivElement>; maxHeight?: string; mask?: boolean; blur?: boolean; blurProps?: Pick<ProgressiveBlurProps, "style">; lockAxis?: ScrollAreaLockAxis; scrollbarVisibility?: ScrollAreaScrollbarVisibility; scrollbarGutter?: ScrollAreaScrollbarGutter;};function Scrollbar({ orientation, visibility, gutter, thumbStyle,}: { orientation: "vertical" | "horizontal"; visibility: ScrollAreaProps["scrollbarVisibility"]; gutter: ScrollAreaScrollbarGutter; thumbStyle?: CSSProperties & ScrollAreaKnobStyle;}) { return ( <ScrollAreaPrimitive.Scrollbar orientation={orientation} keepMounted={gutter === "stable"} data-control-ui="scroll-area" data-control-family="scroll-area" data-slot="scrollbar" data-visibility={visibility} className={cn("m-px flex touch-none select-none", orientation === "vertical" ? "justify-center" : "flex-col")} style={gutter === "stable" ? { position: "relative", inset: "auto" } : undefined} > <ScrollAreaPrimitive.Thumb data-control-ui="scroll-area" data-control-family="scroll-area" data-slot="thumb" className={cn("flex-1", orientation === "vertical" ? "w-full" : "h-full")} style={thumbStyle} /> </ScrollAreaPrimitive.Scrollbar> );}function viewportStyle( baseStyle: CSSProperties | undefined, maxHeight: ScrollAreaProps["maxHeight"], lockX: boolean, lockY: boolean,): CSSProperties | undefined { if (!maxHeight && !lockX && !lockY) return baseStyle; return { ...baseStyle, ...(maxHeight ? { maxHeight } : {}), ...(lockX ? { overflowX: "hidden" } : {}), ...(lockY ? { overflowY: "hidden" } : {}), };}function regionProps(ariaLabel: string | undefined, ariaLabelledby: string | undefined) { if (!ariaLabel && !ariaLabelledby) return {}; return { role: "region", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby };}function EdgeBlurs({ blurProps, lockX, lockY }: { blurProps: ScrollAreaProps["blurProps"]; lockX: boolean; lockY: boolean }) { return ( <> {!lockY && <ProgressiveBlur {...blurProps} side="top" visible={false} />} {!lockY && <ProgressiveBlur {...blurProps} side="bottom" visible={false} />} {!lockX && <ProgressiveBlur {...blurProps} side="inline-start" visible={false} />} {!lockX && <ProgressiveBlur {...blurProps} side="inline-end" visible={false} />} </> );}export function ScrollArea({ className, viewportClassName, contentClassName, viewportProps, viewportRef, maxHeight, mask = true, blur, blurProps, lockAxis, scrollbarVisibility, scrollbarGutter, children, style, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, ...props}: ScrollAreaProps) { const skin = useSkin(); const resolvedBlur = blur ?? skin.scrollAreaBlur ?? false; const resolvedVisibility = scrollbarVisibility ?? skin.scrollAreaScrollbarVisibility ?? "hover"; const resolvedGutter = scrollbarGutter ?? skin.scrollAreaScrollbarGutter ?? "auto"; const lockX = lockAxis === "x" || lockAxis === "both"; const lockY = lockAxis === "y" || lockAxis === "both"; const { style: viewportPropsStyle, ...resolvedViewportProps } = viewportProps ?? {}; const mergedViewportStyle = viewportStyle(viewportPropsStyle, maxHeight, lockX, lockY); const thumbStyle = style; const cornerStyle = style; return ( <ScrollAreaPrimitive.Root {...props} data-control-ui="scroll-area" data-control-family="scroll-area" data-slot="root" data-mask={mask || undefined} data-blur={resolvedBlur || undefined} data-lock-axis={lockAxis} data-scrollbar-gutter={resolvedGutter} className={cn("relative overflow-hidden", className)} style={style} > <ScrollAreaPrimitive.Viewport data-control-ui="scroll-area" data-control-family="scroll-area" data-slot="viewport" {...resolvedViewportProps} {...regionProps(ariaLabel, ariaLabelledby)} data-scroll-area-viewport="" ref={viewportRef} className={cn("h-full w-full", viewportClassName)} style={mergedViewportStyle} > <ScrollAreaPrimitive.Content data-control-ui="scroll-area" data-control-family="scroll-area" data-slot="content" className={contentClassName} style={{ minWidth: 0 }} > {children} </ScrollAreaPrimitive.Content> </ScrollAreaPrimitive.Viewport> {resolvedBlur && <EdgeBlurs blurProps={blurProps} lockX={lockX} lockY={lockY} />} {!lockY && <Scrollbar orientation="vertical" visibility={resolvedVisibility} gutter={resolvedGutter} thumbStyle={thumbStyle} />} {!lockX && <Scrollbar orientation="horizontal" visibility={resolvedVisibility} gutter={resolvedGutter} thumbStyle={thumbStyle} />} {!lockX && !lockY && ( <ScrollAreaPrimitive.Corner data-control-ui="scroll-area" data-control-family="scroll-area" data-slot="corner" style={resolvedGutter === "stable" ? { ...cornerStyle, position: "relative", inset: "auto" } : cornerStyle} /> )} </ScrollAreaPrimitive.Root> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-scroll-area-* · 3 knobsHow the cascade resolves--cui-scroll-area-thumb-radius<length-percentage>9999px--cui-scroll-area-thumb-background<color>oklch(from var(--foreground) l c h / 0.4)--cui-scroll-area-corner-background<color>transparent