bun add @radix-ui/react-compose-refs @seed-design/react-presence
컴포넌트 코드 복사
/** * @file breeze:blur-swap **/"use client";import { useComposedRefs } from "@radix-ui/react-compose-refs";import { usePresence } from "@seed-design/react-presence";import * as React from "react";import styles from "./blur-swap.module.css";const DEFAULT_BLUR = 4;const DEFAULT_OFFSET = 8;const DEFAULT_DURATION_MS = 400;const useIsomorphicLayoutEffect = typeof document === "undefined" ? React.useEffect : React.useLayoutEffect;function joinClassNames(...classNames: Array<string | false | undefined>) { return classNames.filter(Boolean).join(" ");}interface Layer { key: React.Key; children: React.ReactNode;}interface BlurSwapLayerProps { /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */ layerKey: React.Key; present: boolean; initial: boolean; onExitComplete: (key: React.Key) => void; children: React.ReactNode;}function BlurSwapLayer({ layerKey, present, initial, onExitComplete, children,}: BlurSwapLayerProps) { const { isPresent, ref } = usePresence(present); React.useEffect(() => { if (isPresent) return; onExitComplete(layerKey); }, [isPresent, layerKey, onExitComplete]); if (!isPresent) return null; return ( <div ref={ref} className={styles.layer} // `usePresence`는 `animation-name`이 바뀌는 걸로 퇴장 시작을 감지한다. 그래서 상태는 // 아직 살아 있다는 `isPresent`가 아니라, 나가라는 신호인 `present`로 그려야 한다. data-state={present ? "open" : "closed"} {...(initial && { "data-initial": "" })} // 나가는 레이어는 사라지는 중일 뿐 아직 DOM에 있다. 보조기술이 두 벌을 겹쳐 읽거나 // 포인터가 잔상을 집는 걸 막는다. {...(!present && { inert: true })} > {children} </div> );}export interface BlurSwapProps { /** * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다. */ activeKey: React.Key; /** * `activeKey`에 해당하는 콘텐츠 */ children: React.ReactNode; /** * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다. * @default 4 */ blur?: number; /** * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다. * 0이면 제자리에서 crossfade만 한다. * @default 8 */ offset?: number; /** * 전환 길이 (ms) * @default 400 */ duration?: number; /** * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위 * * - `"auto"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다. * - `"height"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다. * - `"none"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우. * * @default "auto" */ size?: "auto" | "height" | "none"; /** * 추가 클래스명 */ className?: string; /** * 컨테이너 스타일 */ style?: React.CSSProperties;}export const BlurSwap = React.forwardRef<HTMLDivElement, BlurSwapProps>(function BlurSwap( { activeKey, children, blur = DEFAULT_BLUR, offset = DEFAULT_OFFSET, duration = DEFAULT_DURATION_MS, size = "auto", className, style, }, forwardedRef,) { const rootRef = React.useRef<HTMLDivElement>(null); const composedRefs = useComposedRefs(forwardedRef, rootRef); const contentRef = React.useRef<HTMLDivElement>(null); /** * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가 * 들고 사라져야 할 옛 내용이 여기 남아 있다. */ const committedChildrenRef = React.useRef(children); const [stack, setStack] = React.useState(() => ({ currentKey: activeKey, exiting: [] as Layer[], hasSwapped: false, })); // 렌더 도중 state를 맞춘다. 이 시점을 놓치면 옛 children을 붙잡을 기회가 사라진다. if (stack.currentKey !== activeKey) { setStack((previous) => ({ currentKey: activeKey, exiting: [ // 되돌아온 키는 나가는 목록에서 뺀다. 남겨두면 같은 key가 둘이 된다. ...previous.exiting.filter( (layer) => layer.key !== previous.currentKey && layer.key !== activeKey, ), { key: previous.currentKey, children: committedChildrenRef.current }, ], hasSwapped: true, })); } useIsomorphicLayoutEffect(() => { committedChildrenRef.current = children; }); useIsomorphicLayoutEffect(() => { const root = rootRef.current; const content = contentRef.current; if (!root || !content) return; if (size === "none") { root.style.width = ""; root.style.height = ""; delete root.dataset.resize; return; } /** * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는 * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다. */ const sync = () => { const width = size === "auto" ? content.offsetWidth : root.offsetWidth; const height = content.offsetHeight; // 어느 방향으로 가는지는 지금 그려진 컨테이너와 비교해서만 알 수 있다. 전환 도중에 // 다시 불려도 남은 거리 기준으로 판단이 다시 선다. root.dataset.resize = width > root.offsetWidth || height > root.offsetHeight ? "grow" : "shrink"; root.style.width = size === "auto" ? `${width}px` : ""; root.style.height = `${height}px`; }; sync(); const observer = new ResizeObserver(sync); observer.observe(content); return () => observer.disconnect(); }, [activeKey, size]); const handleExitComplete = React.useCallback((key: React.Key) => { setStack((previous) => ({ ...previous, exiting: previous.exiting.filter((layer) => layer.key !== key), })); }, []); const rootStyle: React.CSSProperties & Record<`--${string}`, string> = { ...style, "--blur-swap-blur": `${blur}px`, "--blur-swap-offset": `${offset}px`, "--blur-swap-duration": `${duration}ms`, }; // 나가는 레이어와 현재 레이어가 한 배열에 있어야 자리를 옮겨도 React가 같은 인스턴스로 잇는다. const layers: Array<Layer & { present: boolean; initial: boolean }> = [ ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })), { key: activeKey, children, present: true, initial: !stack.hasSwapped }, ]; return ( <div ref={composedRefs} className={joinClassNames(styles.root, size === "auto" && styles.inline, className)} style={rootStyle} > <div ref={contentRef} className={joinClassNames(styles.content, size === "auto" && styles.contentAuto)} > {layers.map((layer) => ( <BlurSwapLayer key={layer.key} layerKey={layer.key} present={layer.present} initial={layer.initial} onExitComplete={handleExitComplete} > {layer.children} </BlurSwapLayer> ))} </div> </div> );});BlurSwap.displayName = "BlurSwap";/** * 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. */
스타일 복사
/* * 자르지 않는 건 의도다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿으므로, 자르면 그동안 * 두 콘텐츠가 상자를 넘긴 만큼이 계속 잘려 나간다. blur도 반경만큼 상자 밖으로 번지기 때문에 * 가장자리가 흐려지는 대신 직선으로 끊긴다. */.root { --blur-swap-blur: 4px; --blur-swap-offset: 8px; --blur-swap-duration: 400ms; --blur-swap-ease: var(--seed-timing-function-easing, cubic-bezier(0.35, 0, 0.35, 1)); --blur-swap-grow-ease: var(--seed-timing-function-enter, cubic-bezier(0, 0, 0.15, 1)); --blur-swap-shrink-ease: var(--seed-timing-function-exit, cubic-bezier(0.35, 0, 1, 1)); position: relative; display: block; transition: width var(--blur-swap-duration) var(--blur-swap-grow-ease), height var(--blur-swap-duration) var(--blur-swap-grow-ease);}/* * 커질 때는 컨테이너가 앞서 나가고, 작아질 때는 버틴다. 들어오는 콘텐츠는 첫 프레임부터 * 최종 크기로 그려지고 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너가 어느 쪽으로 * 가든 큰 쪽을 늦게 놓아줘야 상자 밖에 콘텐츠가 나와 있는 시간이 짧아진다. */.root[data-resize="shrink"] { transition-timing-function: var(--blur-swap-shrink-ease);}.inline { display: inline-block; vertical-align: top;}.content { position: relative; width: 100%;}/* * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가 * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다. */.contentAuto { width: max-content;}/* * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에 * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다. */.layer[data-state="open"] { animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both;}/* * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고, * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다. */.layer[data-state="closed"] { position: absolute; inset-block-start: 0; inset-inline-start: 0; width: 100%; animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both;}.contentAuto .layer[data-state="closed"] { width: max-content;}/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */.layer[data-initial][data-state="open"] { animation: none;}@keyframes blurSwapEnter { from { opacity: 0; filter: blur(var(--blur-swap-blur)); transform: translateY(var(--blur-swap-offset)); } to { opacity: 1; filter: blur(0); transform: translateY(0); }}@keyframes blurSwapExit { from { opacity: 1; filter: blur(0); transform: translateY(0); } to { opacity: 0; filter: blur(var(--blur-swap-blur)); transform: translateY(calc(-1 * var(--blur-swap-offset))); }}@keyframes blurSwapEnterReduced { from { opacity: 0; } to { opacity: 1; }}@keyframes blurSwapExitReduced { from { opacity: 1; } to { opacity: 0; }}/* * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로 * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다. */@media (prefers-reduced-motion: reduce) { .root { transition: none; } .layer[data-state="open"] { animation-name: blurSwapEnterReduced; } .layer[data-state="closed"] { animation-name: blurSwapExitReduced; }}
children이 비어 있는 키도 하나의 상태입니다. 빈 쪽에서 콘텐츠 쪽으로 넘어가면 등장이 되고, 반대로 넘어가면 퇴장이 됩니다. 컨테이너도 0에서부터 자라고 0으로 돌아갑니다.
여기서는 size="height"를 씁니다. "auto"는 너비도 0에서 시작하는데, 콘텐츠는 컨테이너의 시작 모서리에 붙어 있으므로 가운데 정렬된 자리에서는 상자가 자라는 동안 콘텐츠가 옆으로 밀립니다. 빈 상태를 오가는 경우에는 그 폭이 콘텐츠 너비의 절반이나 되어 눈에 걸립니다.
blur는 전환 중 최대 blur 반경(px), offset은 콘텐츠가 흐르는 거리(px)입니다. offset이 양수면 아래에서 위로, 음수면 위에서 아래로 흐릅니다. 한쪽을 0으로 두면 그 축만 빠집니다 — offset이 0이면 제자리에서 흐려지기만 하고, blur가 0이면 흐림 없이 밀려나기만 합니다.
퇴장은 @seed-design/react-presence의 usePresence가 붙잡습니다. data-state가 closed로 바뀌면서 animation-name이 달라지는 것을 감지해, 애니메이션이 끝날 때까지 DOM에 남겨둡니다. 그래서 퇴장 애니메이션을 지우면 나가는 콘텐츠가 사라지지 않습니다. 전환을 없애고 싶다면 애니메이션을 지우는 대신 duration을 0으로 두세요.
나가는 콘텐츠는 position: absolute로 흐름 밖에 놓입니다. 들어오는 콘텐츠가 곧바로 자리를 차지하므로, 컨테이너 크기는 항상 들어오는 쪽을 기준으로 계산됩니다.
루트는 콘텐츠를 자르지 않습니다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿기 때문에, 자르면 그동안 넘치는 만큼이 계속 잘려 나갑니다. blur도 반경만큼 상자 밖으로 번지므로 가장자리가 흐려지는 대신 직선으로 끊깁니다. 대신 전환 중에는 콘텐츠가 컨테이너 밖으로 잠깐 비어져 나올 수 있으니, 빽빽한 자리에 놓을 때는 가장 큰 콘텐츠를 기준으로 여백을 잡아 둡니다.
크기는 ResizeObserver로 실측한 값을 루트에 적고 CSS transition으로 따라갑니다. activeKey가 그대로인 채 콘텐츠 자체가 커지거나 작아지는 경우에도 같은 전환을 탑니다. 커지는 중인지 작아지는 중인지는 루트의 data-resize에 적혀 타이밍 함수를 고릅니다.
모션 감소 설정에서는 blur와 이동을 뺀 keyframes로 바뀌어 crossfade만 남고, 크기는 전환 없이 즉시 맞춰집니다. JavaScript는 관여하지 않습니다.
나가는 콘텐츠에는 inert가 걸립니다. 전환 중 같은 내용이 두 번 읽히거나, 사라지는 쪽이 포인터를 가로채지 않습니다.
filter는 새로운 stacking context를 만듭니다. 콘텐츠가 컨테이너 밖으로 겹쳐 나와야 한다면 BlurSwap 바깥에서 처리합니다.