컴포넌트
스크롤 영역
얇은 스크롤바와 가장자리 페이드가 있는 기본 스크롤 영역이에요.
- #arc
- #component
- #scroll
- #container
- #overflow
- #layout
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/scroll-area.json파일
components/arc/scroll-area/scroll-area.module.csscomponents/arc/scroll-area/scroll-area.tsx
함께 오는 항목
scroll-area.module.cssscroll-area.module.cssscroll-area.module.csscss
.root { position: relative; display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }
/* Native scrolling with the platform scrollbars hidden; the overlay bars below replace them. */
.viewport {
--fade-top: 0px; --fade-bottom: 0px; --fade-left: 0px; --fade-right: 0px;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
overscroll-behavior-x: contain;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
}
.viewport::-webkit-scrollbar { display: none; }
.root[data-orientation="vertical"] > .viewport { overflow-x: hidden; overflow-y: auto; }
.root[data-orientation="horizontal"] > .viewport { overflow-x: auto; overflow-y: hidden; }
.root[data-orientation="both"] > .viewport { overflow: auto; }
/* Each fade is exactly as long as the content hidden past that edge, up to the fade size, so it grows in with the first pixels of scroll. */
.viewport[data-fade="vertical"] {
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent);
mask-image: linear-gradient(to bottom, transparent, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent);
}
.viewport[data-fade="horizontal"] {
-webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-left), #000 calc(100% - var(--fade-right)), transparent);
mask-image: linear-gradient(to right, transparent, #000 var(--fade-left), #000 calc(100% - var(--fade-right)), transparent);
}
.viewport[data-fade="both"] {
-webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent), linear-gradient(to right, transparent, #000 var(--fade-left), #000 calc(100% - var(--fade-right)), transparent);
-webkit-mask-composite: source-in;
mask-image: linear-gradient(to bottom, transparent, #000 var(--fade-top), #000 calc(100% - var(--fade-bottom)), transparent), linear-gradient(to right, transparent, #000 var(--fade-left), #000 calc(100% - var(--fade-right)), transparent);
mask-composite: intersect;
}
/* Overlay scrollbars: a generous hit area around a thin pill that thickens under the pointer. */
.track {
position: absolute;
z-index: 1;
opacity: 0;
pointer-events: none;
touch-action: none;
transition: opacity var(--duration-considered) var(--ease-standard);
}
.track[data-axis="y"] { top: 3px; right: 1px; bottom: 3px; width: 11px; }
.track[data-axis="x"] { right: 3px; bottom: 1px; left: 3px; height: 11px; }
.root[data-orientation="both"] > .track[data-axis="y"] { bottom: 13px; }
.root[data-orientation="both"] > .track[data-axis="x"] { right: 13px; }
.track[data-hidden] { display: none; }
.thumb { position: absolute; top: 0; left: 0; will-change: transform; }
.track[data-axis="y"] .thumb { width: 100%; }
.track[data-axis="x"] .thumb { height: 100%; }
.thumb::after {
content: "";
position: absolute;
border-radius: var(--radius-pill);
background: color-mix(in oklab, var(--foreground) 30%, transparent);
transition: width var(--duration-fast) var(--ease-standard), height var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
}
.track[data-axis="y"] .thumb::after { top: 0; right: 3px; bottom: 0; width: 5px; }
.track[data-axis="x"] .thumb::after { right: 0; bottom: 3px; left: 0; height: 5px; }
/* Visible while scrolling, while the pointer is over the area, and always when asked. Appearing is quick; leaving lingers. */
.root[data-scrolling] > .track,
.root[data-dragging] > .track,
.root[data-scrollbars="always"] > .track { opacity: 1; pointer-events: auto; transition-duration: var(--duration-fast); }
@media (hover: hover) and (pointer: fine) {
.root:hover > .track { opacity: 1; pointer-events: auto; transition-duration: var(--duration-fast); }
.track:hover .thumb::after,
.root[data-dragging] .thumb::after { background: color-mix(in oklab, var(--foreground) 46%, transparent); }
.track[data-axis="y"]:hover .thumb::after,
.root[data-dragging="y"] .track[data-axis="y"] .thumb::after { width: 7px; right: 2px; }
.track[data-axis="x"]:hover .thumb::after,
.root[data-dragging="x"] .track[data-axis="x"] .thumb::after { height: 7px; bottom: 2px; }
}
@media (prefers-reduced-motion: reduce) {
.track, .thumb::after { transition: none; }
}
scroll-area.tsxscroll-area.tsxscroll-area.tsxtsx
"use client";
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef } from "react";
import type { CSSProperties, HTMLAttributes, PointerEvent as ReactPointerEvent, ReactNode, Ref, UIEvent } from "react";
import styles from "./scroll-area.module.css";
import { prefersReducedMotion } from "../lib/use-reduced-motion";
type Axis = "y" | "x";
/** Which edges still have content beyond them. */
export interface ScrollAreaEdges { top: boolean; bottom: boolean; left: boolean; right: boolean }
/**
* A native scroll container with thin overlay scrollbars that appear while scrolling or on hover and fade away at rest.
* Each edge fades out only when there is more content beyond it, in proportion to how much. Scrolling stays native, so
* keyboard, touch momentum, and scroll snap all behave as the platform expects.
*/
export interface ScrollAreaProps extends Omit<HTMLAttributes<HTMLDivElement>, "onScroll"> {
children?: ReactNode;
/** Axes that scroll. Defaults to vertical. */
orientation?: "vertical" | "horizontal" | "both";
/** Length of the edge fade in px. 0 turns the fades off. */
fade?: number;
/** "auto" shows scrollbars while scrolling or hovering; "always" keeps them visible when content overflows. */
scrollbars?: "auto" | "always";
/** How long scrollbars linger after scrolling stops, in ms. */
hideDelay?: number;
/** Maximum height of the viewport, for vertical areas that should grow with their content. */
maxHeight?: CSSProperties["maxHeight"];
/** Passed to the viewport's `scroll-snap-type`, for example "x mandatory". Children set their own `scroll-snap-align`. */
snap?: CSSProperties["scrollSnapType"];
/** Accessible name. The viewport becomes a labelled, focusable region that arrow and page keys scroll. */
label?: string;
/** Turns vertical wheel movement into horizontal scrolling for horizontal areas. Defaults to true. */
wheelToHorizontal?: boolean;
viewportClassName?: string;
viewportStyle?: CSSProperties;
viewportRef?: Ref<HTMLDivElement>;
onScroll?: (event: UIEvent<HTMLDivElement>) => void;
/** Called when content starts or stops extending past an edge. */
onEdgeChange?: (edges: ScrollAreaEdges) => void;
}
const MIN_THUMB = 28;
export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(function ScrollArea({
children, orientation = "vertical", fade = 28, scrollbars = "auto", hideDelay = 900, maxHeight, snap, label,
wheelToHorizontal = true, viewportClassName, viewportStyle, viewportRef, onScroll, onEdgeChange, className, ...rest
}, forwardedRef) {
const rootRef = useRef<HTMLDivElement>(null);
const viewport = useRef<HTMLDivElement>(null);
const tracks = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });
const thumbs = useRef<Record<Axis, HTMLDivElement | null>>({ x: null, y: null });
const hideTimer = useRef<number | undefined>(undefined);
const lastEdges = useRef("");
const onEdgeRef = useRef(onEdgeChange);
useImperativeHandle(forwardedRef, () => rootRef.current as HTMLDivElement);
useImperativeHandle(viewportRef, () => viewport.current as HTMLDivElement);
useEffect(() => { onEdgeRef.current = onEdgeChange; }, [onEdgeChange]);
const vertical = orientation !== "horizontal", horizontal = orientation !== "vertical";
/* Everything visual is written straight to the DOM on each scroll frame, so scrolling never re-renders React. */
const sync = useCallback(() => {
const node = viewport.current, root = rootRef.current;
if (!node || !root) return;
const { scrollTop, scrollLeft, scrollHeight, scrollWidth, clientHeight, clientWidth } = node;
const maxY = Math.max(0, scrollHeight - clientHeight), maxX = Math.max(0, scrollWidth - clientWidth);
// RTL layouts report negative scrollLeft; distance from the start edge is its magnitude.
const left = Math.abs(scrollLeft);
const edges: ScrollAreaEdges = { top: vertical && scrollTop > .5, bottom: vertical && maxY - scrollTop > .5, left: horizontal && left > .5, right: horizontal && maxX - left > .5 };
if (fade > 0) {
node.style.setProperty("--fade-top", `${vertical ? Math.min(fade, scrollTop) : 0}px`);
node.style.setProperty("--fade-bottom", `${vertical ? Math.min(fade, maxY - scrollTop) : 0}px`);
node.style.setProperty("--fade-left", `${horizontal ? Math.min(fade, left) : 0}px`);
node.style.setProperty("--fade-right", `${horizontal ? Math.min(fade, maxX - left) : 0}px`);
}
const place = (axis: Axis, scroll: number, max: number, client: number, total: number) => {
const track = tracks.current[axis], thumb = thumbs.current[axis];
if (!track || !thumb) return;
const overflowing = max > .5;
track.toggleAttribute("data-hidden", !overflowing);
if (!overflowing) return;
const length = axis === "y" ? track.clientHeight : track.clientWidth;
const size = Math.max(MIN_THUMB, length * client / total);
const offset = (length - size) * (scroll / max);
thumb.style[axis === "y" ? "height" : "width"] = `${size}px`;
thumb.style.transform = axis === "y" ? `translate3d(0, ${offset}px, 0)` : `translate3d(${offset}px, 0, 0)`;
};
if (vertical) place("y", scrollTop, maxY, clientHeight, scrollHeight);
if (horizontal) place("x", left, maxX, clientWidth, scrollWidth);
root.toggleAttribute("data-overflow", maxY > .5 || maxX > .5);
const key = `${edges.top}${edges.bottom}${edges.left}${edges.right}`;
if (key !== lastEdges.current) { lastEdges.current = key; onEdgeRef.current?.(edges); }
}, [fade, horizontal, vertical]);
useLayoutEffect(() => {
const node = viewport.current;
if (!node) return;
sync();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => sync());
observer.observe(node);
Array.from(node.children).forEach(child => observer.observe(child));
const mutations = new MutationObserver(() => { observer.disconnect(); observer.observe(node); Array.from(node.children).forEach(child => observer.observe(child)); sync(); });
mutations.observe(node, { childList: true });
return () => { observer.disconnect(); mutations.disconnect(); };
}, [sync]);
useEffect(() => () => window.clearTimeout(hideTimer.current), []);
const wake = () => {
const root = rootRef.current;
if (!root) return;
root.setAttribute("data-scrolling", "");
window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => root.removeAttribute("data-scrolling"), hideDelay);
};
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
sync();
wake();
onScroll?.(event);
};
// A mouse wheel over a horizontal strip moves it sideways, but only while there is room, so the page still scrolls at the ends.
useEffect(() => {
const node = viewport.current;
if (!node || orientation !== "horizontal" || !wheelToHorizontal) return;
const wheel = (event: WheelEvent) => {
if (event.ctrlKey || Math.abs(event.deltaX) >= Math.abs(event.deltaY)) return;
const max = node.scrollWidth - node.clientWidth, at = Math.abs(node.scrollLeft);
if (max <= 0 || (event.deltaY < 0 && at <= 0) || (event.deltaY > 0 && at >= max - .5)) return;
event.preventDefault();
node.scrollLeft += event.deltaY * (getComputedStyle(node).direction === "rtl" ? -1 : 1);
};
node.addEventListener("wheel", wheel, { passive: false });
return () => node.removeEventListener("wheel", wheel);
}, [orientation, wheelToHorizontal]);
/* Dragging a thumb maps pointer travel to scroll distance; pressing the track pages toward the pointer. */
const drag = useRef<{ axis: Axis; start: number; scroll: number; ratio: number } | null>(null);
const onThumbDown = (event: ReactPointerEvent<HTMLDivElement>, axis: Axis) => {
const node = viewport.current, track = tracks.current[axis], thumb = thumbs.current[axis];
if (!node || !track || !thumb || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
const length = axis === "y" ? track.clientHeight : track.clientWidth, size = axis === "y" ? thumb.offsetHeight : thumb.offsetWidth;
const max = axis === "y" ? node.scrollHeight - node.clientHeight : node.scrollWidth - node.clientWidth;
drag.current = { axis, start: axis === "y" ? event.clientY : event.clientX, scroll: axis === "y" ? node.scrollTop : node.scrollLeft, ratio: max / Math.max(1, length - size) };
rootRef.current?.setAttribute("data-dragging", axis);
node.style.scrollSnapType = "none";
};
const onThumbMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const state = drag.current, node = viewport.current;
if (!state || !node) return;
const delta = ((state.axis === "y" ? event.clientY : event.clientX) - state.start) * state.ratio;
if (state.axis === "y") node.scrollTop = state.scroll + delta; else node.scrollLeft = state.scroll + delta;
};
const onThumbUp = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!drag.current) return;
drag.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
rootRef.current?.removeAttribute("data-dragging");
if (viewport.current) viewport.current.style.scrollSnapType = "";
wake();
};
const onTrackDown = (event: ReactPointerEvent<HTMLDivElement>, axis: Axis) => {
const node = viewport.current, thumb = thumbs.current[axis];
if (!node || !thumb || event.button !== 0 || event.target !== event.currentTarget) return;
const rect = thumb.getBoundingClientRect();
const before = axis === "y" ? event.clientY < rect.top : event.clientX < rect.left;
const page = (axis === "y" ? node.clientHeight : node.clientWidth) * .9 * (before ? -1 : 1);
const reduce = prefersReducedMotion();
node.scrollBy({ [axis === "y" ? "top" : "left"]: page, behavior: reduce ? "auto" : "smooth" });
};
const bar = (axis: Axis) => <div ref={node => { tracks.current[axis] = node; }} className={styles.track} data-axis={axis} data-hidden="" aria-hidden="true"
onPointerDown={event => onTrackDown(event, axis)}>
<div ref={node => { thumbs.current[axis] = node; }} className={styles.thumb}
onPointerDown={event => onThumbDown(event, axis)} onPointerMove={onThumbMove} onPointerUp={onThumbUp} onPointerCancel={onThumbUp} />
</div>;
return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} data-orientation={orientation} data-scrollbars={scrollbars} {...rest}>
<div ref={viewport} className={[styles.viewport, viewportClassName].filter(Boolean).join(" ")} data-fade={fade > 0 ? orientation : undefined}
style={{ maxHeight, scrollSnapType: snap, ...viewportStyle }} tabIndex={0} role={label ? "region" : undefined} aria-label={label} onScroll={handleScroll}>
{children}
</div>
{vertical && bar("y")}
{horizontal && bar("x")}
</div>;
});
export default ScrollArea;