From 578dbd64bd414f6e5aa7f347964db55b273ea816 Mon Sep 17 00:00:00 2001 From: Joo Chanhwi <56245920+te6-in@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:13:07 +0900 Subject: [PATCH 1/5] feat(breeze): add blur swap component Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EMZNyR7oqm4pjdU4ACsgFG --- docs/content/breeze/components/blur-swap.mdx | 120 ++++++++++++ docs/content/breeze/index.mdx | 14 +- docs/examples/breeze/blur-swap/panel.tsx | 50 +++++ docs/examples/breeze/blur-swap/preview.tsx | 30 +++ docs/examples/breeze/blur-swap/tuning.tsx | 43 +++++ docs/public/__docs__/index.json | 17 ++ .../__registry__/react/breeze/blur-swap.json | 17 ++ .../__registry__/react/breeze/index.json | 15 ++ .../breeze/blur-swap/blur-swap.module.css | 22 +++ .../react/breeze/blur-swap/blur-swap.test.tsx | 108 +++++++++++ .../react/breeze/blur-swap/blur-swap.tsx | 182 ++++++++++++++++++ docs/registry/react/registry-breeze.ts | 5 + 12 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 docs/content/breeze/components/blur-swap.mdx create mode 100644 docs/examples/breeze/blur-swap/panel.tsx create mode 100644 docs/examples/breeze/blur-swap/preview.tsx create mode 100644 docs/examples/breeze/blur-swap/tuning.tsx create mode 100644 docs/public/__registry__/react/breeze/blur-swap.json create mode 100644 docs/registry/react/breeze/blur-swap/blur-swap.module.css create mode 100644 docs/registry/react/breeze/blur-swap/blur-swap.test.tsx create mode 100644 docs/registry/react/breeze/blur-swap/blur-swap.tsx diff --git a/docs/content/breeze/components/blur-swap.mdx b/docs/content/breeze/components/blur-swap.mdx new file mode 100644 index 0000000000..6ee03952e1 --- /dev/null +++ b/docs/content/breeze/components/blur-swap.mdx @@ -0,0 +1,120 @@ +--- +title: Blur Swap +description: 두 콘텐츠를 blur crossfade로 교차하는 컴포넌트 +--- + +`BlurSwap`은 `activeKey`가 바뀔 때 이전 콘텐츠와 새 콘텐츠를 같은 자리에 겹쳐두고 교차시킵니다. 나가는 쪽은 흐려지며 밀려나고 들어오는 쪽은 선명해지며 자리를 잡습니다. 컨테이너 크기는 들어오는 콘텐츠에 맞춰 함께 움직입니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/preview.tsx", + "codeblock": true + } + ``` + + +## Installation + +Dependency: + +```package-install +npm install motion +``` + +Snippet: + +```package-install +npx @seed-design/cli@latest add breeze:blur-swap +``` + + + +## Configuration + +blur-swap 컴포넌트는 motion의 `m.` 컴포넌트를 사용합니다. lazy loading을 위해 프로젝트에 MotionProvider를 설정해야 합니다. + +자세한 내용은 [Motion Lazy Motion 문서](https://motion.dev/docs/react-lazy-motion)를 참고하세요. + +```tsx title="MotionProvider.tsx" +"use client"; + +import { LazyMotion } from "motion/react"; +import type { ReactNode } from "react"; + +const loadFeatures = () => import("motion/react").then((res) => res.domAnimation); + +export function MotionProvider({ children }: { children: ReactNode }) { + return {children}; +} +``` + +```tsx title="index.tsx" +import { createRoot } from 'react-dom/client' +import App from './App.tsx' +import { MotionProvider } from './MotionProvider.tsx' + +createRoot(document.getElementById('root')!).render( + + + +) +``` + +## Usage + +지금 보여줄 콘텐츠와, 그 콘텐츠를 식별하는 `activeKey`를 함께 전달합니다. `activeKey`가 바뀔 때만 전환이 일어나므로, 콘텐츠 안쪽이 바뀌어도 키가 같으면 그대로 둡니다. + +```tsx +const [status, setStatus] = useState("ready"); + +return ( + + {STATUS_LABEL[status]} + +); +``` + +### 크기 애니메이션 범위 + +`size`로 컨테이너가 어느 축을 따라갈지 정합니다. + +- `"auto"` (기본값): 너비와 높이 모두 따라갑니다. 콘텐츠 너비를 그대로 쓰기 때문에 줄바꿈이 일어나지 않습니다. 라벨, 뱃지, 숫자처럼 한 줄짜리 콘텐츠에 씁니다. +- `"height"`: 높이만 따라가고 너비는 부모를 채웁니다. 카드나 패널처럼 폭이 정해진 콘텐츠에 씁니다. +- `"none"`: 크기를 건드리지 않습니다. 바깥에서 크기를 이미 정해둔 경우에 씁니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/panel.tsx", + "codeblock": true + } + ``` + + +### 전환 세기 조절 + +`blur`는 전환 중 최대 blur 반경(px), `offset`은 콘텐츠가 흐르는 거리(px)입니다. `offset`이 양수면 아래에서 위로, 음수면 위에서 아래로 흐릅니다. 둘 다 `0`이면 제자리 crossfade가 됩니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/tuning.tsx", + "codeblock": true + } + ``` + + +## 동작과 스타일 + +- 나가는 콘텐츠와 들어오는 콘텐츠의 `opacity`, blur, 이동이 하나의 전환으로 함께 움직입니다. 두 콘텐츠가 동시에 화면에 있는 구간이 곧 crossfade입니다. +- 나가는 콘텐츠는 `AnimatePresence`의 `popLayout`이 흐름 밖으로 빼내므로, 들어오는 콘텐츠가 곧바로 자리를 차지합니다. 그래서 컨테이너 크기는 항상 들어오는 쪽을 기준으로 계산됩니다. +- 루트에 `overflow: hidden`이 걸려 있습니다. `offset`만큼의 이동이 가장자리 밖에서 시작하고 끝나는 대신, 콘텐츠 밖으로 넘치는 그림자나 포커스 링은 잘립니다. +- 크기는 스프링으로 따라갑니다. `activeKey`가 그대로인 채 콘텐츠 자체가 커지거나 작아지는 경우에도 `ResizeObserver`로 같은 스프링을 태웁니다. +- 모션 감소 설정에서는 blur와 이동을 빼고 crossfade만 남기며, 크기는 애니메이션 없이 즉시 맞춥니다. +- 나가는 콘텐츠에는 `inert`가 걸립니다. 전환 중 같은 내용이 두 번 읽히거나, 사라지는 쪽이 포인터를 가로채지 않습니다. +- `filter`는 새로운 stacking context를 만듭니다. 콘텐츠가 컨테이너 밖으로 겹쳐 나와야 한다면 `BlurSwap` 바깥에서 처리합니다. + +## Props + + diff --git a/docs/content/breeze/index.mdx b/docs/content/breeze/index.mdx index 873c764329..cd758ecb7a 100644 --- a/docs/content/breeze/index.mdx +++ b/docs/content/breeze/index.mdx @@ -25,11 +25,21 @@ SEED Breeze는 다양한 프로젝트에서 바로 사용할 수 있는 유용 ## 사용 가능한 컴포넌트 - + + 더 많은 유용한 컴포넌트들이 곧 추가될 예정입니다! diff --git a/docs/examples/breeze/blur-swap/panel.tsx b/docs/examples/breeze/blur-swap/panel.tsx new file mode 100644 index 0000000000..50d81b7c98 --- /dev/null +++ b/docs/examples/breeze/blur-swap/panel.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Box, HStack, Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; + +const PLANS = [ + { id: "basic", name: "베이직", lines: ["월 4,900원", "기본 기능 전체"] }, + { + id: "pro", + name: "프로", + lines: ["월 9,900원", "기본 기능 전체", "우선 지원", "리포트 내보내기", "팀 멤버 5명"], + }, +]; + +export default function BlurSwapPanel() { + const [planId, setPlanId] = useState(PLANS[0].id); + + const plan = PLANS.find((candidate) => candidate.id === planId) ?? PLANS[0]; + + return ( + + + {PLANS.map((candidate) => ( + setPlanId(candidate.id)} + > + {candidate.name} + + ))} + + + + + + {plan.lines.map((line) => ( + + {line} + + ))} + + + + + ); +} diff --git a/docs/examples/breeze/blur-swap/preview.tsx b/docs/examples/breeze/blur-swap/preview.tsx new file mode 100644 index 0000000000..7987293019 --- /dev/null +++ b/docs/examples/breeze/blur-swap/preview.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; + +const STATUSES = ["결제 완료", "상품 준비 중", "배송 중", "배송 완료"]; + +export default function BlurSwapPreview() { + const [index, setIndex] = useState(0); + + return ( + + + + {STATUSES[index]} + + + + setIndex((current) => (current + 1) % STATUSES.length)} + > + 다음 상태 + + + ); +} diff --git a/docs/examples/breeze/blur-swap/tuning.tsx b/docs/examples/breeze/blur-swap/tuning.tsx new file mode 100644 index 0000000000..0f81de0e34 --- /dev/null +++ b/docs/examples/breeze/blur-swap/tuning.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { HStack, Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; + +const PRESETS = [ + { id: "flat", label: "blur 0 / offset 8", blur: 0, offset: 8 }, + { id: "default", label: "blur 4 / offset 8", blur: 4, offset: 8 }, + { id: "soft", label: "blur 12 / offset 20", blur: 12, offset: 20 }, +]; + +export default function BlurSwapTuning() { + const [count, setCount] = useState(1); + + return ( + + + {PRESETS.map((preset) => ( + + + + {count.toLocaleString()} + + + + {preset.label} + + + ))} + + + setCount((current) => current * 7 + 3)} + > + 값 바꾸기 + + + ); +} diff --git a/docs/public/__docs__/index.json b/docs/public/__docs__/index.json index c18e93d5f8..f0a36826f3 100644 --- a/docs/public/__docs__/index.json +++ b/docs/public/__docs__/index.json @@ -1907,6 +1907,23 @@ } ] }, + { + "id": "blur-swap", + "title": "Blur Swap", + "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", + "docUrl": "/breeze/components/blur-swap", + "snippetKey": "react/breeze:blur-swap", + "snippets": [ + { + "label": "react", + "path": "blur-swap/blur-swap.tsx" + }, + { + "label": "css", + "path": "blur-swap/blur-swap.module.css" + } + ] + }, { "id": "scroll-auto-hide", "title": "Scroll Auto Hide", diff --git a/docs/public/__registry__/react/breeze/blur-swap.json b/docs/public/__registry__/react/breeze/blur-swap.json new file mode 100644 index 0000000000..fa97c84b29 --- /dev/null +++ b/docs/public/__registry__/react/breeze/blur-swap.json @@ -0,0 +1,17 @@ +{ + "id": "blur-swap", + "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", + "dependencies": [ + "motion" + ], + "snippets": [ + { + "path": "blur-swap/blur-swap.tsx", + "content": "/**\n * @file breeze:blur-swap\n **/\n\n\"use client\";\n\nimport { AnimatePresence, useIsPresent, useReducedMotion, useSpring } from \"motion/react\";\nimport * as m from \"motion/react-m\";\nimport * as React from \"react\";\nimport styles from \"./blur-swap.module.css\";\n\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_OFFSET = 8;\nconst DEFAULT_DURATION = 0.3;\nconst CONTENT_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];\nconst SIZE_SPRING = { stiffness: 400, damping: 40 };\n\nconst useIsomorphicLayoutEffect =\n typeof document === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction joinClassNames(...classNames: Array) {\n return classNames.filter(Boolean).join(\" \");\n}\n\ninterface BlurSwapLayerProps {\n blur: number;\n duration: number;\n offset: number;\n children: React.ReactNode;\n}\n\nconst BlurSwapLayer = React.forwardRef(function BlurSwapLayer(\n { blur, duration, offset, children },\n forwardedRef,\n) {\n const isPresent = useIsPresent();\n\n return (\n \n {children}\n \n );\n});\n\nBlurSwapLayer.displayName = \"BlurSwapLayer\";\n\nexport interface BlurSwapProps {\n /**\n * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다.\n */\n activeKey: React.Key;\n\n /**\n * `activeKey`에 해당하는 콘텐츠\n */\n children: React.ReactNode;\n\n /**\n * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다.\n * @default 4\n */\n blur?: number;\n\n /**\n * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다.\n * 0이면 제자리에서 crossfade만 한다.\n * @default 8\n */\n offset?: number;\n\n /**\n * 전환 길이 (초)\n * @default 0.3\n */\n duration?: number;\n\n /**\n * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위\n *\n * - `\"auto\"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다.\n * - `\"height\"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다.\n * - `\"none\"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우.\n *\n * @default \"auto\"\n */\n size?: \"auto\" | \"height\" | \"none\";\n\n /**\n * 추가 클래스명\n */\n className?: string;\n\n /**\n * 컨테이너 스타일\n */\n style?: React.CSSProperties;\n}\n\nexport const BlurSwap = React.forwardRef(function BlurSwap(\n {\n activeKey,\n children,\n blur = DEFAULT_BLUR,\n offset = DEFAULT_OFFSET,\n duration = DEFAULT_DURATION,\n size = \"auto\",\n className,\n style,\n },\n forwardedRef,\n) {\n const prefersReducedMotion = useReducedMotion() ?? false;\n\n const contentRef = React.useRef(null);\n const hasMeasured = React.useRef(false);\n const [sizeApplied, setSizeApplied] = React.useState(false);\n\n const width = useSpring(0, SIZE_SPRING);\n const height = useSpring(0, SIZE_SPRING);\n\n /**\n * `activeKey`가 바뀌면 나가는 레이어는 AnimatePresence의 popLayout이 흐름 밖으로 빼내므로,\n * 이 시점의 콘텐츠 크기는 곧 들어오는 레이어의 크기다. 첫 측정만 jump로 흘려보내\n * 마운트 때 0에서 자라나지 않게 한다.\n */\n useIsomorphicLayoutEffect(() => {\n const content = contentRef.current;\n if (!content || size === \"none\") return;\n\n const sync = (animated: boolean) => {\n if (animated) {\n width.set(content.offsetWidth);\n height.set(content.offsetHeight);\n return;\n }\n\n width.jump(content.offsetWidth);\n height.jump(content.offsetHeight);\n };\n\n sync(hasMeasured.current && !prefersReducedMotion);\n hasMeasured.current = true;\n setSizeApplied(true);\n\n const observer = new ResizeObserver(() => sync(!prefersReducedMotion));\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [activeKey, size, prefersReducedMotion, width, height]);\n\n return (\n \n
\n \n \n {children}\n \n \n
\n \n );\n});\n\nBlurSwap.displayName = \"BlurSwap\";\n\n/**\n * This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.\n * You can extend this snippet however you want.\n */\n" + }, + { + "path": "blur-swap/blur-swap.module.css", + "content": ".root {\n position: relative;\n display: block;\n overflow: hidden;\n}\n\n.inline {\n display: inline-block;\n vertical-align: top;\n}\n\n.content {\n width: 100%;\n}\n\n/*\n * 컨테이너 너비가 스프링으로 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고,\n * 그 높이 변화가 다시 스프링에 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.\n */\n.contentAuto {\n width: max-content;\n}\n" + } + ] +} \ No newline at end of file diff --git a/docs/public/__registry__/react/breeze/index.json b/docs/public/__registry__/react/breeze/index.json index 84a04b22c1..10763e5384 100644 --- a/docs/public/__registry__/react/breeze/index.json +++ b/docs/public/__registry__/react/breeze/index.json @@ -16,6 +16,21 @@ "motion" ] }, + { + "snippets": [ + { + "path": "blur-swap/blur-swap.tsx" + }, + { + "path": "blur-swap/blur-swap.module.css" + } + ], + "id": "blur-swap", + "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", + "dependencies": [ + "motion" + ] + }, { "snippets": [ { diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.module.css b/docs/registry/react/breeze/blur-swap/blur-swap.module.css new file mode 100644 index 0000000000..d1aa0cdc04 --- /dev/null +++ b/docs/registry/react/breeze/blur-swap/blur-swap.module.css @@ -0,0 +1,22 @@ +.root { + position: relative; + display: block; + overflow: hidden; +} + +.inline { + display: inline-block; + vertical-align: top; +} + +.content { + width: 100%; +} + +/* + * 컨테이너 너비가 스프링으로 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, + * 그 높이 변화가 다시 스프링에 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다. + */ +.contentAuto { + width: max-content; +} diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx b/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx new file mode 100644 index 0000000000..36778bce7f --- /dev/null +++ b/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx @@ -0,0 +1,108 @@ +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { LazyMotion, domAnimation } from "motion/react"; +import type * as React from "react"; +import { BlurSwap } from "./blur-swap"; + +const originalResizeObserver = window.ResizeObserver; + +class ResizeObserverMock { + observe = mock(); + unobserve = mock(); + disconnect = mock(); +} + +function renderBlurSwap(props: Partial> = {}) { + const { activeKey = "a", children = A, ...rest } = props; + + const result = render( + + + {children} + + , + ); + + const root = result.container.firstElementChild; + if (!(root instanceof HTMLElement)) throw new Error("root not rendered"); + + return { ...result, root }; +} + +function swapTo( + rerender: ReturnType["rerender"], + key: string, + props: Partial> = {}, +) { + rerender( + + + {key.toUpperCase()} + + , + ); +} + +function readLayers(root: HTMLElement) { + return [...root.querySelectorAll("[data-testid='content']")].map((content) => { + const layer = content.parentElement; + if (!(layer instanceof HTMLElement)) throw new Error("layer not rendered"); + + return { + text: content.textContent, + inert: layer.hasAttribute("inert"), + filter: layer.style.filter, + transform: layer.style.transform, + }; + }); +} + +beforeEach(() => { + window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + window.ResizeObserver = originalResizeObserver; +}); + +describe("BlurSwap", () => { + it("className과 style을 루트 요소에 전달한다", () => { + const { root } = renderBlurSwap({ className: "consumer-class", style: { margin: "4px" } }); + + expect(root.className).toContain("consumer-class"); + expect(root.style.margin).toBe("4px"); + }); + + it("activeKey가 바뀌면 나가는 콘텐츠를 inert로 남긴 채 새 콘텐츠를 함께 그린다", () => { + const { root, rerender } = renderBlurSwap({ activeKey: "a" }); + + swapTo(rerender, "b"); + + expect(readLayers(root).map(({ text, inert }) => ({ text, inert }))).toEqual([ + { text: "A", inert: true }, + { text: "B", inert: false }, + ]); + }); + + it("들어오는 콘텐츠는 blur와 이동을 건 상태에서 등장한다", () => { + const { root, rerender } = renderBlurSwap({ activeKey: "a", blur: 12, offset: -20 }); + + swapTo(rerender, "b", { blur: 12, offset: -20 }); + + expect(readLayers(root).at(-1)).toEqual({ + text: "B", + inert: false, + filter: "blur(12px)", + transform: "translateY(-20px)", + }); + }); + + it("전환이 끝나면 나가는 콘텐츠를 DOM에서 제거한다", async () => { + const { root, rerender } = renderBlurSwap({ activeKey: "a", duration: 0 }); + + swapTo(rerender, "b", { duration: 0 }); + await act(() => new Promise((resolve) => setTimeout(resolve, 100))); + + expect(readLayers(root).map(({ text }) => text)).toEqual(["B"]); + }); +}); diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.tsx b/docs/registry/react/breeze/blur-swap/blur-swap.tsx new file mode 100644 index 0000000000..37fac11ecb --- /dev/null +++ b/docs/registry/react/breeze/blur-swap/blur-swap.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { AnimatePresence, useIsPresent, useReducedMotion, useSpring } from "motion/react"; +import * as m from "motion/react-m"; +import * as React from "react"; +import styles from "./blur-swap.module.css"; + +const DEFAULT_BLUR = 4; +const DEFAULT_OFFSET = 8; +const DEFAULT_DURATION = 0.3; +const CONTENT_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]; +const SIZE_SPRING = { stiffness: 400, damping: 40 }; + +const useIsomorphicLayoutEffect = + typeof document === "undefined" ? React.useEffect : React.useLayoutEffect; + +function joinClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(" "); +} + +interface BlurSwapLayerProps { + blur: number; + duration: number; + offset: number; + children: React.ReactNode; +} + +const BlurSwapLayer = React.forwardRef(function BlurSwapLayer( + { blur, duration, offset, children }, + forwardedRef, +) { + const isPresent = useIsPresent(); + + return ( + + {children} + + ); +}); + +BlurSwapLayer.displayName = "BlurSwapLayer"; + +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; + + /** + * 전환 길이 (초) + * @default 0.3 + */ + duration?: number; + + /** + * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위 + * + * - `"auto"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다. + * - `"height"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다. + * - `"none"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우. + * + * @default "auto" + */ + size?: "auto" | "height" | "none"; + + /** + * 추가 클래스명 + */ + className?: string; + + /** + * 컨테이너 스타일 + */ + style?: React.CSSProperties; +} + +export const BlurSwap = React.forwardRef(function BlurSwap( + { + activeKey, + children, + blur = DEFAULT_BLUR, + offset = DEFAULT_OFFSET, + duration = DEFAULT_DURATION, + size = "auto", + className, + style, + }, + forwardedRef, +) { + const prefersReducedMotion = useReducedMotion() ?? false; + + const contentRef = React.useRef(null); + const hasMeasured = React.useRef(false); + const [sizeApplied, setSizeApplied] = React.useState(false); + + const width = useSpring(0, SIZE_SPRING); + const height = useSpring(0, SIZE_SPRING); + + /** + * `activeKey`가 바뀌면 나가는 레이어는 AnimatePresence의 popLayout이 흐름 밖으로 빼내므로, + * 이 시점의 콘텐츠 크기는 곧 들어오는 레이어의 크기다. 첫 측정만 jump로 흘려보내 + * 마운트 때 0에서 자라나지 않게 한다. + */ + useIsomorphicLayoutEffect(() => { + const content = contentRef.current; + if (!content || size === "none") return; + + const sync = (animated: boolean) => { + if (animated) { + width.set(content.offsetWidth); + height.set(content.offsetHeight); + return; + } + + width.jump(content.offsetWidth); + height.jump(content.offsetHeight); + }; + + sync(hasMeasured.current && !prefersReducedMotion); + hasMeasured.current = true; + setSizeApplied(true); + + const observer = new ResizeObserver(() => sync(!prefersReducedMotion)); + observer.observe(content); + + return () => observer.disconnect(); + }, [activeKey, size, prefersReducedMotion, width, height]); + + return ( + +
+ + + {children} + + +
+
+ ); +}); + +BlurSwap.displayName = "BlurSwap"; diff --git a/docs/registry/react/registry-breeze.ts b/docs/registry/react/registry-breeze.ts index 51650e615e..76395dcde8 100644 --- a/docs/registry/react/registry-breeze.ts +++ b/docs/registry/react/registry-breeze.ts @@ -11,6 +11,11 @@ export const registryBreeze: Registry = { { path: "animate-number/animate-number.module.css" }, ], }, + { + id: "blur-swap", + description: "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", + snippets: [{ path: "blur-swap/blur-swap.tsx" }, { path: "blur-swap/blur-swap.module.css" }], + }, { id: "scroll-auto-hide", description: "스크롤 방향에 따라 고정 영역을 숨기고 드러내는 컴포넌트", From d6c62dc98319df5c51658a0698e02b71d109bc5f Mon Sep 17 00:00:00 2001 From: Joo Chanhwi <56245920+te6-in@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:37:19 +0900 Subject: [PATCH 2/5] refactor(breeze): drive blur swap with react-presence instead of motion Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EMZNyR7oqm4pjdU4ACsgFG --- bun.lock | 1 + docs/content/breeze/components/blur-swap.mdx | 53 ++--- docs/package.json | 1 + .../__registry__/react/breeze/blur-swap.json | 7 +- .../__registry__/react/breeze/index.json | 3 +- .../breeze/blur-swap/blur-swap.module.css | 104 ++++++++- .../react/breeze/blur-swap/blur-swap.test.tsx | 137 ++++++++---- .../react/breeze/blur-swap/blur-swap.tsx | 197 ++++++++++++------ 8 files changed, 353 insertions(+), 150 deletions(-) diff --git a/bun.lock b/bun.lock index 1ec074892e..6b6459bee7 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,7 @@ "@seed-design/migration-index": "2.0.1", "@seed-design/react": "2.3.0", "@seed-design/react-icon": "^0.7.4", + "@seed-design/react-presence": "^1.0.1", "@seed-design/rootage-artifacts": "workspace:*", "@seed-design/rootage-core": "0.0.0", "@seed-design/stackflow": "2.0.3", diff --git a/docs/content/breeze/components/blur-swap.mdx b/docs/content/breeze/components/blur-swap.mdx index 6ee03952e1..a76e15681f 100644 --- a/docs/content/breeze/components/blur-swap.mdx +++ b/docs/content/breeze/components/blur-swap.mdx @@ -19,7 +19,7 @@ description: 두 콘텐츠를 blur crossfade로 교차하는 컴포넌트 Dependency: ```package-install -npm install motion +npm install @seed-design/react-presence @radix-ui/react-compose-refs ``` Snippet: @@ -30,37 +30,6 @@ npx @seed-design/cli@latest add breeze:blur-swap -## Configuration - -blur-swap 컴포넌트는 motion의 `m.` 컴포넌트를 사용합니다. lazy loading을 위해 프로젝트에 MotionProvider를 설정해야 합니다. - -자세한 내용은 [Motion Lazy Motion 문서](https://motion.dev/docs/react-lazy-motion)를 참고하세요. - -```tsx title="MotionProvider.tsx" -"use client"; - -import { LazyMotion } from "motion/react"; -import type { ReactNode } from "react"; - -const loadFeatures = () => import("motion/react").then((res) => res.domAnimation); - -export function MotionProvider({ children }: { children: ReactNode }) { - return {children}; -} -``` - -```tsx title="index.tsx" -import { createRoot } from 'react-dom/client' -import App from './App.tsx' -import { MotionProvider } from './MotionProvider.tsx' - -createRoot(document.getElementById('root')!).render( - - - -) -``` - ## Usage 지금 보여줄 콘텐츠와, 그 콘텐츠를 식별하는 `activeKey`를 함께 전달합니다. `activeKey`가 바뀔 때만 전환이 일어나므로, 콘텐츠 안쪽이 바뀌어도 키가 같으면 그대로 둡니다. @@ -105,13 +74,25 @@ return ( ``` +세 값 모두 CSS 커스텀 프로퍼티로 내려가므로, prop 대신 스타일시트에서 한 번에 정해도 됩니다. + +```css +.my-swap { + --blur-swap-blur: 12px; + --blur-swap-offset: 20px; + --blur-swap-duration: 450ms; + --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 1); +} +``` + ## 동작과 스타일 -- 나가는 콘텐츠와 들어오는 콘텐츠의 `opacity`, blur, 이동이 하나의 전환으로 함께 움직입니다. 두 콘텐츠가 동시에 화면에 있는 구간이 곧 crossfade입니다. -- 나가는 콘텐츠는 `AnimatePresence`의 `popLayout`이 흐름 밖으로 빼내므로, 들어오는 콘텐츠가 곧바로 자리를 차지합니다. 그래서 컨테이너 크기는 항상 들어오는 쪽을 기준으로 계산됩니다. +- 나가는 콘텐츠와 들어오는 콘텐츠가 `opacity`, blur, 이동을 하나의 `@keyframes`로 함께 움직입니다. 셋이 같은 timing function을 공유하므로 blur는 저절로 crossfade 진행도에 묶입니다. +- 퇴장은 `@seed-design/react-presence`의 `usePresence`가 붙잡습니다. `data-state`가 `closed`로 바뀌면서 `animation-name`이 달라지는 것을 감지해, 애니메이션이 끝날 때까지 DOM에 남겨둡니다. 그래서 **퇴장 애니메이션을 지우면 나가는 콘텐츠가 사라지지 않습니다.** 전환을 없애고 싶다면 애니메이션을 지우는 대신 `duration`을 `0`으로 두세요. +- 나가는 콘텐츠는 `position: absolute`로 흐름 밖에 놓입니다. 들어오는 콘텐츠가 곧바로 자리를 차지하므로, 컨테이너 크기는 항상 들어오는 쪽을 기준으로 계산됩니다. - 루트에 `overflow: hidden`이 걸려 있습니다. `offset`만큼의 이동이 가장자리 밖에서 시작하고 끝나는 대신, 콘텐츠 밖으로 넘치는 그림자나 포커스 링은 잘립니다. -- 크기는 스프링으로 따라갑니다. `activeKey`가 그대로인 채 콘텐츠 자체가 커지거나 작아지는 경우에도 `ResizeObserver`로 같은 스프링을 태웁니다. -- 모션 감소 설정에서는 blur와 이동을 빼고 crossfade만 남기며, 크기는 애니메이션 없이 즉시 맞춥니다. +- 크기는 `ResizeObserver`로 실측한 값을 루트에 적고 CSS `transition`으로 따라갑니다. `activeKey`가 그대로인 채 콘텐츠 자체가 커지거나 작아지는 경우에도 같은 전환을 탑니다. +- 모션 감소 설정에서는 blur와 이동을 뺀 keyframes로 바뀌어 crossfade만 남고, 크기는 전환 없이 즉시 맞춰집니다. JavaScript는 관여하지 않습니다. - 나가는 콘텐츠에는 `inert`가 걸립니다. 전환 중 같은 내용이 두 번 읽히거나, 사라지는 쪽이 포인터를 가로채지 않습니다. - `filter`는 새로운 stacking context를 만듭니다. 콘텐츠가 컨테이너 밖으로 겹쳐 나와야 한다면 `BlurSwap` 바깥에서 처리합니다. diff --git a/docs/package.json b/docs/package.json index 58c88915f1..113bd8913e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -64,6 +64,7 @@ "@seed-design/migration-index": "2.0.1", "@seed-design/react": "2.3.0", "@seed-design/react-icon": "^0.7.4", + "@seed-design/react-presence": "^1.0.1", "@seed-design/rootage-artifacts": "workspace:*", "@seed-design/rootage-core": "0.0.0", "@seed-design/stackflow": "2.0.3", diff --git a/docs/public/__registry__/react/breeze/blur-swap.json b/docs/public/__registry__/react/breeze/blur-swap.json index fa97c84b29..43728ef649 100644 --- a/docs/public/__registry__/react/breeze/blur-swap.json +++ b/docs/public/__registry__/react/breeze/blur-swap.json @@ -2,16 +2,17 @@ "id": "blur-swap", "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", "dependencies": [ - "motion" + "@radix-ui/react-compose-refs", + "@seed-design/react-presence" ], "snippets": [ { "path": "blur-swap/blur-swap.tsx", - "content": "/**\n * @file breeze:blur-swap\n **/\n\n\"use client\";\n\nimport { AnimatePresence, useIsPresent, useReducedMotion, useSpring } from \"motion/react\";\nimport * as m from \"motion/react-m\";\nimport * as React from \"react\";\nimport styles from \"./blur-swap.module.css\";\n\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_OFFSET = 8;\nconst DEFAULT_DURATION = 0.3;\nconst CONTENT_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];\nconst SIZE_SPRING = { stiffness: 400, damping: 40 };\n\nconst useIsomorphicLayoutEffect =\n typeof document === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction joinClassNames(...classNames: Array) {\n return classNames.filter(Boolean).join(\" \");\n}\n\ninterface BlurSwapLayerProps {\n blur: number;\n duration: number;\n offset: number;\n children: React.ReactNode;\n}\n\nconst BlurSwapLayer = React.forwardRef(function BlurSwapLayer(\n { blur, duration, offset, children },\n forwardedRef,\n) {\n const isPresent = useIsPresent();\n\n return (\n \n {children}\n \n );\n});\n\nBlurSwapLayer.displayName = \"BlurSwapLayer\";\n\nexport interface BlurSwapProps {\n /**\n * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다.\n */\n activeKey: React.Key;\n\n /**\n * `activeKey`에 해당하는 콘텐츠\n */\n children: React.ReactNode;\n\n /**\n * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다.\n * @default 4\n */\n blur?: number;\n\n /**\n * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다.\n * 0이면 제자리에서 crossfade만 한다.\n * @default 8\n */\n offset?: number;\n\n /**\n * 전환 길이 (초)\n * @default 0.3\n */\n duration?: number;\n\n /**\n * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위\n *\n * - `\"auto\"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다.\n * - `\"height\"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다.\n * - `\"none\"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우.\n *\n * @default \"auto\"\n */\n size?: \"auto\" | \"height\" | \"none\";\n\n /**\n * 추가 클래스명\n */\n className?: string;\n\n /**\n * 컨테이너 스타일\n */\n style?: React.CSSProperties;\n}\n\nexport const BlurSwap = React.forwardRef(function BlurSwap(\n {\n activeKey,\n children,\n blur = DEFAULT_BLUR,\n offset = DEFAULT_OFFSET,\n duration = DEFAULT_DURATION,\n size = \"auto\",\n className,\n style,\n },\n forwardedRef,\n) {\n const prefersReducedMotion = useReducedMotion() ?? false;\n\n const contentRef = React.useRef(null);\n const hasMeasured = React.useRef(false);\n const [sizeApplied, setSizeApplied] = React.useState(false);\n\n const width = useSpring(0, SIZE_SPRING);\n const height = useSpring(0, SIZE_SPRING);\n\n /**\n * `activeKey`가 바뀌면 나가는 레이어는 AnimatePresence의 popLayout이 흐름 밖으로 빼내므로,\n * 이 시점의 콘텐츠 크기는 곧 들어오는 레이어의 크기다. 첫 측정만 jump로 흘려보내\n * 마운트 때 0에서 자라나지 않게 한다.\n */\n useIsomorphicLayoutEffect(() => {\n const content = contentRef.current;\n if (!content || size === \"none\") return;\n\n const sync = (animated: boolean) => {\n if (animated) {\n width.set(content.offsetWidth);\n height.set(content.offsetHeight);\n return;\n }\n\n width.jump(content.offsetWidth);\n height.jump(content.offsetHeight);\n };\n\n sync(hasMeasured.current && !prefersReducedMotion);\n hasMeasured.current = true;\n setSizeApplied(true);\n\n const observer = new ResizeObserver(() => sync(!prefersReducedMotion));\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [activeKey, size, prefersReducedMotion, width, height]);\n\n return (\n \n
\n \n \n {children}\n \n \n
\n \n );\n});\n\nBlurSwap.displayName = \"BlurSwap\";\n\n/**\n * This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.\n * You can extend this snippet however you want.\n */\n" + "content": "/**\n * @file breeze:blur-swap\n **/\n\n\"use client\";\n\nimport { useComposedRefs } from \"@radix-ui/react-compose-refs\";\nimport { usePresence } from \"@seed-design/react-presence\";\nimport * as React from \"react\";\nimport styles from \"./blur-swap.module.css\";\n\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_OFFSET = 8;\nconst DEFAULT_DURATION_MS = 300;\n\nconst useIsomorphicLayoutEffect =\n typeof document === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction joinClassNames(...classNames: Array) {\n return classNames.filter(Boolean).join(\" \");\n}\n\ninterface Layer {\n key: React.Key;\n children: React.ReactNode;\n}\n\ninterface BlurSwapLayerProps {\n /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */\n layerKey: React.Key;\n present: boolean;\n initial: boolean;\n onExitComplete: (key: React.Key) => void;\n children: React.ReactNode;\n}\n\nfunction BlurSwapLayer({\n layerKey,\n present,\n initial,\n onExitComplete,\n children,\n}: BlurSwapLayerProps) {\n const { isPresent, ref } = usePresence(present);\n\n React.useEffect(() => {\n if (isPresent) return;\n\n onExitComplete(layerKey);\n }, [isPresent, layerKey, onExitComplete]);\n\n if (!isPresent) return null;\n\n return (\n \n {children}\n \n );\n}\n\nexport interface BlurSwapProps {\n /**\n * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다.\n */\n activeKey: React.Key;\n\n /**\n * `activeKey`에 해당하는 콘텐츠\n */\n children: React.ReactNode;\n\n /**\n * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다.\n * @default 4\n */\n blur?: number;\n\n /**\n * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다.\n * 0이면 제자리에서 crossfade만 한다.\n * @default 8\n */\n offset?: number;\n\n /**\n * 전환 길이 (ms)\n * @default 300\n */\n duration?: number;\n\n /**\n * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위\n *\n * - `\"auto\"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다.\n * - `\"height\"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다.\n * - `\"none\"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우.\n *\n * @default \"auto\"\n */\n size?: \"auto\" | \"height\" | \"none\";\n\n /**\n * 추가 클래스명\n */\n className?: string;\n\n /**\n * 컨테이너 스타일\n */\n style?: React.CSSProperties;\n}\n\nexport const BlurSwap = React.forwardRef(function BlurSwap(\n {\n activeKey,\n children,\n blur = DEFAULT_BLUR,\n offset = DEFAULT_OFFSET,\n duration = DEFAULT_DURATION_MS,\n size = \"auto\",\n className,\n style,\n },\n forwardedRef,\n) {\n const rootRef = React.useRef(null);\n const composedRefs = useComposedRefs(forwardedRef, rootRef);\n const contentRef = React.useRef(null);\n\n /**\n * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가\n * 들고 사라져야 할 옛 내용이 여기 남아 있다.\n */\n const committedChildrenRef = React.useRef(children);\n\n const [stack, setStack] = React.useState(() => ({\n currentKey: activeKey,\n exiting: [] as Layer[],\n hasSwapped: false,\n }));\n\n // 렌더 도중 state를 맞춘다. 이 시점을 놓치면 옛 children을 붙잡을 기회가 사라진다.\n if (stack.currentKey !== activeKey) {\n setStack((previous) => ({\n currentKey: activeKey,\n exiting: [\n // 되돌아온 키는 나가는 목록에서 뺀다. 남겨두면 같은 key가 둘이 된다.\n ...previous.exiting.filter(\n (layer) => layer.key !== previous.currentKey && layer.key !== activeKey,\n ),\n { key: previous.currentKey, children: committedChildrenRef.current },\n ],\n hasSwapped: true,\n }));\n }\n\n useIsomorphicLayoutEffect(() => {\n committedChildrenRef.current = children;\n });\n\n useIsomorphicLayoutEffect(() => {\n const root = rootRef.current;\n const content = contentRef.current;\n if (!root || !content) return;\n\n if (size === \"none\") {\n root.style.width = \"\";\n root.style.height = \"\";\n return;\n }\n\n /**\n * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는\n * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다.\n */\n const sync = () => {\n root.style.width = size === \"auto\" ? `${content.offsetWidth}px` : \"\";\n root.style.height = `${content.offsetHeight}px`;\n };\n\n sync();\n\n const observer = new ResizeObserver(sync);\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [activeKey, size]);\n\n const handleExitComplete = React.useCallback((key: React.Key) => {\n setStack((previous) => ({\n ...previous,\n exiting: previous.exiting.filter((layer) => layer.key !== key),\n }));\n }, []);\n\n const rootStyle: React.CSSProperties & Record<`--${string}`, string> = {\n ...style,\n \"--blur-swap-blur\": `${blur}px`,\n \"--blur-swap-offset\": `${offset}px`,\n \"--blur-swap-duration\": `${duration}ms`,\n };\n\n // 나가는 레이어와 현재 레이어가 한 배열에 있어야 자리를 옮겨도 React가 같은 인스턴스로 잇는다.\n const layers: Array = [\n ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })),\n { key: activeKey, children, present: true, initial: !stack.hasSwapped },\n ];\n\n return (\n \n \n {layers.map((layer) => (\n \n {layer.children}\n \n ))}\n \n \n );\n});\n\nBlurSwap.displayName = \"BlurSwap\";\n\n/**\n * This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.\n * You can extend this snippet however you want.\n */\n" }, { "path": "blur-swap/blur-swap.module.css", - "content": ".root {\n position: relative;\n display: block;\n overflow: hidden;\n}\n\n.inline {\n display: inline-block;\n vertical-align: top;\n}\n\n.content {\n width: 100%;\n}\n\n/*\n * 컨테이너 너비가 스프링으로 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고,\n * 그 높이 변화가 다시 스프링에 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.\n */\n.contentAuto {\n width: max-content;\n}\n" + "content": ".root {\n --blur-swap-blur: 4px;\n --blur-swap-offset: 8px;\n --blur-swap-duration: 300ms;\n --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n position: relative;\n display: block;\n overflow: hidden;\n transition:\n width var(--blur-swap-duration) var(--blur-swap-ease),\n height var(--blur-swap-duration) var(--blur-swap-ease);\n}\n\n.inline {\n display: inline-block;\n vertical-align: top;\n}\n\n.content {\n position: relative;\n width: 100%;\n}\n\n/*\n * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가\n * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.\n */\n.contentAuto {\n width: max-content;\n}\n\n/*\n * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에\n * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다.\n */\n.layer[data-state=\"open\"] {\n animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n/*\n * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고,\n * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다.\n */\n.layer[data-state=\"closed\"] {\n position: absolute;\n inset-block-start: 0;\n inset-inline-start: 0;\n width: 100%;\n animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n.contentAuto .layer[data-state=\"closed\"] {\n width: max-content;\n}\n\n/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */\n.layer[data-initial][data-state=\"open\"] {\n animation: none;\n}\n\n@keyframes blurSwapEnter {\n from {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(var(--blur-swap-offset));\n }\n to {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n}\n\n@keyframes blurSwapExit {\n from {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n to {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(calc(-1 * var(--blur-swap-offset)));\n }\n}\n\n@keyframes blurSwapEnterReduced {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n@keyframes blurSwapExitReduced {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n}\n\n/*\n * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로\n * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다.\n */\n@media (prefers-reduced-motion: reduce) {\n .root {\n transition: none;\n }\n\n .layer[data-state=\"open\"] {\n animation-name: blurSwapEnterReduced;\n }\n\n .layer[data-state=\"closed\"] {\n animation-name: blurSwapExitReduced;\n }\n}\n" } ] } \ No newline at end of file diff --git a/docs/public/__registry__/react/breeze/index.json b/docs/public/__registry__/react/breeze/index.json index 10763e5384..9337552812 100644 --- a/docs/public/__registry__/react/breeze/index.json +++ b/docs/public/__registry__/react/breeze/index.json @@ -28,7 +28,8 @@ "id": "blur-swap", "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트", "dependencies": [ - "motion" + "@radix-ui/react-compose-refs", + "@seed-design/react-presence" ] }, { diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.module.css b/docs/registry/react/breeze/blur-swap/blur-swap.module.css index d1aa0cdc04..c18219fadd 100644 --- a/docs/registry/react/breeze/blur-swap/blur-swap.module.css +++ b/docs/registry/react/breeze/blur-swap/blur-swap.module.css @@ -1,7 +1,15 @@ .root { + --blur-swap-blur: 4px; + --blur-swap-offset: 8px; + --blur-swap-duration: 300ms; + --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 1); + position: relative; display: block; overflow: hidden; + transition: + width var(--blur-swap-duration) var(--blur-swap-ease), + height var(--blur-swap-duration) var(--blur-swap-ease); } .inline { @@ -10,13 +18,105 @@ } .content { + position: relative; width: 100%; } /* - * 컨테이너 너비가 스프링으로 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, - * 그 높이 변화가 다시 스프링에 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다. + * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가 + * 다시 컨테이너로 되먹임된다. 콘텐츠를 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; + } +} diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx b/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx index 36778bce7f..a942d0d34c 100644 --- a/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx +++ b/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx @@ -1,10 +1,32 @@ import { act, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import { LazyMotion, domAnimation } from "motion/react"; import type * as React from "react"; -import { BlurSwap } from "./blur-swap"; + +/** + * CSS Modules는 테스트 환경에서 빈 객체로 들어오고, happy-dom은 `animation` 단축 속성을 + * `animation-name`으로 펼치지 못한다. `usePresence`가 퇴장 애니메이션을 실제로 감지하도록 + * 클래스 이름과 longhand 스타일을 대신 채워 넣는다. + */ +mock.module("./blur-swap.module.css", () => ({ + default: { + root: "root", + inline: "inline", + content: "content", + contentAuto: "contentAuto", + layer: "layer", + }, +})); + +const LAYER_STYLES = ` + .layer[data-state="open"] { animation-name: blurSwapEnter; animation-duration: 300ms; } + .layer[data-state="closed"] { animation-name: blurSwapExit; animation-duration: 300ms; position: absolute; } + .layer[data-initial][data-state="open"] { animation-name: none; } +`; + +const { BlurSwap } = await import("./blur-swap"); const originalResizeObserver = window.ResizeObserver; +let styleElement: HTMLStyleElement; class ResizeObserverMock { observe = mock(); @@ -13,14 +35,12 @@ class ResizeObserverMock { } function renderBlurSwap(props: Partial> = {}) { - const { activeKey = "a", children = A, ...rest } = props; + const { activeKey = "a", children = A, ...rest } = props; const result = render( - - - {children} - - , + + {children} + , ); const root = result.container.firstElementChild; @@ -35,34 +55,40 @@ function swapTo( props: Partial> = {}, ) { rerender( - - - {key.toUpperCase()} - - , + + {key.toUpperCase()} + , ); } function readLayers(root: HTMLElement) { - return [...root.querySelectorAll("[data-testid='content']")].map((content) => { - const layer = content.parentElement; - if (!(layer instanceof HTMLElement)) throw new Error("layer not rendered"); + return [...root.querySelectorAll(".layer")].map((layer) => ({ + text: layer.textContent, + state: layer.getAttribute("data-state"), + inert: layer.hasAttribute("inert"), + })); +} - return { - text: content.textContent, - inert: layer.hasAttribute("inert"), - filter: layer.style.filter, - transform: layer.style.transform, - }; +function finishExitAnimations(root: HTMLElement) { + act(() => { + for (const layer of root.querySelectorAll('.layer[data-state="closed"]')) { + const event = new Event("animationend", { bubbles: true }); + Object.defineProperty(event, "animationName", { value: "blurSwapExit" }); + layer.dispatchEvent(event); + } }); } beforeEach(() => { window.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + styleElement = document.createElement("style"); + styleElement.textContent = LAYER_STYLES; + document.head.appendChild(styleElement); }); afterEach(() => { window.ResizeObserver = originalResizeObserver; + styleElement.remove(); }); describe("BlurSwap", () => { @@ -73,36 +99,69 @@ describe("BlurSwap", () => { expect(root.style.margin).toBe("4px"); }); + it("blur·offset·duration을 CSS 커스텀 프로퍼티로 내린다", () => { + const { root } = renderBlurSwap({ blur: 12, offset: -20, duration: 450 }); + + expect([ + root.style.getPropertyValue("--blur-swap-blur"), + root.style.getPropertyValue("--blur-swap-offset"), + root.style.getPropertyValue("--blur-swap-duration"), + ]).toEqual(["12px", "-20px", "450ms"]); + }); + + it("마운트 첫 레이어는 등장 애니메이션을 건너뛴다", () => { + const { root } = renderBlurSwap(); + const layer = root.querySelector(".layer"); + if (!(layer instanceof HTMLElement)) throw new Error("layer not rendered"); + + expect(layer.hasAttribute("data-initial")).toBe(true); + expect(getComputedStyle(layer).animationName).toBe("none"); + }); + it("activeKey가 바뀌면 나가는 콘텐츠를 inert로 남긴 채 새 콘텐츠를 함께 그린다", () => { const { root, rerender } = renderBlurSwap({ activeKey: "a" }); swapTo(rerender, "b"); - expect(readLayers(root).map(({ text, inert }) => ({ text, inert }))).toEqual([ - { text: "A", inert: true }, - { text: "B", inert: false }, + expect(readLayers(root)).toEqual([ + { text: "A", state: "closed", inert: true }, + { text: "B", state: "open", inert: false }, ]); }); - it("들어오는 콘텐츠는 blur와 이동을 건 상태에서 등장한다", () => { - const { root, rerender } = renderBlurSwap({ activeKey: "a", blur: 12, offset: -20 }); + it("퇴장 애니메이션이 끝나야 나가는 콘텐츠를 DOM에서 뗀다", () => { + const { root, rerender } = renderBlurSwap({ activeKey: "a" }); + + swapTo(rerender, "b"); + expect(readLayers(root)).toHaveLength(2); + + finishExitAnimations(root); + + expect(readLayers(root)).toEqual([{ text: "B", state: "open", inert: false }]); + }); - swapTo(rerender, "b", { blur: 12, offset: -20 }); + it("전환이 끝나기 전에 되돌아와도 같은 키를 두 번 그리지 않는다", () => { + const { root, rerender } = renderBlurSwap({ activeKey: "a" }); - expect(readLayers(root).at(-1)).toEqual({ - text: "B", - inert: false, - filter: "blur(12px)", - transform: "translateY(-20px)", - }); + swapTo(rerender, "b"); + swapTo(rerender, "a"); + + expect(readLayers(root)).toEqual([ + { text: "B", state: "closed", inert: true }, + { text: "A", state: "open", inert: false }, + ]); }); - it("전환이 끝나면 나가는 콘텐츠를 DOM에서 제거한다", async () => { - const { root, rerender } = renderBlurSwap({ activeKey: "a", duration: 0 }); + it("size가 따라가기로 정한 축만 루트에 크기로 적는다", () => { + const auto = renderBlurSwap({ size: "auto" }); + expect([auto.root.style.width, auto.root.style.height]).toEqual(["0px", "0px"]); + auto.unmount(); - swapTo(rerender, "b", { duration: 0 }); - await act(() => new Promise((resolve) => setTimeout(resolve, 100))); + const height = renderBlurSwap({ size: "height" }); + expect([height.root.style.width, height.root.style.height]).toEqual(["", "0px"]); + height.unmount(); - expect(readLayers(root).map(({ text }) => text)).toEqual(["B"]); + const none = renderBlurSwap({ size: "none" }); + expect([none.root.style.width, none.root.style.height]).toEqual(["", ""]); }); }); diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.tsx b/docs/registry/react/breeze/blur-swap/blur-swap.tsx index 37fac11ecb..c79b8354ff 100644 --- a/docs/registry/react/breeze/blur-swap/blur-swap.tsx +++ b/docs/registry/react/breeze/blur-swap/blur-swap.tsx @@ -1,15 +1,13 @@ "use client"; -import { AnimatePresence, useIsPresent, useReducedMotion, useSpring } from "motion/react"; -import * as m from "motion/react-m"; +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 = 0.3; -const CONTENT_EASE: [number, number, number, number] = [0.22, 1, 0.36, 1]; -const SIZE_SPRING = { stiffness: 400, damping: 40 }; +const DEFAULT_DURATION_MS = 300; const useIsomorphicLayoutEffect = typeof document === "undefined" ? React.useEffect : React.useLayoutEffect; @@ -18,36 +16,53 @@ function joinClassNames(...classNames: Array) { return classNames.filter(Boolean).join(" "); } +interface Layer { + key: React.Key; + children: React.ReactNode; +} + interface BlurSwapLayerProps { - blur: number; - duration: number; - offset: number; + /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */ + layerKey: React.Key; + present: boolean; + initial: boolean; + onExitComplete: (key: React.Key) => void; children: React.ReactNode; } -const BlurSwapLayer = React.forwardRef(function BlurSwapLayer( - { blur, duration, offset, children }, - forwardedRef, -) { - const isPresent = useIsPresent(); +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 ( - {children} - + ); -}); - -BlurSwapLayer.displayName = "BlurSwapLayer"; +} export interface BlurSwapProps { /** @@ -74,8 +89,8 @@ export interface BlurSwapProps { offset?: number; /** - * 전환 길이 (초) - * @default 0.3 + * 전환 길이 (ms) + * @default 300 */ duration?: number; @@ -107,75 +122,119 @@ export const BlurSwap = React.forwardRef(function children, blur = DEFAULT_BLUR, offset = DEFAULT_OFFSET, - duration = DEFAULT_DURATION, + duration = DEFAULT_DURATION_MS, size = "auto", className, style, }, forwardedRef, ) { - const prefersReducedMotion = useReducedMotion() ?? false; - + const rootRef = React.useRef(null); + const composedRefs = useComposedRefs(forwardedRef, rootRef); const contentRef = React.useRef(null); - const hasMeasured = React.useRef(false); - const [sizeApplied, setSizeApplied] = React.useState(false); - - const width = useSpring(0, SIZE_SPRING); - const height = useSpring(0, SIZE_SPRING); /** - * `activeKey`가 바뀌면 나가는 레이어는 AnimatePresence의 popLayout이 흐름 밖으로 빼내므로, - * 이 시점의 콘텐츠 크기는 곧 들어오는 레이어의 크기다. 첫 측정만 jump로 흘려보내 - * 마운트 때 0에서 자라나지 않게 한다. + * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가 + * 들고 사라져야 할 옛 내용이 여기 남아 있다. */ - useIsomorphicLayoutEffect(() => { - const content = contentRef.current; - if (!content || size === "none") return; + 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, + })); + } - const sync = (animated: boolean) => { - if (animated) { - width.set(content.offsetWidth); - height.set(content.offsetHeight); - return; - } + useIsomorphicLayoutEffect(() => { + committedChildrenRef.current = children; + }); - width.jump(content.offsetWidth); - height.jump(content.offsetHeight); + useIsomorphicLayoutEffect(() => { + const root = rootRef.current; + const content = contentRef.current; + if (!root || !content) return; + + if (size === "none") { + root.style.width = ""; + root.style.height = ""; + return; + } + + /** + * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는 + * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다. + */ + const sync = () => { + root.style.width = size === "auto" ? `${content.offsetWidth}px` : ""; + root.style.height = `${content.offsetHeight}px`; }; - sync(hasMeasured.current && !prefersReducedMotion); - hasMeasured.current = true; - setSizeApplied(true); + sync(); - const observer = new ResizeObserver(() => sync(!prefersReducedMotion)); + const observer = new ResizeObserver(sync); observer.observe(content); return () => observer.disconnect(); - }, [activeKey, size, prefersReducedMotion, width, height]); + }, [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 = [ + ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })), + { key: activeKey, children, present: true, initial: !stack.hasSwapped }, + ]; return ( - -
- +
+ {layers.map((layer) => ( - {children} + {layer.children} - + ))}
- +
); }); From 4295c98d7aa838e10aaa239a23487233f1994bac Mon Sep 17 00:00:00 2001 From: Joo Chanhwi <56245920+te6-in@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:22:34 +0900 Subject: [PATCH 3/5] fix(breeze): stop blur swap from clipping its content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container is still travelling toward the incoming content's size for the whole transition, so it matches neither layer while the swap runs. With overflow hidden that mismatch was sliced off in a straight line — 40px of the outgoing label in the preview example — and the blur, which spreads past the box by its radius, ended in a hard edge instead of fading out. Drop the clipping and keep the overflow short instead: the container now leads when it grows and holds when it shrinks, driven by a data-resize flag written from the measured size, so the pixels outside the box are always the faintest ones. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EMZNyR7oqm4pjdU4ACsgFG --- .../__registry__/react/breeze/blur-swap.json | 4 ++-- .../breeze/blur-swap/blur-swap.module.css | 21 ++++++++++++++++--- .../react/breeze/blur-swap/blur-swap.tsx | 13 ++++++++++-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/public/__registry__/react/breeze/blur-swap.json b/docs/public/__registry__/react/breeze/blur-swap.json index 43728ef649..72d7c81bfa 100644 --- a/docs/public/__registry__/react/breeze/blur-swap.json +++ b/docs/public/__registry__/react/breeze/blur-swap.json @@ -8,11 +8,11 @@ "snippets": [ { "path": "blur-swap/blur-swap.tsx", - "content": "/**\n * @file breeze:blur-swap\n **/\n\n\"use client\";\n\nimport { useComposedRefs } from \"@radix-ui/react-compose-refs\";\nimport { usePresence } from \"@seed-design/react-presence\";\nimport * as React from \"react\";\nimport styles from \"./blur-swap.module.css\";\n\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_OFFSET = 8;\nconst DEFAULT_DURATION_MS = 300;\n\nconst useIsomorphicLayoutEffect =\n typeof document === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction joinClassNames(...classNames: Array) {\n return classNames.filter(Boolean).join(\" \");\n}\n\ninterface Layer {\n key: React.Key;\n children: React.ReactNode;\n}\n\ninterface BlurSwapLayerProps {\n /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */\n layerKey: React.Key;\n present: boolean;\n initial: boolean;\n onExitComplete: (key: React.Key) => void;\n children: React.ReactNode;\n}\n\nfunction BlurSwapLayer({\n layerKey,\n present,\n initial,\n onExitComplete,\n children,\n}: BlurSwapLayerProps) {\n const { isPresent, ref } = usePresence(present);\n\n React.useEffect(() => {\n if (isPresent) return;\n\n onExitComplete(layerKey);\n }, [isPresent, layerKey, onExitComplete]);\n\n if (!isPresent) return null;\n\n return (\n \n {children}\n \n );\n}\n\nexport interface BlurSwapProps {\n /**\n * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다.\n */\n activeKey: React.Key;\n\n /**\n * `activeKey`에 해당하는 콘텐츠\n */\n children: React.ReactNode;\n\n /**\n * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다.\n * @default 4\n */\n blur?: number;\n\n /**\n * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다.\n * 0이면 제자리에서 crossfade만 한다.\n * @default 8\n */\n offset?: number;\n\n /**\n * 전환 길이 (ms)\n * @default 300\n */\n duration?: number;\n\n /**\n * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위\n *\n * - `\"auto\"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다.\n * - `\"height\"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다.\n * - `\"none\"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우.\n *\n * @default \"auto\"\n */\n size?: \"auto\" | \"height\" | \"none\";\n\n /**\n * 추가 클래스명\n */\n className?: string;\n\n /**\n * 컨테이너 스타일\n */\n style?: React.CSSProperties;\n}\n\nexport const BlurSwap = React.forwardRef(function BlurSwap(\n {\n activeKey,\n children,\n blur = DEFAULT_BLUR,\n offset = DEFAULT_OFFSET,\n duration = DEFAULT_DURATION_MS,\n size = \"auto\",\n className,\n style,\n },\n forwardedRef,\n) {\n const rootRef = React.useRef(null);\n const composedRefs = useComposedRefs(forwardedRef, rootRef);\n const contentRef = React.useRef(null);\n\n /**\n * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가\n * 들고 사라져야 할 옛 내용이 여기 남아 있다.\n */\n const committedChildrenRef = React.useRef(children);\n\n const [stack, setStack] = React.useState(() => ({\n currentKey: activeKey,\n exiting: [] as Layer[],\n hasSwapped: false,\n }));\n\n // 렌더 도중 state를 맞춘다. 이 시점을 놓치면 옛 children을 붙잡을 기회가 사라진다.\n if (stack.currentKey !== activeKey) {\n setStack((previous) => ({\n currentKey: activeKey,\n exiting: [\n // 되돌아온 키는 나가는 목록에서 뺀다. 남겨두면 같은 key가 둘이 된다.\n ...previous.exiting.filter(\n (layer) => layer.key !== previous.currentKey && layer.key !== activeKey,\n ),\n { key: previous.currentKey, children: committedChildrenRef.current },\n ],\n hasSwapped: true,\n }));\n }\n\n useIsomorphicLayoutEffect(() => {\n committedChildrenRef.current = children;\n });\n\n useIsomorphicLayoutEffect(() => {\n const root = rootRef.current;\n const content = contentRef.current;\n if (!root || !content) return;\n\n if (size === \"none\") {\n root.style.width = \"\";\n root.style.height = \"\";\n return;\n }\n\n /**\n * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는\n * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다.\n */\n const sync = () => {\n root.style.width = size === \"auto\" ? `${content.offsetWidth}px` : \"\";\n root.style.height = `${content.offsetHeight}px`;\n };\n\n sync();\n\n const observer = new ResizeObserver(sync);\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [activeKey, size]);\n\n const handleExitComplete = React.useCallback((key: React.Key) => {\n setStack((previous) => ({\n ...previous,\n exiting: previous.exiting.filter((layer) => layer.key !== key),\n }));\n }, []);\n\n const rootStyle: React.CSSProperties & Record<`--${string}`, string> = {\n ...style,\n \"--blur-swap-blur\": `${blur}px`,\n \"--blur-swap-offset\": `${offset}px`,\n \"--blur-swap-duration\": `${duration}ms`,\n };\n\n // 나가는 레이어와 현재 레이어가 한 배열에 있어야 자리를 옮겨도 React가 같은 인스턴스로 잇는다.\n const layers: Array = [\n ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })),\n { key: activeKey, children, present: true, initial: !stack.hasSwapped },\n ];\n\n return (\n \n \n {layers.map((layer) => (\n \n {layer.children}\n \n ))}\n \n \n );\n});\n\nBlurSwap.displayName = \"BlurSwap\";\n\n/**\n * This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.\n * You can extend this snippet however you want.\n */\n" + "content": "/**\n * @file breeze:blur-swap\n **/\n\n\"use client\";\n\nimport { useComposedRefs } from \"@radix-ui/react-compose-refs\";\nimport { usePresence } from \"@seed-design/react-presence\";\nimport * as React from \"react\";\nimport styles from \"./blur-swap.module.css\";\n\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_OFFSET = 8;\nconst DEFAULT_DURATION_MS = 300;\n\nconst useIsomorphicLayoutEffect =\n typeof document === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction joinClassNames(...classNames: Array) {\n return classNames.filter(Boolean).join(\" \");\n}\n\ninterface Layer {\n key: React.Key;\n children: React.ReactNode;\n}\n\ninterface BlurSwapLayerProps {\n /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */\n layerKey: React.Key;\n present: boolean;\n initial: boolean;\n onExitComplete: (key: React.Key) => void;\n children: React.ReactNode;\n}\n\nfunction BlurSwapLayer({\n layerKey,\n present,\n initial,\n onExitComplete,\n children,\n}: BlurSwapLayerProps) {\n const { isPresent, ref } = usePresence(present);\n\n React.useEffect(() => {\n if (isPresent) return;\n\n onExitComplete(layerKey);\n }, [isPresent, layerKey, onExitComplete]);\n\n if (!isPresent) return null;\n\n return (\n \n {children}\n \n );\n}\n\nexport interface BlurSwapProps {\n /**\n * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다.\n */\n activeKey: React.Key;\n\n /**\n * `activeKey`에 해당하는 콘텐츠\n */\n children: React.ReactNode;\n\n /**\n * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다.\n * @default 4\n */\n blur?: number;\n\n /**\n * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다.\n * 0이면 제자리에서 crossfade만 한다.\n * @default 8\n */\n offset?: number;\n\n /**\n * 전환 길이 (ms)\n * @default 300\n */\n duration?: number;\n\n /**\n * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위\n *\n * - `\"auto\"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다.\n * - `\"height\"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다.\n * - `\"none\"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우.\n *\n * @default \"auto\"\n */\n size?: \"auto\" | \"height\" | \"none\";\n\n /**\n * 추가 클래스명\n */\n className?: string;\n\n /**\n * 컨테이너 스타일\n */\n style?: React.CSSProperties;\n}\n\nexport const BlurSwap = React.forwardRef(function BlurSwap(\n {\n activeKey,\n children,\n blur = DEFAULT_BLUR,\n offset = DEFAULT_OFFSET,\n duration = DEFAULT_DURATION_MS,\n size = \"auto\",\n className,\n style,\n },\n forwardedRef,\n) {\n const rootRef = React.useRef(null);\n const composedRefs = useComposedRefs(forwardedRef, rootRef);\n const contentRef = React.useRef(null);\n\n /**\n * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가\n * 들고 사라져야 할 옛 내용이 여기 남아 있다.\n */\n const committedChildrenRef = React.useRef(children);\n\n const [stack, setStack] = React.useState(() => ({\n currentKey: activeKey,\n exiting: [] as Layer[],\n hasSwapped: false,\n }));\n\n // 렌더 도중 state를 맞춘다. 이 시점을 놓치면 옛 children을 붙잡을 기회가 사라진다.\n if (stack.currentKey !== activeKey) {\n setStack((previous) => ({\n currentKey: activeKey,\n exiting: [\n // 되돌아온 키는 나가는 목록에서 뺀다. 남겨두면 같은 key가 둘이 된다.\n ...previous.exiting.filter(\n (layer) => layer.key !== previous.currentKey && layer.key !== activeKey,\n ),\n { key: previous.currentKey, children: committedChildrenRef.current },\n ],\n hasSwapped: true,\n }));\n }\n\n useIsomorphicLayoutEffect(() => {\n committedChildrenRef.current = children;\n });\n\n useIsomorphicLayoutEffect(() => {\n const root = rootRef.current;\n const content = contentRef.current;\n if (!root || !content) return;\n\n if (size === \"none\") {\n root.style.width = \"\";\n root.style.height = \"\";\n delete root.dataset.resize;\n return;\n }\n\n /**\n * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는\n * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다.\n */\n const sync = () => {\n const width = size === \"auto\" ? content.offsetWidth : root.offsetWidth;\n const height = content.offsetHeight;\n\n // 어느 방향으로 가는지는 지금 그려진 컨테이너와 비교해서만 알 수 있다. 전환 도중에\n // 다시 불려도 남은 거리 기준으로 판단이 다시 선다.\n root.dataset.resize =\n width > root.offsetWidth || height > root.offsetHeight ? \"grow\" : \"shrink\";\n\n root.style.width = size === \"auto\" ? `${width}px` : \"\";\n root.style.height = `${height}px`;\n };\n\n sync();\n\n const observer = new ResizeObserver(sync);\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [activeKey, size]);\n\n const handleExitComplete = React.useCallback((key: React.Key) => {\n setStack((previous) => ({\n ...previous,\n exiting: previous.exiting.filter((layer) => layer.key !== key),\n }));\n }, []);\n\n const rootStyle: React.CSSProperties & Record<`--${string}`, string> = {\n ...style,\n \"--blur-swap-blur\": `${blur}px`,\n \"--blur-swap-offset\": `${offset}px`,\n \"--blur-swap-duration\": `${duration}ms`,\n };\n\n // 나가는 레이어와 현재 레이어가 한 배열에 있어야 자리를 옮겨도 React가 같은 인스턴스로 잇는다.\n const layers: Array = [\n ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })),\n { key: activeKey, children, present: true, initial: !stack.hasSwapped },\n ];\n\n return (\n \n \n {layers.map((layer) => (\n \n {layer.children}\n \n ))}\n \n \n );\n});\n\nBlurSwap.displayName = \"BlurSwap\";\n\n/**\n * This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.\n * You can extend this snippet however you want.\n */\n" }, { "path": "blur-swap/blur-swap.module.css", - "content": ".root {\n --blur-swap-blur: 4px;\n --blur-swap-offset: 8px;\n --blur-swap-duration: 300ms;\n --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n position: relative;\n display: block;\n overflow: hidden;\n transition:\n width var(--blur-swap-duration) var(--blur-swap-ease),\n height var(--blur-swap-duration) var(--blur-swap-ease);\n}\n\n.inline {\n display: inline-block;\n vertical-align: top;\n}\n\n.content {\n position: relative;\n width: 100%;\n}\n\n/*\n * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가\n * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.\n */\n.contentAuto {\n width: max-content;\n}\n\n/*\n * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에\n * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다.\n */\n.layer[data-state=\"open\"] {\n animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n/*\n * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고,\n * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다.\n */\n.layer[data-state=\"closed\"] {\n position: absolute;\n inset-block-start: 0;\n inset-inline-start: 0;\n width: 100%;\n animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n.contentAuto .layer[data-state=\"closed\"] {\n width: max-content;\n}\n\n/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */\n.layer[data-initial][data-state=\"open\"] {\n animation: none;\n}\n\n@keyframes blurSwapEnter {\n from {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(var(--blur-swap-offset));\n }\n to {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n}\n\n@keyframes blurSwapExit {\n from {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n to {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(calc(-1 * var(--blur-swap-offset)));\n }\n}\n\n@keyframes blurSwapEnterReduced {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n@keyframes blurSwapExitReduced {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n}\n\n/*\n * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로\n * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다.\n */\n@media (prefers-reduced-motion: reduce) {\n .root {\n transition: none;\n }\n\n .layer[data-state=\"open\"] {\n animation-name: blurSwapEnterReduced;\n }\n\n .layer[data-state=\"closed\"] {\n animation-name: blurSwapExitReduced;\n }\n}\n" + "content": "/*\n * 자르지 않는 건 의도다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿으므로, 자르면 그동안\n * 두 콘텐츠가 상자를 넘긴 만큼이 계속 잘려 나간다. blur도 반경만큼 상자 밖으로 번지기 때문에\n * 가장자리가 흐려지는 대신 직선으로 끊긴다.\n */\n.root {\n --blur-swap-blur: 4px;\n --blur-swap-offset: 8px;\n --blur-swap-duration: 300ms;\n --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 1);\n --blur-swap-grow-ease: var(--seed-timing-function-enter, cubic-bezier(0, 0, 0.15, 1));\n --blur-swap-shrink-ease: var(--seed-timing-function-exit, cubic-bezier(0.35, 0, 1, 1));\n\n position: relative;\n display: block;\n transition:\n width var(--blur-swap-duration) var(--blur-swap-grow-ease),\n height var(--blur-swap-duration) var(--blur-swap-grow-ease);\n}\n\n/*\n * 커질 때는 컨테이너가 앞서 나가고, 작아질 때는 버틴다. 들어오는 콘텐츠는 첫 프레임부터\n * 최종 크기로 그려지고 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너가 어느 쪽으로\n * 가든 큰 쪽을 늦게 놓아줘야 상자 밖에 콘텐츠가 나와 있는 시간이 짧아진다.\n */\n.root[data-resize=\"shrink\"] {\n transition-timing-function: var(--blur-swap-shrink-ease);\n}\n\n.inline {\n display: inline-block;\n vertical-align: top;\n}\n\n.content {\n position: relative;\n width: 100%;\n}\n\n/*\n * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가\n * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.\n */\n.contentAuto {\n width: max-content;\n}\n\n/*\n * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에\n * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다.\n */\n.layer[data-state=\"open\"] {\n animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n/*\n * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고,\n * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다.\n */\n.layer[data-state=\"closed\"] {\n position: absolute;\n inset-block-start: 0;\n inset-inline-start: 0;\n width: 100%;\n animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n.contentAuto .layer[data-state=\"closed\"] {\n width: max-content;\n}\n\n/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */\n.layer[data-initial][data-state=\"open\"] {\n animation: none;\n}\n\n@keyframes blurSwapEnter {\n from {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(var(--blur-swap-offset));\n }\n to {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n}\n\n@keyframes blurSwapExit {\n from {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n to {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(calc(-1 * var(--blur-swap-offset)));\n }\n}\n\n@keyframes blurSwapEnterReduced {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n@keyframes blurSwapExitReduced {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n}\n\n/*\n * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로\n * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다.\n */\n@media (prefers-reduced-motion: reduce) {\n .root {\n transition: none;\n }\n\n .layer[data-state=\"open\"] {\n animation-name: blurSwapEnterReduced;\n }\n\n .layer[data-state=\"closed\"] {\n animation-name: blurSwapExitReduced;\n }\n}\n" } ] } \ No newline at end of file diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.module.css b/docs/registry/react/breeze/blur-swap/blur-swap.module.css index c18219fadd..c4eab6f574 100644 --- a/docs/registry/react/breeze/blur-swap/blur-swap.module.css +++ b/docs/registry/react/breeze/blur-swap/blur-swap.module.css @@ -1,15 +1,30 @@ +/* + * 자르지 않는 건 의도다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿으므로, 자르면 그동안 + * 두 콘텐츠가 상자를 넘긴 만큼이 계속 잘려 나간다. blur도 반경만큼 상자 밖으로 번지기 때문에 + * 가장자리가 흐려지는 대신 직선으로 끊긴다. + */ .root { --blur-swap-blur: 4px; --blur-swap-offset: 8px; --blur-swap-duration: 300ms; --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 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; - overflow: hidden; transition: - width var(--blur-swap-duration) var(--blur-swap-ease), - height var(--blur-swap-duration) var(--blur-swap-ease); + 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 { diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.tsx b/docs/registry/react/breeze/blur-swap/blur-swap.tsx index c79b8354ff..56dc8302e9 100644 --- a/docs/registry/react/breeze/blur-swap/blur-swap.tsx +++ b/docs/registry/react/breeze/blur-swap/blur-swap.tsx @@ -172,6 +172,7 @@ export const BlurSwap = React.forwardRef(function if (size === "none") { root.style.width = ""; root.style.height = ""; + delete root.dataset.resize; return; } @@ -180,8 +181,16 @@ export const BlurSwap = React.forwardRef(function * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다. */ const sync = () => { - root.style.width = size === "auto" ? `${content.offsetWidth}px` : ""; - root.style.height = `${content.offsetHeight}px`; + 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(); From 3ca90db9916e665ac115a7b9f244d29dd37fb311 Mon Sep 17 00:00:00 2001 From: Joo Chanhwi <56245920+te6-in@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:22:59 +0900 Subject: [PATCH 4/5] fix(breeze): make the blur swap crossfade perceptible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expo-out curve pushed almost all of the progress into the opening frames: 40% done at a tenth of the duration, 83% at a third. A 300ms swap therefore finished visibly within about 90ms and spent the rest on a tail nobody can see, so the blur read as instant and raising the duration alone would only have lengthened that tail. Move the crossfade onto SEED's own easing, which crosses the halfway mark near the middle, and take the default to 400ms — past the 300ms top of the duration scale, because the blur only registers while a half blurred frame is on screen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EMZNyR7oqm4pjdU4ACsgFG --- docs/public/__registry__/react/breeze/blur-swap.json | 4 ++-- docs/registry/react/breeze/blur-swap/blur-swap.module.css | 4 ++-- docs/registry/react/breeze/blur-swap/blur-swap.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/public/__registry__/react/breeze/blur-swap.json b/docs/public/__registry__/react/breeze/blur-swap.json index 72d7c81bfa..89c2b62592 100644 --- a/docs/public/__registry__/react/breeze/blur-swap.json +++ b/docs/public/__registry__/react/breeze/blur-swap.json @@ -8,11 +8,11 @@ "snippets": [ { "path": "blur-swap/blur-swap.tsx", - "content": "/**\n * @file breeze:blur-swap\n **/\n\n\"use client\";\n\nimport { useComposedRefs } from \"@radix-ui/react-compose-refs\";\nimport { usePresence } from \"@seed-design/react-presence\";\nimport * as React from \"react\";\nimport styles from \"./blur-swap.module.css\";\n\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_OFFSET = 8;\nconst DEFAULT_DURATION_MS = 300;\n\nconst useIsomorphicLayoutEffect =\n typeof document === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction joinClassNames(...classNames: Array) {\n return classNames.filter(Boolean).join(\" \");\n}\n\ninterface Layer {\n key: React.Key;\n children: React.ReactNode;\n}\n\ninterface BlurSwapLayerProps {\n /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */\n layerKey: React.Key;\n present: boolean;\n initial: boolean;\n onExitComplete: (key: React.Key) => void;\n children: React.ReactNode;\n}\n\nfunction BlurSwapLayer({\n layerKey,\n present,\n initial,\n onExitComplete,\n children,\n}: BlurSwapLayerProps) {\n const { isPresent, ref } = usePresence(present);\n\n React.useEffect(() => {\n if (isPresent) return;\n\n onExitComplete(layerKey);\n }, [isPresent, layerKey, onExitComplete]);\n\n if (!isPresent) return null;\n\n return (\n \n {children}\n \n );\n}\n\nexport interface BlurSwapProps {\n /**\n * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다.\n */\n activeKey: React.Key;\n\n /**\n * `activeKey`에 해당하는 콘텐츠\n */\n children: React.ReactNode;\n\n /**\n * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다.\n * @default 4\n */\n blur?: number;\n\n /**\n * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다.\n * 0이면 제자리에서 crossfade만 한다.\n * @default 8\n */\n offset?: number;\n\n /**\n * 전환 길이 (ms)\n * @default 300\n */\n duration?: number;\n\n /**\n * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위\n *\n * - `\"auto\"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다.\n * - `\"height\"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다.\n * - `\"none\"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우.\n *\n * @default \"auto\"\n */\n size?: \"auto\" | \"height\" | \"none\";\n\n /**\n * 추가 클래스명\n */\n className?: string;\n\n /**\n * 컨테이너 스타일\n */\n style?: React.CSSProperties;\n}\n\nexport const BlurSwap = React.forwardRef(function BlurSwap(\n {\n activeKey,\n children,\n blur = DEFAULT_BLUR,\n offset = DEFAULT_OFFSET,\n duration = DEFAULT_DURATION_MS,\n size = \"auto\",\n className,\n style,\n },\n forwardedRef,\n) {\n const rootRef = React.useRef(null);\n const composedRefs = useComposedRefs(forwardedRef, rootRef);\n const contentRef = React.useRef(null);\n\n /**\n * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가\n * 들고 사라져야 할 옛 내용이 여기 남아 있다.\n */\n const committedChildrenRef = React.useRef(children);\n\n const [stack, setStack] = React.useState(() => ({\n currentKey: activeKey,\n exiting: [] as Layer[],\n hasSwapped: false,\n }));\n\n // 렌더 도중 state를 맞춘다. 이 시점을 놓치면 옛 children을 붙잡을 기회가 사라진다.\n if (stack.currentKey !== activeKey) {\n setStack((previous) => ({\n currentKey: activeKey,\n exiting: [\n // 되돌아온 키는 나가는 목록에서 뺀다. 남겨두면 같은 key가 둘이 된다.\n ...previous.exiting.filter(\n (layer) => layer.key !== previous.currentKey && layer.key !== activeKey,\n ),\n { key: previous.currentKey, children: committedChildrenRef.current },\n ],\n hasSwapped: true,\n }));\n }\n\n useIsomorphicLayoutEffect(() => {\n committedChildrenRef.current = children;\n });\n\n useIsomorphicLayoutEffect(() => {\n const root = rootRef.current;\n const content = contentRef.current;\n if (!root || !content) return;\n\n if (size === \"none\") {\n root.style.width = \"\";\n root.style.height = \"\";\n delete root.dataset.resize;\n return;\n }\n\n /**\n * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는\n * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다.\n */\n const sync = () => {\n const width = size === \"auto\" ? content.offsetWidth : root.offsetWidth;\n const height = content.offsetHeight;\n\n // 어느 방향으로 가는지는 지금 그려진 컨테이너와 비교해서만 알 수 있다. 전환 도중에\n // 다시 불려도 남은 거리 기준으로 판단이 다시 선다.\n root.dataset.resize =\n width > root.offsetWidth || height > root.offsetHeight ? \"grow\" : \"shrink\";\n\n root.style.width = size === \"auto\" ? `${width}px` : \"\";\n root.style.height = `${height}px`;\n };\n\n sync();\n\n const observer = new ResizeObserver(sync);\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [activeKey, size]);\n\n const handleExitComplete = React.useCallback((key: React.Key) => {\n setStack((previous) => ({\n ...previous,\n exiting: previous.exiting.filter((layer) => layer.key !== key),\n }));\n }, []);\n\n const rootStyle: React.CSSProperties & Record<`--${string}`, string> = {\n ...style,\n \"--blur-swap-blur\": `${blur}px`,\n \"--blur-swap-offset\": `${offset}px`,\n \"--blur-swap-duration\": `${duration}ms`,\n };\n\n // 나가는 레이어와 현재 레이어가 한 배열에 있어야 자리를 옮겨도 React가 같은 인스턴스로 잇는다.\n const layers: Array = [\n ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })),\n { key: activeKey, children, present: true, initial: !stack.hasSwapped },\n ];\n\n return (\n \n \n {layers.map((layer) => (\n \n {layer.children}\n \n ))}\n \n \n );\n});\n\nBlurSwap.displayName = \"BlurSwap\";\n\n/**\n * This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.\n * You can extend this snippet however you want.\n */\n" + "content": "/**\n * @file breeze:blur-swap\n **/\n\n\"use client\";\n\nimport { useComposedRefs } from \"@radix-ui/react-compose-refs\";\nimport { usePresence } from \"@seed-design/react-presence\";\nimport * as React from \"react\";\nimport styles from \"./blur-swap.module.css\";\n\nconst DEFAULT_BLUR = 4;\nconst DEFAULT_OFFSET = 8;\nconst DEFAULT_DURATION_MS = 400;\n\nconst useIsomorphicLayoutEffect =\n typeof document === \"undefined\" ? React.useEffect : React.useLayoutEffect;\n\nfunction joinClassNames(...classNames: Array) {\n return classNames.filter(Boolean).join(\" \");\n}\n\ninterface Layer {\n key: React.Key;\n children: React.ReactNode;\n}\n\ninterface BlurSwapLayerProps {\n /** React의 `key`는 prop으로 넘어오지 않으므로 나갈 때 알릴 키를 따로 받는다. */\n layerKey: React.Key;\n present: boolean;\n initial: boolean;\n onExitComplete: (key: React.Key) => void;\n children: React.ReactNode;\n}\n\nfunction BlurSwapLayer({\n layerKey,\n present,\n initial,\n onExitComplete,\n children,\n}: BlurSwapLayerProps) {\n const { isPresent, ref } = usePresence(present);\n\n React.useEffect(() => {\n if (isPresent) return;\n\n onExitComplete(layerKey);\n }, [isPresent, layerKey, onExitComplete]);\n\n if (!isPresent) return null;\n\n return (\n \n {children}\n \n );\n}\n\nexport interface BlurSwapProps {\n /**\n * 지금 보여줄 콘텐츠를 식별하는 키. 이 값이 바뀔 때 전환이 일어난다.\n */\n activeKey: React.Key;\n\n /**\n * `activeKey`에 해당하는 콘텐츠\n */\n children: React.ReactNode;\n\n /**\n * 전환 중 최대 blur 반경 (px). 0이면 blur 없이 crossfade만 한다.\n * @default 4\n */\n blur?: number;\n\n /**\n * 전환 중 콘텐츠가 흐르는 거리 (px). 양수면 아래에서 위로, 음수면 위에서 아래로 흐른다.\n * 0이면 제자리에서 crossfade만 한다.\n * @default 8\n */\n offset?: number;\n\n /**\n * 전환 길이 (ms)\n * @default 400\n */\n duration?: number;\n\n /**\n * 컨테이너 크기를 들어오는 콘텐츠에 맞춰 애니메이션하는 범위\n *\n * - `\"auto\"`: 너비와 높이 모두. 콘텐츠 너비를 그대로 쓰므로 줄바꿈이 일어나지 않는다. 라벨·뱃지처럼 한 줄짜리에 맞다.\n * - `\"height\"`: 높이만. 너비는 부모를 채운다. 카드·패널처럼 폭이 정해진 콘텐츠에 맞다.\n * - `\"none\"`: 크기를 애니메이션하지 않는다. 크기를 바깥에서 정하는 경우.\n *\n * @default \"auto\"\n */\n size?: \"auto\" | \"height\" | \"none\";\n\n /**\n * 추가 클래스명\n */\n className?: string;\n\n /**\n * 컨테이너 스타일\n */\n style?: React.CSSProperties;\n}\n\nexport const BlurSwap = React.forwardRef(function BlurSwap(\n {\n activeKey,\n children,\n blur = DEFAULT_BLUR,\n offset = DEFAULT_OFFSET,\n duration = DEFAULT_DURATION_MS,\n size = \"auto\",\n className,\n style,\n },\n forwardedRef,\n) {\n const rootRef = React.useRef(null);\n const composedRefs = useComposedRefs(forwardedRef, rootRef);\n const contentRef = React.useRef(null);\n\n /**\n * 직전 커밋의 children. `activeKey`가 바뀐 렌더에서는 아직 갱신되기 전이라, 나가는 레이어가\n * 들고 사라져야 할 옛 내용이 여기 남아 있다.\n */\n const committedChildrenRef = React.useRef(children);\n\n const [stack, setStack] = React.useState(() => ({\n currentKey: activeKey,\n exiting: [] as Layer[],\n hasSwapped: false,\n }));\n\n // 렌더 도중 state를 맞춘다. 이 시점을 놓치면 옛 children을 붙잡을 기회가 사라진다.\n if (stack.currentKey !== activeKey) {\n setStack((previous) => ({\n currentKey: activeKey,\n exiting: [\n // 되돌아온 키는 나가는 목록에서 뺀다. 남겨두면 같은 key가 둘이 된다.\n ...previous.exiting.filter(\n (layer) => layer.key !== previous.currentKey && layer.key !== activeKey,\n ),\n { key: previous.currentKey, children: committedChildrenRef.current },\n ],\n hasSwapped: true,\n }));\n }\n\n useIsomorphicLayoutEffect(() => {\n committedChildrenRef.current = children;\n });\n\n useIsomorphicLayoutEffect(() => {\n const root = rootRef.current;\n const content = contentRef.current;\n if (!root || !content) return;\n\n if (size === \"none\") {\n root.style.width = \"\";\n root.style.height = \"\";\n delete root.dataset.resize;\n return;\n }\n\n /**\n * 나가는 레이어는 이 시점에 이미 `position: absolute`라 흐름 밖이다. 그래서 콘텐츠 크기는\n * 곧 들어오는 레이어의 크기고, 마운트 첫 측정은 계산된 값과 같아 transition이 걸리지 않는다.\n */\n const sync = () => {\n const width = size === \"auto\" ? content.offsetWidth : root.offsetWidth;\n const height = content.offsetHeight;\n\n // 어느 방향으로 가는지는 지금 그려진 컨테이너와 비교해서만 알 수 있다. 전환 도중에\n // 다시 불려도 남은 거리 기준으로 판단이 다시 선다.\n root.dataset.resize =\n width > root.offsetWidth || height > root.offsetHeight ? \"grow\" : \"shrink\";\n\n root.style.width = size === \"auto\" ? `${width}px` : \"\";\n root.style.height = `${height}px`;\n };\n\n sync();\n\n const observer = new ResizeObserver(sync);\n observer.observe(content);\n\n return () => observer.disconnect();\n }, [activeKey, size]);\n\n const handleExitComplete = React.useCallback((key: React.Key) => {\n setStack((previous) => ({\n ...previous,\n exiting: previous.exiting.filter((layer) => layer.key !== key),\n }));\n }, []);\n\n const rootStyle: React.CSSProperties & Record<`--${string}`, string> = {\n ...style,\n \"--blur-swap-blur\": `${blur}px`,\n \"--blur-swap-offset\": `${offset}px`,\n \"--blur-swap-duration\": `${duration}ms`,\n };\n\n // 나가는 레이어와 현재 레이어가 한 배열에 있어야 자리를 옮겨도 React가 같은 인스턴스로 잇는다.\n const layers: Array = [\n ...stack.exiting.map((layer) => ({ ...layer, present: false, initial: false })),\n { key: activeKey, children, present: true, initial: !stack.hasSwapped },\n ];\n\n return (\n \n \n {layers.map((layer) => (\n \n {layer.children}\n \n ))}\n \n \n );\n});\n\nBlurSwap.displayName = \"BlurSwap\";\n\n/**\n * This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.\n * You can extend this snippet however you want.\n */\n" }, { "path": "blur-swap/blur-swap.module.css", - "content": "/*\n * 자르지 않는 건 의도다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿으므로, 자르면 그동안\n * 두 콘텐츠가 상자를 넘긴 만큼이 계속 잘려 나간다. blur도 반경만큼 상자 밖으로 번지기 때문에\n * 가장자리가 흐려지는 대신 직선으로 끊긴다.\n */\n.root {\n --blur-swap-blur: 4px;\n --blur-swap-offset: 8px;\n --blur-swap-duration: 300ms;\n --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 1);\n --blur-swap-grow-ease: var(--seed-timing-function-enter, cubic-bezier(0, 0, 0.15, 1));\n --blur-swap-shrink-ease: var(--seed-timing-function-exit, cubic-bezier(0.35, 0, 1, 1));\n\n position: relative;\n display: block;\n transition:\n width var(--blur-swap-duration) var(--blur-swap-grow-ease),\n height var(--blur-swap-duration) var(--blur-swap-grow-ease);\n}\n\n/*\n * 커질 때는 컨테이너가 앞서 나가고, 작아질 때는 버틴다. 들어오는 콘텐츠는 첫 프레임부터\n * 최종 크기로 그려지고 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너가 어느 쪽으로\n * 가든 큰 쪽을 늦게 놓아줘야 상자 밖에 콘텐츠가 나와 있는 시간이 짧아진다.\n */\n.root[data-resize=\"shrink\"] {\n transition-timing-function: var(--blur-swap-shrink-ease);\n}\n\n.inline {\n display: inline-block;\n vertical-align: top;\n}\n\n.content {\n position: relative;\n width: 100%;\n}\n\n/*\n * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가\n * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.\n */\n.contentAuto {\n width: max-content;\n}\n\n/*\n * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에\n * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다.\n */\n.layer[data-state=\"open\"] {\n animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n/*\n * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고,\n * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다.\n */\n.layer[data-state=\"closed\"] {\n position: absolute;\n inset-block-start: 0;\n inset-inline-start: 0;\n width: 100%;\n animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n.contentAuto .layer[data-state=\"closed\"] {\n width: max-content;\n}\n\n/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */\n.layer[data-initial][data-state=\"open\"] {\n animation: none;\n}\n\n@keyframes blurSwapEnter {\n from {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(var(--blur-swap-offset));\n }\n to {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n}\n\n@keyframes blurSwapExit {\n from {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n to {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(calc(-1 * var(--blur-swap-offset)));\n }\n}\n\n@keyframes blurSwapEnterReduced {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n@keyframes blurSwapExitReduced {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n}\n\n/*\n * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로\n * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다.\n */\n@media (prefers-reduced-motion: reduce) {\n .root {\n transition: none;\n }\n\n .layer[data-state=\"open\"] {\n animation-name: blurSwapEnterReduced;\n }\n\n .layer[data-state=\"closed\"] {\n animation-name: blurSwapExitReduced;\n }\n}\n" + "content": "/*\n * 자르지 않는 건 의도다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿으므로, 자르면 그동안\n * 두 콘텐츠가 상자를 넘긴 만큼이 계속 잘려 나간다. blur도 반경만큼 상자 밖으로 번지기 때문에\n * 가장자리가 흐려지는 대신 직선으로 끊긴다.\n */\n.root {\n --blur-swap-blur: 4px;\n --blur-swap-offset: 8px;\n --blur-swap-duration: 400ms;\n --blur-swap-ease: var(--seed-timing-function-easing, cubic-bezier(0.35, 0, 0.35, 1));\n --blur-swap-grow-ease: var(--seed-timing-function-enter, cubic-bezier(0, 0, 0.15, 1));\n --blur-swap-shrink-ease: var(--seed-timing-function-exit, cubic-bezier(0.35, 0, 1, 1));\n\n position: relative;\n display: block;\n transition:\n width var(--blur-swap-duration) var(--blur-swap-grow-ease),\n height var(--blur-swap-duration) var(--blur-swap-grow-ease);\n}\n\n/*\n * 커질 때는 컨테이너가 앞서 나가고, 작아질 때는 버틴다. 들어오는 콘텐츠는 첫 프레임부터\n * 최종 크기로 그려지고 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너가 어느 쪽으로\n * 가든 큰 쪽을 늦게 놓아줘야 상자 밖에 콘텐츠가 나와 있는 시간이 짧아진다.\n */\n.root[data-resize=\"shrink\"] {\n transition-timing-function: var(--blur-swap-shrink-ease);\n}\n\n.inline {\n display: inline-block;\n vertical-align: top;\n}\n\n.content {\n position: relative;\n width: 100%;\n}\n\n/*\n * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가\n * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.\n */\n.contentAuto {\n width: max-content;\n}\n\n/*\n * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에\n * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다.\n */\n.layer[data-state=\"open\"] {\n animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n/*\n * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고,\n * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다.\n */\n.layer[data-state=\"closed\"] {\n position: absolute;\n inset-block-start: 0;\n inset-inline-start: 0;\n width: 100%;\n animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both;\n}\n\n.contentAuto .layer[data-state=\"closed\"] {\n width: max-content;\n}\n\n/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */\n.layer[data-initial][data-state=\"open\"] {\n animation: none;\n}\n\n@keyframes blurSwapEnter {\n from {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(var(--blur-swap-offset));\n }\n to {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n}\n\n@keyframes blurSwapExit {\n from {\n opacity: 1;\n filter: blur(0);\n transform: translateY(0);\n }\n to {\n opacity: 0;\n filter: blur(var(--blur-swap-blur));\n transform: translateY(calc(-1 * var(--blur-swap-offset)));\n }\n}\n\n@keyframes blurSwapEnterReduced {\n from {\n opacity: 0;\n }\n to {\n opacity: 1;\n }\n}\n\n@keyframes blurSwapExitReduced {\n from {\n opacity: 1;\n }\n to {\n opacity: 0;\n }\n}\n\n/*\n * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로\n * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다.\n */\n@media (prefers-reduced-motion: reduce) {\n .root {\n transition: none;\n }\n\n .layer[data-state=\"open\"] {\n animation-name: blurSwapEnterReduced;\n }\n\n .layer[data-state=\"closed\"] {\n animation-name: blurSwapExitReduced;\n }\n}\n" } ] } \ No newline at end of file diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.module.css b/docs/registry/react/breeze/blur-swap/blur-swap.module.css index c4eab6f574..c09f42702a 100644 --- a/docs/registry/react/breeze/blur-swap/blur-swap.module.css +++ b/docs/registry/react/breeze/blur-swap/blur-swap.module.css @@ -6,8 +6,8 @@ .root { --blur-swap-blur: 4px; --blur-swap-offset: 8px; - --blur-swap-duration: 300ms; - --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 1); + --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)); diff --git a/docs/registry/react/breeze/blur-swap/blur-swap.tsx b/docs/registry/react/breeze/blur-swap/blur-swap.tsx index 56dc8302e9..ffa455543b 100644 --- a/docs/registry/react/breeze/blur-swap/blur-swap.tsx +++ b/docs/registry/react/breeze/blur-swap/blur-swap.tsx @@ -7,7 +7,7 @@ import styles from "./blur-swap.module.css"; const DEFAULT_BLUR = 4; const DEFAULT_OFFSET = 8; -const DEFAULT_DURATION_MS = 300; +const DEFAULT_DURATION_MS = 400; const useIsomorphicLayoutEffect = typeof document === "undefined" ? React.useEffect : React.useLayoutEffect; @@ -90,7 +90,7 @@ export interface BlurSwapProps { /** * 전환 길이 (ms) - * @default 300 + * @default 400 */ duration?: number; From 26d56e4ce0ca750366f67d89020fbeeb36d54ff2 Mon Sep 17 00:00:00 2001 From: Joo Chanhwi <56245920+te6-in@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:23:09 +0900 Subject: [PATCH 5/5] docs(breeze): show what each blur swap layer does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page described the swap as one motion, so there was nowhere to look up what the layer on the way out is doing versus the one coming in, and no example isolated either half. Add a section contrasting the two layers across opacity, blur, offset, placement, interactivity and lifetime, plus examples for the parts that only a running swap can show: playback speed down to 0.25x, appearing from and returning to empty, and an offset-free preset next to the existing ones. Move the panel example to a SegmentedControl and pin it to the top of a fixed box — the preview area centres its content, so growing the panel used to push the control that triggered it 40px up. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EMZNyR7oqm4pjdU4ACsgFG --- docs/content/breeze/components/blur-swap.mdx | 66 +++++++++++++++++--- docs/examples/breeze/blur-swap/anatomy.tsx | 48 ++++++++++++++ docs/examples/breeze/blur-swap/appear.tsx | 34 ++++++++++ docs/examples/breeze/blur-swap/panel.tsx | 20 +++--- docs/examples/breeze/blur-swap/tuning.tsx | 7 ++- 5 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 docs/examples/breeze/blur-swap/anatomy.tsx create mode 100644 docs/examples/breeze/blur-swap/appear.tsx diff --git a/docs/content/breeze/components/blur-swap.mdx b/docs/content/breeze/components/blur-swap.mdx index a76e15681f..be8280cc67 100644 --- a/docs/content/breeze/components/blur-swap.mdx +++ b/docs/content/breeze/components/blur-swap.mdx @@ -44,6 +44,32 @@ return ( ); ``` +### 나가는 콘텐츠와 들어오는 콘텐츠 + +전환이 시작되면 두 콘텐츠가 잠깐 같은 자리에 겹칩니다. 둘은 같은 시간 동안 서로 반대 방향으로 움직입니다. 나가는 쪽은 제자리에서 위로 밀리며 흐려지고, 들어오는 쪽은 아래에서 올라오며 선명해집니다. + +| | 나가는 콘텐츠 | 들어오는 콘텐츠 | +| ----------- | ----------------------------------- | ------------------------------------- | +| 투명도 | `1` → `0` | `0` → `1` | +| 흐림 | `0` → `blur` | `blur` → `0` | +| 이동 | 제자리 → `offset`만큼 위로 | `offset`만큼 아래 → 제자리 | +| 배치 | `position: absolute`로 흐름 밖 | 흐름 안. 컨테이너 크기의 기준이 됩니다 | +| 상호작용 | `inert`. 포커스·포인터·스크린리더에서 모두 빠집니다 | 평소와 같습니다 | +| 수명 | 퇴장 애니메이션이 끝나면 DOM에서 제거 | `activeKey`가 다시 바뀔 때까지 유지 | + +투명도·흐림·이동 세 가지가 하나의 `@keyframes`에 함께 들어 있어 같은 타이밍 함수를 공유합니다. 그래서 흐림 정도는 crossfade 진행도를 그대로 따라갑니다 — 반쯤 사라진 콘텐츠는 반쯤 흐려져 있습니다. + +느린 속도로 돌려보면 두 콘텐츠가 겹치는 구간이 보입니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/anatomy.tsx", + "codeblock": true + } + ``` + + ### 크기 애니메이션 범위 `size`로 컨테이너가 어느 축을 따라갈지 정합니다. @@ -52,6 +78,8 @@ return ( - `"height"`: 높이만 따라가고 너비는 부모를 채웁니다. 카드나 패널처럼 폭이 정해진 콘텐츠에 씁니다. - `"none"`: 크기를 건드리지 않습니다. 바깥에서 크기를 이미 정해둔 경우에 씁니다. +크기가 움직이면 주변 레이아웃도 함께 밀립니다. 전환을 일으키는 컨트롤이 그 영향권에 있으면 방금 누른 버튼이 손끝에서 달아나므로, 컨테이너가 한쪽으로만 자라도록 배치합니다. + ```json doc-gen:file { @@ -61,9 +89,24 @@ return ( ``` +### 빈 상태에서 등장하기 + +`children`이 비어 있는 키도 하나의 상태입니다. 빈 쪽에서 콘텐츠 쪽으로 넘어가면 등장이 되고, 반대로 넘어가면 퇴장이 됩니다. 컨테이너도 0에서부터 자라고 0으로 돌아갑니다. + +여기서는 `size="height"`를 씁니다. `"auto"`는 너비도 0에서 시작하는데, 콘텐츠는 컨테이너의 시작 모서리에 붙어 있으므로 가운데 정렬된 자리에서는 상자가 자라는 동안 콘텐츠가 옆으로 밀립니다. 빈 상태를 오가는 경우에는 그 폭이 콘텐츠 너비의 절반이나 되어 눈에 걸립니다. + + + ```json doc-gen:file + { + "file": "examples/breeze/blur-swap/appear.tsx", + "codeblock": true + } + ``` + + ### 전환 세기 조절 -`blur`는 전환 중 최대 blur 반경(px), `offset`은 콘텐츠가 흐르는 거리(px)입니다. `offset`이 양수면 아래에서 위로, 음수면 위에서 아래로 흐릅니다. 둘 다 `0`이면 제자리 crossfade가 됩니다. +`blur`는 전환 중 최대 blur 반경(px), `offset`은 콘텐츠가 흐르는 거리(px)입니다. `offset`이 양수면 아래에서 위로, 음수면 위에서 아래로 흐릅니다. 한쪽을 `0`으로 두면 그 축만 빠집니다 — `offset`이 `0`이면 제자리에서 흐려지기만 하고, `blur`가 `0`이면 흐림 없이 밀려나기만 합니다. ```json doc-gen:file @@ -74,24 +117,33 @@ return ( ``` -세 값 모두 CSS 커스텀 프로퍼티로 내려가므로, prop 대신 스타일시트에서 한 번에 정해도 됩니다. +`duration`은 전환 길이(ms)로 기본값은 `400`입니다. blur는 반쯤 흐려진 중간 상태가 보여야 효과가 읽히기 때문에, 여느 상태 전환보다 길게 잡혀 있습니다. 짧게 줄이면 crossfade만 남고 blur는 눈에 걸리지 않습니다. + +값은 모두 CSS 커스텀 프로퍼티로 내려가므로, prop 대신 스타일시트에서 한 번에 정해도 됩니다. ```css .my-swap { --blur-swap-blur: 12px; --blur-swap-offset: 20px; - --blur-swap-duration: 450ms; - --blur-swap-ease: cubic-bezier(0.22, 1, 0.36, 1); + --blur-swap-duration: 600ms; + --blur-swap-ease: cubic-bezier(0.35, 0, 0.35, 1); + --blur-swap-grow-ease: cubic-bezier(0, 0, 0.15, 1); + --blur-swap-shrink-ease: cubic-bezier(0.35, 0, 1, 1); } ``` +타이밍 함수는 셋으로 나뉘어 있고, 기본값은 각각 SEED의 `$timing-function.easing`, `$timing-function.enter`, `$timing-function.exit`입니다. + +- `--blur-swap-ease`: 두 콘텐츠의 crossfade. 중간 구간이 보여야 하므로 가운데에서 절반을 지나는 S자 곡선을 씁니다. +- `--blur-swap-grow-ease`: 컨테이너가 커질 때. 들어오는 콘텐츠는 첫 프레임부터 최종 크기로 그려지므로, 컨테이너가 앞서 자리를 만들어야 합니다. +- `--blur-swap-shrink-ease`: 컨테이너가 작아질 때. 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너는 그쪽이 다 사라질 때까지 버티다 뒤늦게 줄어듭니다. + ## 동작과 스타일 -- 나가는 콘텐츠와 들어오는 콘텐츠가 `opacity`, blur, 이동을 하나의 `@keyframes`로 함께 움직입니다. 셋이 같은 timing function을 공유하므로 blur는 저절로 crossfade 진행도에 묶입니다. - 퇴장은 `@seed-design/react-presence`의 `usePresence`가 붙잡습니다. `data-state`가 `closed`로 바뀌면서 `animation-name`이 달라지는 것을 감지해, 애니메이션이 끝날 때까지 DOM에 남겨둡니다. 그래서 **퇴장 애니메이션을 지우면 나가는 콘텐츠가 사라지지 않습니다.** 전환을 없애고 싶다면 애니메이션을 지우는 대신 `duration`을 `0`으로 두세요. - 나가는 콘텐츠는 `position: absolute`로 흐름 밖에 놓입니다. 들어오는 콘텐츠가 곧바로 자리를 차지하므로, 컨테이너 크기는 항상 들어오는 쪽을 기준으로 계산됩니다. -- 루트에 `overflow: hidden`이 걸려 있습니다. `offset`만큼의 이동이 가장자리 밖에서 시작하고 끝나는 대신, 콘텐츠 밖으로 넘치는 그림자나 포커스 링은 잘립니다. -- 크기는 `ResizeObserver`로 실측한 값을 루트에 적고 CSS `transition`으로 따라갑니다. `activeKey`가 그대로인 채 콘텐츠 자체가 커지거나 작아지는 경우에도 같은 전환을 탑니다. +- 루트는 콘텐츠를 **자르지 않습니다.** 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿기 때문에, 자르면 그동안 넘치는 만큼이 계속 잘려 나갑니다. blur도 반경만큼 상자 밖으로 번지므로 가장자리가 흐려지는 대신 직선으로 끊깁니다. 대신 전환 중에는 콘텐츠가 컨테이너 밖으로 잠깐 비어져 나올 수 있으니, 빽빽한 자리에 놓을 때는 가장 큰 콘텐츠를 기준으로 여백을 잡아 둡니다. +- 크기는 `ResizeObserver`로 실측한 값을 루트에 적고 CSS `transition`으로 따라갑니다. `activeKey`가 그대로인 채 콘텐츠 자체가 커지거나 작아지는 경우에도 같은 전환을 탑니다. 커지는 중인지 작아지는 중인지는 루트의 `data-resize`에 적혀 타이밍 함수를 고릅니다. - 모션 감소 설정에서는 blur와 이동을 뺀 keyframes로 바뀌어 crossfade만 남고, 크기는 전환 없이 즉시 맞춰집니다. JavaScript는 관여하지 않습니다. - 나가는 콘텐츠에는 `inert`가 걸립니다. 전환 중 같은 내용이 두 번 읽히거나, 사라지는 쪽이 포인터를 가로채지 않습니다. - `filter`는 새로운 stacking context를 만듭니다. 콘텐츠가 컨테이너 밖으로 겹쳐 나와야 한다면 `BlurSwap` 바깥에서 처리합니다. diff --git a/docs/examples/breeze/blur-swap/anatomy.tsx b/docs/examples/breeze/blur-swap/anatomy.tsx new file mode 100644 index 0000000000..843f363124 --- /dev/null +++ b/docs/examples/breeze/blur-swap/anatomy.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; +import { SegmentedControl, SegmentedControlItem } from "seed-design/ui/segmented-control"; + +const SPEEDS = [ + { id: "1x", duration: 400 }, + { id: "0.5x", duration: 800 }, + { id: "0.25x", duration: 1600 }, +]; + +const WEATHERS = ["맑음", "흐림", "비", "천둥번개"]; + +export default function BlurSwapAnatomy() { + const [speedId, setSpeedId] = useState(SPEEDS[0].id); + const [index, setIndex] = useState(0); + + const speed = SPEEDS.find((candidate) => candidate.id === speedId) ?? SPEEDS[0]; + + return ( + + + {SPEEDS.map((candidate) => ( + + {candidate.id} + + ))} + + + + + {WEATHERS[index]} + + + + setIndex((current) => (current + 1) % WEATHERS.length)} + > + 날씨 바꾸기 + + + ); +} diff --git a/docs/examples/breeze/blur-swap/appear.tsx b/docs/examples/breeze/blur-swap/appear.tsx new file mode 100644 index 0000000000..ce0316d13d --- /dev/null +++ b/docs/examples/breeze/blur-swap/appear.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Text, VStack } from "@seed-design/react"; +import { useState } from "react"; +import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; +import { ActionButton } from "seed-design/ui/action-button"; + +const NOTICES = [null, "저장했어요", null, "링크를 복사했어요"]; + +export default function BlurSwapAppear() { + const [index, setIndex] = useState(0); + + const notice = NOTICES[index]; + + return ( + + setIndex((current) => (current + 1) % NOTICES.length)} + > + {notice ? "메시지 지우기" : "메시지 띄우기"} + + + + {notice && ( + + {notice} + + )} + + + ); +} diff --git a/docs/examples/breeze/blur-swap/panel.tsx b/docs/examples/breeze/blur-swap/panel.tsx index 50d81b7c98..e87742dd84 100644 --- a/docs/examples/breeze/blur-swap/panel.tsx +++ b/docs/examples/breeze/blur-swap/panel.tsx @@ -1,9 +1,9 @@ "use client"; -import { Box, HStack, Text, VStack } from "@seed-design/react"; +import { Box, Text, VStack } from "@seed-design/react"; import { useState } from "react"; import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; -import { ActionButton } from "seed-design/ui/action-button"; +import { SegmentedControl, SegmentedControlItem } from "seed-design/ui/segmented-control"; const PLANS = [ { id: "basic", name: "베이직", lines: ["월 4,900원", "기본 기능 전체"] }, @@ -19,20 +19,16 @@ export default function BlurSwapPanel() { const plan = PLANS.find((candidate) => candidate.id === planId) ?? PLANS[0]; + // 패널은 아래로만 자라야 요금제를 고르는 손끝에서 컨트롤이 달아나지 않는다. return ( - - + + {PLANS.map((candidate) => ( - setPlanId(candidate.id)} - > + {candidate.name} - + ))} - + diff --git a/docs/examples/breeze/blur-swap/tuning.tsx b/docs/examples/breeze/blur-swap/tuning.tsx index 0f81de0e34..1eaa11f8c0 100644 --- a/docs/examples/breeze/blur-swap/tuning.tsx +++ b/docs/examples/breeze/blur-swap/tuning.tsx @@ -6,9 +6,10 @@ import { BlurSwap } from "seed-design/breeze/blur-swap/blur-swap"; import { ActionButton } from "seed-design/ui/action-button"; const PRESETS = [ - { id: "flat", label: "blur 0 / offset 8", blur: 0, offset: 8 }, + { id: "no-blur", label: "blur 0 / offset 8", blur: 0, offset: 8 }, + { id: "no-offset", label: "blur 4 / offset 0", blur: 4, offset: 0 }, { id: "default", label: "blur 4 / offset 8", blur: 4, offset: 8 }, - { id: "soft", label: "blur 12 / offset 20", blur: 12, offset: 20 }, + { id: "strong", label: "blur 12 / offset 20", blur: 12, offset: 20 }, ]; export default function BlurSwapTuning() { @@ -16,7 +17,7 @@ export default function BlurSwapTuning() { return ( - + {PRESETS.map((preset) => (