Scroll Auto Hide
스크롤 방향에 따라 고정 영역을 숨기고 드러내는 컴포넌트
ScrollAutoHide는 아래로 스크롤할 때 고정 영역을 숨깁니다. 위로 스크롤하면 같은 거리만큼 다시 드러납니다. 필터, 탭, 보조 헤더처럼 콘텐츠 공간을 더 넓게 쓰고 싶은 영역에 적합합니다.
"use client";
import { IconChevronDownFill } from "@karrotmarket/react-monochrome-icon";
import { Badge, Box, Flex, HStack, Icon, Text, VStack } from "@seed-design/react";
import { useRef } from "react";
import { ScrollAutoHide } from "seed-design/breeze/scroll-auto-hide/scroll-auto-hide";
import { Avatar } from "seed-design/ui/avatar";
import { Chip } from "seed-design/ui/chip";
import { IdentityPlaceholder } from "seed-design/ui/identity-placeholder";
import { TabsList, TabsRoot, TabsTrigger } from "seed-design/ui/tabs";
import { TagGroupItem, TagGroupRoot } from "seed-design/ui/tag-group";
type Article = {
author: string;
title: string;
content: string;
category: string;
date: string;
popular?: boolean;
};
const ARTICLES = [
{
author: "magna",
title: "독서 습관 만들기",
content: "하루 10페이지부터 시작하자. 취침 전 20분 독서는 수면의 질도 높여줘요.",
category: "라이프스타일",
date: "12월 4일",
popular: true,
},
{
author: "ullamco",
title: "알프스 하이킹 가이드",
content: "그린델발트에서 시작해 아이거 북벽을 감상하는 초보자용 코스를 소개해요.",
category: "여행",
date: "10월 15일",
},
{
author: "consectetur",
title: "커피 브루잉 팁",
content: "물 온도 92도, 중간 분쇄도와 3분의 추출 시간이 기본이에요.",
category: "음식",
date: "9월 25일",
},
{
author: "exercitation",
title: "실내 공기 정화 식물",
content: "관리하기 쉽고 공기 정화 능력이 뛰어난 식물을 모아봤어요.",
category: "라이프스타일",
date: "8월 7일",
},
{
author: "elit",
title: "아침 루틴의 힘",
content: "따뜻한 물 한 잔과 가벼운 스트레칭으로 하루를 시작해보세요.",
category: "라이프스타일",
date: "7월 28일",
popular: true,
},
] satisfies Article[];
const FILTERS = ["카테고리", "동네", "작성자", "작성 시간"];
export default function ScrollAutoHidePreview() {
const scrollContainerRef = useRef<HTMLDivElement>(null);
return (
<Box
width="360px"
borderWidth={1}
borderColor="stroke.neutralMuted"
borderRadius="r3"
bg="bg.layerDefault"
overflowX="hidden"
overflowY="hidden"
>
<Box as="header" px="spacingX.globalGutter" py="x4">
<Text textStyle="t6Bold" color="fg.neutral">
Demo
</Text>
</Box>
<TabsRoot defaultValue="recommendations" size="medium" triggerLayout="fill">
<TabsList>
<TabsTrigger value="recommendations">추천</TabsTrigger>
<TabsTrigger value="subscriptions">구독</TabsTrigger>
</TabsList>
</TabsRoot>
<Box
ref={scrollContainerRef}
data-testid="scroll-auto-hide-container"
height="360px"
overflowY="auto"
>
<ScrollAutoHide
scrollContainerRef={scrollContainerRef}
data-testid="scroll-auto-hide-root"
asChild
>
<Flex
gap="spacingX.betweenChips"
px="spacingX.globalGutter"
py="x4"
bg="bg.layerDefault"
overflowX="auto"
style={{ zIndex: 1 }}
>
{FILTERS.map((filter) => (
<Chip.Button key={filter} size="small">
<Chip.Label>{filter}</Chip.Label>
<Chip.SuffixIcon>
<Icon svg={<IconChevronDownFill />} />
</Chip.SuffixIcon>
</Chip.Button>
))}
</Flex>
</ScrollAutoHide>
<VStack as="ul" gap="x5" px="spacingX.globalGutter" py="x4">
{ARTICLES.map((article) => (
<Box as="li" key={article.title}>
<VStack gap="x2_5">
<HStack gap="x1_5" align="center">
<Avatar fallback={<IdentityPlaceholder identity="person" />} size="20" />
<Text textStyle="t4Medium" color="fg.neutral">
{article.author}
</Text>
</HStack>
<VStack gap="x2">
<VStack gap="x1">
<Text as="h3" textStyle="t5Bold" color="fg.neutral" maxLines={1}>
{article.title}
</Text>
<Text as="p" textStyle="t4Regular" color="fg.neutralMuted" maxLines={2}>
{article.content}
</Text>
</VStack>
<HStack align="center" gap="x2">
{article.popular && (
<Badge variant="outline" tone="brand">
인기
</Badge>
)}
<TagGroupRoot size="t4" tone="neutralSubtle">
<TagGroupItem label={article.category} />
<TagGroupItem label="서초2동" />
<TagGroupItem label={article.date} />
</TagGroupRoot>
</HStack>
</VStack>
</VStack>
</Box>
))}
</VStack>
</Box>
</Box>
);
}Installation
Dependency:
npm install @radix-ui/react-compose-refs @radix-ui/react-slotpnpm add @radix-ui/react-compose-refs @radix-ui/react-slotyarn add @radix-ui/react-compose-refs @radix-ui/react-slotbun add @radix-ui/react-compose-refs @radix-ui/react-slotSnippet:
npx @seed-design/cli@latest add breeze:scroll-auto-hidepnpm dlx @seed-design/cli@latest add breeze:scroll-auto-hideyarn dlx @seed-design/cli@latest add breeze:scroll-auto-hidebun x @seed-design/cli@latest add breeze:scroll-auto-hide의존성 설치
npm install @radix-ui/react-compose-refs @radix-ui/react-slot @seed-design/cssyarn add @radix-ui/react-compose-refs @radix-ui/react-slot @seed-design/csspnpm add @radix-ui/react-compose-refs @radix-ui/react-slot @seed-design/cssbun add @radix-ui/react-compose-refs @radix-ui/react-slot @seed-design/css컴포넌트 코드 복사
/**
* @file breeze:scroll-auto-hide
**/
"use client";
import { useComposedRefs } from "@radix-ui/react-compose-refs";
import { Slot } from "@radix-ui/react-slot";
import { vars } from "@seed-design/css/vars";
import * as React from "react";
import styles from "./scroll-auto-hide.module.css";
const SNAP_DURATION_MS = 200;
const SNAP_THRESHOLD_RATIO = 0.5;
const SCROLL_SETTLE_DELAY_MS = 120;
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
type ScrollTimelineConstructor = new (options: {
source: Element;
axis: "block";
}) => AnimationTimeline;
type ScrollAutoHideOwnProps = {
/** 스크롤을 감지할 컨테이너의 ref */
scrollContainerRef: React.RefObject<HTMLElement | null>;
};
type ScrollAutoHideAsChildProps = ScrollAutoHideOwnProps &
Omit<React.HTMLAttributes<HTMLElement>, "children"> & {
asChild: true;
children: React.ReactElement;
};
type ScrollAutoHideDefaultProps = ScrollAutoHideOwnProps &
React.HTMLAttributes<HTMLDivElement> & {
asChild?: false;
};
export type ScrollAutoHideProps = ScrollAutoHideAsChildProps | ScrollAutoHideDefaultProps;
const SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
function joinClassNames(...classNames: Array<string | undefined>) {
return classNames.filter(Boolean).join(" ");
}
function parseTranslateY(value: string) {
if (!value || value === "none") return 0;
const values = value.slice(value.indexOf("(") + 1, -1).split(/[ ,]+/);
const yIndex = value.startsWith("matrix3d(") ? 13 : value.startsWith("matrix(") ? 5 : 1;
const y = Number.parseFloat(values[yIndex] ?? "0");
return Number.isFinite(y) ? y : 0;
}
function hasTransitionDuration(value: string) {
return value.split(",").some((duration) => Number.parseFloat(duration) > 0);
}
export const ScrollAutoHide = React.forwardRef<HTMLElement, ScrollAutoHideProps>(
function ScrollAutoHide({ asChild, scrollContainerRef, className, ...props }, forwardedRef) {
const rootRef = React.useRef<HTMLElement>(null);
const composedRefs = useComposedRefs(forwardedRef, rootRef);
React.useEffect(() => {
const root = rootRef.current;
const scrollContainer = scrollContainerRef.current;
if (!root || !scrollContainer) return;
const initialTransform = root.style.transform;
const initialTransition = root.style.transition;
const initialWillChange = root.style.willChange;
const mediaQuery = window.matchMedia(REDUCED_MOTION_QUERY);
const supportsScrollEnd = typeof scrollContainer.onscrollend !== "undefined";
const ScrollTimeline = Reflect.get(window, "ScrollTimeline") as unknown;
let scrollTimeline: AnimationTimeline | undefined;
if (typeof ScrollTimeline === "function") {
try {
scrollTimeline = new (ScrollTimeline as ScrollTimelineConstructor)({
source: scrollContainer,
axis: "block",
});
} catch {
scrollTimeline = undefined;
}
}
const getScrollTop = () => {
const maxScrollTop = Math.max(
0,
scrollContainer.scrollHeight - scrollContainer.clientHeight,
);
return Math.min(maxScrollTop, Math.max(0, scrollContainer.scrollTop));
};
let height = 0;
let naturalOffset = 0;
let translateY = 0;
let previousScrollTop = getScrollTop();
let isSettling = false;
let isTouching = false;
let trackingAnimation: Animation | undefined;
let trackingDirection = 0;
let settleTimer: ReturnType<typeof setTimeout> | undefined;
let settleTransition = initialTransition;
let skipNextSettleAnimation = false;
const clamp = (value: number, min: number) => Math.min(0, Math.max(min, value));
const computeMinTranslate = (scrollTop: number) => {
if (height === 0 || scrollTop <= naturalOffset) return 0;
if (scrollTop >= naturalOffset + height) return -height;
return -(scrollTop - naturalOffset);
};
const applyTranslate = (value: number) => {
translateY = value;
root.style.transform = `translate3d(0px, ${value}px, 0px)`;
};
const stopTracking = () => {
trackingAnimation?.cancel();
trackingAnimation = undefined;
trackingDirection = 0;
};
const syncTracking = () => {
if (!trackingAnimation) return;
const renderedTranslateY = parseTranslateY(getComputedStyle(root).transform);
stopTracking();
applyTranslate(clamp(renderedTranslateY, computeMinTranslate(getScrollTop())));
};
const startTracking = (direction: number, scrollTop: number) => {
if (!scrollTimeline || direction === 0 || direction === trackingDirection) return;
stopTracking();
const maxScrollTop = Math.max(
0,
scrollContainer.scrollHeight - scrollContainer.clientHeight,
);
if (maxScrollTop === 0) return;
let rangeStart: number;
let rangeEnd: number;
let rangeStartTranslate: number;
let rangeEndTranslate: number;
if (direction > 0) {
rangeStart = translateY === 0 ? Math.max(scrollTop, naturalOffset) : scrollTop;
rangeEnd = Math.min(maxScrollTop, rangeStart + translateY + height);
rangeStartTranslate = translateY;
rangeEndTranslate = translateY - (rangeEnd - rangeStart);
} else {
rangeEnd = scrollTop;
rangeStart = Math.max(0, rangeEnd + translateY);
rangeStartTranslate = Math.min(0, translateY + rangeEnd - rangeStart);
rangeEndTranslate = translateY;
}
if (rangeStart === rangeEnd) {
applyTranslate(translateY);
return;
}
const startOffset = rangeStart / maxScrollTop;
const endOffset = rangeEnd / maxScrollTop;
const startTransform = `translate3d(0px, ${rangeStartTranslate}px, 0px)`;
const endTransform = `translate3d(0px, ${rangeEndTranslate}px, 0px)`;
applyTranslate(translateY);
try {
trackingAnimation = root.animate(
[
{ transform: startTransform, offset: 0 },
{ transform: startTransform, offset: startOffset },
{ transform: endTransform, offset: endOffset },
{ transform: endTransform, offset: 1 },
],
{ fill: "both", timeline: scrollTimeline },
);
} catch {
scrollTimeline = undefined;
applyTranslate(translateY);
return;
}
trackingDirection = direction;
};
const clearSettleTimer = () => {
if (settleTimer === undefined) return;
clearTimeout(settleTimer);
settleTimer = undefined;
};
const finishSettling = () => {
if (isSettling) {
isSettling = false;
root.style.transition = settleTransition;
}
root.style.willChange = initialWillChange;
};
const cancelSettling = () => {
if (!isSettling) return;
const renderedTranslateY = parseTranslateY(getComputedStyle(root).transform);
finishSettling();
applyTranslate(clamp(renderedTranslateY, computeMinTranslate(getScrollTop())));
};
const settle = () => {
clearSettleTimer();
syncTracking();
if (height === 0) return;
if (mediaQuery.matches) {
finishSettling();
root.style.transition = initialTransition;
applyTranslate(0);
skipNextSettleAnimation = false;
return;
}
const visibleRatio = 1 + translateY / height;
const minTranslate = computeMinTranslate(getScrollTop());
const target = visibleRatio >= SNAP_THRESHOLD_RATIO ? 0 : clamp(-height, minTranslate);
if (target === translateY) {
finishSettling();
skipNextSettleAnimation = false;
return;
}
if (skipNextSettleAnimation) {
finishSettling();
root.style.transition = initialTransition;
applyTranslate(target);
skipNextSettleAnimation = false;
return;
}
settleTransition = root.style.transition;
const computedStyle = getComputedStyle(root);
const computedTransition = computedStyle.transition;
const translateTransition = `transform ${SNAP_DURATION_MS}ms ${vars.$timingFunction.enter}`;
isSettling = true;
root.style.willChange = "transform";
root.style.transition = hasTransitionDuration(computedStyle.transitionDuration)
? `${computedTransition}, ${translateTransition}`
: translateTransition;
applyTranslate(target);
};
const scheduleSettle = () => {
if (supportsScrollEnd || isTouching) return;
clearSettleTimer();
settleTimer = setTimeout(settle, SCROLL_SETTLE_DELAY_MS);
};
const measure = () => {
syncTracking();
cancelSettling();
const previousPosition = root.style.position;
const previousTransition = root.style.transition;
const previousTransform = root.style.transform;
const visibleRatio = height === 0 ? 1 : 1 + translateY / height;
root.style.position = "relative";
root.style.transition = "none";
root.style.transform = "none";
const rootRect = root.getBoundingClientRect();
const scrollRect = scrollContainer.getBoundingClientRect();
const scrollTop = getScrollTop();
height = rootRect.height;
naturalOffset = scrollTop + rootRect.top - scrollRect.top - scrollContainer.clientTop;
root.style.position = previousPosition;
root.style.transition = previousTransition;
root.style.transform = previousTransform;
const nextTranslate = height === 0 ? 0 : -(1 - visibleRatio) * height;
applyTranslate(clamp(nextTranslate, computeMinTranslate(scrollTop)));
};
const handleScroll = () => {
clearSettleTimer();
cancelSettling();
const scrollTop = getScrollTop();
const scrollDelta = scrollTop - previousScrollTop;
previousScrollTop = scrollTop;
if (mediaQuery.matches) {
stopTracking();
finishSettling();
applyTranslate(0);
return;
}
root.style.willChange = "transform";
translateY = clamp(translateY - scrollDelta, computeMinTranslate(scrollTop));
if (scrollTimeline && !skipNextSettleAnimation) {
startTracking(Math.sign(scrollDelta), scrollTop);
} else {
stopTracking();
applyTranslate(translateY);
}
scheduleSettle();
};
const handleInteractionStart = () => {
clearSettleTimer();
cancelSettling();
syncTracking();
skipNextSettleAnimation = false;
};
const handleTouchStart = () => {
isTouching = true;
handleInteractionStart();
};
const handleTouchEnd = () => {
isTouching = false;
if (!supportsScrollEnd) scheduleSettle();
};
const handleFocusIn = () => {
clearSettleTimer();
cancelSettling();
stopTracking();
previousScrollTop = getScrollTop();
root.style.transition = initialTransition;
root.style.willChange = initialWillChange;
applyTranslate(0);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!SCROLL_KEYS.has(event.key)) return;
if (!(event.target instanceof Node) || !scrollContainer.contains(event.target)) return;
skipNextSettleAnimation = true;
cancelSettling();
syncTracking();
};
const handleTransitionComplete = (event: TransitionEvent) => {
if (event.target !== root || event.propertyName !== "transform") return;
finishSettling();
};
const handleReducedMotionChange = () => {
clearSettleTimer();
cancelSettling();
stopTracking();
finishSettling();
root.style.transition = initialTransition;
applyTranslate(0);
previousScrollTop = getScrollTop();
skipNextSettleAnimation = false;
if (!mediaQuery.matches) measure();
};
measure();
const resizeObserver = new ResizeObserver(measure);
resizeObserver.observe(root);
resizeObserver.observe(scrollContainer);
scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
scrollContainer.addEventListener("scrollend", settle);
scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
scrollContainer.addEventListener("touchcancel", handleTouchEnd, { passive: true });
scrollContainer.addEventListener("wheel", handleInteractionStart, { passive: true });
root.addEventListener("focusin", handleFocusIn);
root.addEventListener("transitionend", handleTransitionComplete);
root.addEventListener("transitioncancel", handleTransitionComplete);
root.ownerDocument.addEventListener("keydown", handleKeyDown);
mediaQuery.addEventListener("change", handleReducedMotionChange);
return () => {
clearSettleTimer();
stopTracking();
resizeObserver.disconnect();
scrollContainer.removeEventListener("scroll", handleScroll);
scrollContainer.removeEventListener("scrollend", settle);
scrollContainer.removeEventListener("touchstart", handleTouchStart);
scrollContainer.removeEventListener("touchend", handleTouchEnd);
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
scrollContainer.removeEventListener("wheel", handleInteractionStart);
root.removeEventListener("focusin", handleFocusIn);
root.removeEventListener("transitionend", handleTransitionComplete);
root.removeEventListener("transitioncancel", handleTransitionComplete);
root.ownerDocument.removeEventListener("keydown", handleKeyDown);
mediaQuery.removeEventListener("change", handleReducedMotionChange);
root.style.transform = initialTransform;
root.style.transition = initialTransition;
root.style.willChange = initialWillChange;
};
}, [scrollContainerRef]);
const Component = asChild ? Slot : "div";
return (
<Component {...props} ref={composedRefs} className={joinClassNames(styles.root, className)} />
);
},
);
ScrollAutoHide.displayName = "ScrollAutoHide";
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
스타일 복사
.root {
position: sticky;
top: 0;
}
Usage
스크롤이 일어나는 요소의 ref를 scrollContainerRef로 전달합니다. 컴포넌트가 position: sticky와 top: 0을 적용하므로 스크롤 컨테이너 안에 배치해야 합니다.
const scrollContainerRef = useRef<HTMLDivElement>(null);
return (
<div ref={scrollContainerRef} style={{ height: 480, overflowY: "auto" }}>
<ScrollAutoHide scrollContainerRef={scrollContainerRef}>
<nav>필터</nav>
</ScrollAutoHide>
<main>{/* 스크롤할 콘텐츠 */}</main>
</div>
);다른 요소를 루트로 사용하기
asChild를 사용하면 불필요한 래퍼 없이 자식 요소를 루트로 사용합니다. 자식은 하나의 React 요소여야 합니다.
<ScrollAutoHide scrollContainerRef={scrollContainerRef} asChild>
<header>필터</header>
</ScrollAutoHide>동작과 스타일
- 스크롤 중에는 이동 거리를 그대로 따라갑니다. 스크롤이 끝나면 50% 지점을 기준으로 완전히 숨거나 드러납니다.
- 스냅은 200ms 동안 SEED의
enter타이밍 함수인cubic-bezier(0, 0, 0.15, 1)을 사용합니다. - 모션 감소 설정에서는 위치 이동을 피하기 위해 자동 숨김을 비활성화합니다.
- 숨겨진 영역 안으로 키보드 포커스가 이동하면 내용을 즉시 드러냅니다.
- 컴포넌트는 넓은 웹뷰 버전에서 합성할 수 있도록
translate3d()를 사용합니다.ScrollTimeline을 지원하는 브라우저에서는 스크롤과 이동을 브라우저가 직접 연결하고, 그 외 환경에서는 같은 동작을 스크롤 이벤트로 처리합니다. - 루트 요소의
transform은 내부 이동에 사용합니다. 별도의transform이 필요하면 자식 요소에 적용합니다. - 배경색과
z-index는 사용 환경에 맞게 직접 지정합니다. 배경색이 없으면 숨겨지는 동안 아래 콘텐츠가 비칠 수 있습니다.
Props
Prop
Type
asChild?boolean | undefinedchildren?React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | React.ReactNodeLast updated on