Scroll Area

PreviousNext

Augments native scroll functionality for custom, cross-browser styling.

Docs
opticscomponent

Preview

Loading preview…
registry/optics/scroll-area.jsx
"use client";

import * as React from "react";

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

const ScrollArea = React.forwardRef(
	(
		{
			className = "",
			children = null,
			scrollHideDelay = 0,
			viewportClassName = "",
			maskClassName = "",
			maskHeight = 30,
			maskColor = undefined,
			...props
		},
		ref,
	) => {
		const [showMask, setShowMask] = React.useState({
			top: false,
			bottom: false,
			left: false,
			right: false,
		});
		const viewportRef = React.useRef(null);

		const checkScrollability = React.useCallback(() => {
			const element = viewportRef.current;
			if (!element) return;

			const {
				scrollTop,
				scrollLeft,
				scrollWidth,
				clientWidth,
				scrollHeight,
				clientHeight,
			} = element;
			setShowMask((prev) => ({
				...prev,
				top: scrollTop > 0,
				bottom: scrollTop + clientHeight < scrollHeight - 1,
				left: scrollLeft > 0,
				right: scrollLeft + clientWidth < scrollWidth - 1,
			}));
		}, []);

		React.useEffect(() => {
			if (typeof window === "undefined") return;

			const element = viewportRef.current;
			if (!element) return;

			const controller = new AbortController();
			const { signal } = controller;

			const resizeObserver = new ResizeObserver(checkScrollability);
			resizeObserver.observe(element);

			element.addEventListener("scroll", checkScrollability, { signal });
			window.addEventListener("resize", checkScrollability, { signal });

			checkScrollability();

			return () => {
				controller.abort();
				resizeObserver.disconnect();
			};
		}, [checkScrollability]);

		return (
			<div
				ref={ref}
				role="group"
				data-slot="scroll-area"
				aria-roledescription="scroll area"
				className={cn("relative overflow-hidden", className)}
				{...props}
			>
				<div
					ref={viewportRef}
					data-slot="scroll-area-viewport"
					className={cn(
						"size-full overflow-auto rounded-[inherit] scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent",
						viewportClassName,
					)}
					tabIndex={0}
				>
					{children}
				</div>

				{maskHeight > 0 && (
					<ScrollMask
						showMask={showMask}
						className={maskClassName}
						maskHeight={maskHeight}
						maskColor={maskColor}
					/>
				)}
			</div>
		);
	},
);

ScrollArea.displayName = "ScrollArea";

const ScrollBar = () => null;


const ScrollMask = ({
	showMask,
	maskHeight,
	maskColor = undefined,
	className = "",
	...props
}) => {
	// Extract color name from maskColor (e.g., "from-sidebar" -> "sidebar", "sidebar" -> "sidebar")
	const getColorVar = () => {
		if (!maskColor || maskColor === "from-background") {
			return null; // Use default Tailwind classes
		}
		const colorName = maskColor.startsWith("from-")
			? maskColor.slice(5) // Remove "from-" prefix
			: maskColor;
		// Map color name to CSS variable
		return `var(--${colorName})`;
	};

	const colorVar = getColorVar();
	const useCustomColor = colorVar !== null;

	return (
		<>
			<div
				{...props}
				aria-hidden="true"
				className={cn("pointer-events-none absolute inset-0 z-10", className)}
			>
				{useCustomColor ? (
					<>
						<div
							className="absolute inset-x-0 top-0 transition-[height,opacity] duration-300"
							style={{
								height: showMask.top ? `${maskHeight}px` : "0px",
								opacity: showMask.top ? 1 : 0,
								backgroundImage: `linear-gradient(to bottom, ${colorVar}, transparent)`,
							}}
						/>
						<div
							className="absolute inset-x-0 bottom-0 transition-[height,opacity] duration-300"
							style={{
								height: showMask.bottom ? `${maskHeight}px` : "0px",
								opacity: showMask.bottom ? 1 : 0,
								backgroundImage: `linear-gradient(to top, ${colorVar}, transparent)`,
							}}
						/>
					</>
				) : (
					<>
						<div
							className={cn(
								"absolute inset-x-0 top-0 transition-[height,opacity] duration-300 before:from-background before:bg-gradient-to-b before:to-transparent",
								"before:absolute before:inset-x-0 before:top-0 before:h-full before:content-['']",
								showMask.top ? "before:opacity-100" : "before:opacity-0",
							)}
							style={{
								height: showMask.top ? `${maskHeight}px` : "0px",
							}}
						/>
						<div
							className={cn(
								"absolute inset-x-0 bottom-0 transition-[height,opacity] duration-300 after:from-background after:bg-gradient-to-t after:to-transparent",
								"after:absolute after:inset-x-0 after:bottom-0 after:h-full after:content-['']",
								showMask.bottom ? "after:opacity-100" : "after:opacity-0",
							)}
							style={{
								height: showMask.bottom ? `${maskHeight}px` : "0px",
							}}
						/>
					</>
				)}
			</div>
			<div
				{...props}
				aria-hidden="true"
				className={cn("pointer-events-none absolute inset-0 z-10", className)}
			>
				{useCustomColor ? (
					<>
						<div
							className="absolute inset-y-0 left-0 transition-[width,opacity] duration-300"
							style={{
								width: showMask.left ? `${maskHeight}px` : "0px",
								opacity: showMask.left ? 1 : 0,
								backgroundImage: `linear-gradient(to right, ${colorVar}, transparent)`,
							}}
						/>
						<div
							className="absolute inset-y-0 right-0 transition-[width,opacity] duration-300"
							style={{
								width: showMask.right ? `${maskHeight}px` : "0px",
								opacity: showMask.right ? 1 : 0,
								backgroundImage: `linear-gradient(to left, ${colorVar}, transparent)`,
							}}
						/>
					</>
				) : (
					<>
						<div
							className={cn(
								"absolute inset-y-0 left-0 transition-[width,opacity] duration-300 before:from-background before:bg-gradient-to-r before:to-transparent",
								"before:absolute before:inset-y-0 before:left-0 before:w-full before:content-['']",
								showMask.left ? "before:opacity-100" : "before:opacity-0",
							)}
							style={{
								width: showMask.left ? `${maskHeight}px` : "0px",
							}}
						/>
						<div
							className={cn(
								"absolute inset-y-0 right-0 transition-[width,opacity] duration-300 after:from-background after:bg-gradient-to-l after:to-transparent",
								"after:absolute after:inset-y-0 after:right-0 after:w-full after:content-['']",
								showMask.right ? "after:opacity-100" : "after:opacity-0",
							)}
							style={{
								width: showMask.right ? `${maskHeight}px` : "0px",
							}}
						/>
					</>
				)}
			</div>
		</>
	);
};

export { ScrollArea, ScrollBar };

Installation

npx shadcn@latest add @optics/scroll-area

Usage

import { ScrollArea } from "@/components/scroll-area"
<ScrollArea />