\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: 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/public/__registry__/react/breeze/index.json b/docs/public/__registry__/react/breeze/index.json
index 84a04b22c1..9337552812 100644
--- a/docs/public/__registry__/react/breeze/index.json
+++ b/docs/public/__registry__/react/breeze/index.json
@@ -16,6 +16,22 @@
"motion"
]
},
+ {
+ "snippets": [
+ {
+ "path": "blur-swap/blur-swap.tsx"
+ },
+ {
+ "path": "blur-swap/blur-swap.module.css"
+ }
+ ],
+ "id": "blur-swap",
+ "description": "두 콘텐츠를 blur crossfade로 교차하는 컴포넌트",
+ "dependencies": [
+ "@radix-ui/react-compose-refs",
+ "@seed-design/react-presence"
+ ]
+ },
{
"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..c09f42702a
--- /dev/null
+++ b/docs/registry/react/breeze/blur-swap/blur-swap.module.css
@@ -0,0 +1,137 @@
+/*
+ * 자르지 않는 건 의도다. 컨테이너 크기는 전환이 끝나야 새 콘텐츠에 닿으므로, 자르면 그동안
+ * 두 콘텐츠가 상자를 넘긴 만큼이 계속 잘려 나간다. blur도 반경만큼 상자 밖으로 번지기 때문에
+ * 가장자리가 흐려지는 대신 직선으로 끊긴다.
+ */
+.root {
+ --blur-swap-blur: 4px;
+ --blur-swap-offset: 8px;
+ --blur-swap-duration: 400ms;
+ --blur-swap-ease: var(--seed-timing-function-easing, cubic-bezier(0.35, 0, 0.35, 1));
+ --blur-swap-grow-ease: var(--seed-timing-function-enter, cubic-bezier(0, 0, 0.15, 1));
+ --blur-swap-shrink-ease: var(--seed-timing-function-exit, cubic-bezier(0.35, 0, 1, 1));
+
+ position: relative;
+ display: block;
+ transition:
+ width var(--blur-swap-duration) var(--blur-swap-grow-ease),
+ height var(--blur-swap-duration) var(--blur-swap-grow-ease);
+}
+
+/*
+ * 커질 때는 컨테이너가 앞서 나가고, 작아질 때는 버틴다. 들어오는 콘텐츠는 첫 프레임부터
+ * 최종 크기로 그려지고 나가는 콘텐츠는 끝까지 원래 크기를 지키므로, 컨테이너가 어느 쪽으로
+ * 가든 큰 쪽을 늦게 놓아줘야 상자 밖에 콘텐츠가 나와 있는 시간이 짧아진다.
+ */
+.root[data-resize="shrink"] {
+ transition-timing-function: var(--blur-swap-shrink-ease);
+}
+
+.inline {
+ display: inline-block;
+ vertical-align: top;
+}
+
+.content {
+ position: relative;
+ width: 100%;
+}
+
+/*
+ * 컨테이너 너비가 움직이는 동안 콘텐츠까지 같이 좁아지면 줄바꿈이 바뀌고, 그 높이 변화가
+ * 다시 컨테이너로 되먹임된다. 콘텐츠를 max-content로 고정해 그 고리를 끊는다.
+ */
+.contentAuto {
+ width: max-content;
+}
+
+/*
+ * opacity·blur·이동이 한 keyframe·한 timing function을 공유한다. blur가 opacity progress에
+ * 묶이는 건 그 결과이고, 값을 따로 보간해줄 필요가 없는 이유이기도 하다.
+ */
+.layer[data-state="open"] {
+ animation: blurSwapEnter var(--blur-swap-duration) var(--blur-swap-ease) both;
+}
+
+/*
+ * 나가는 레이어를 흐름 밖으로 빼야 들어오는 레이어가 곧바로 자리를 차지하고,
+ * 컨테이너 크기가 들어오는 쪽 기준으로 계산된다.
+ */
+.layer[data-state="closed"] {
+ position: absolute;
+ inset-block-start: 0;
+ inset-inline-start: 0;
+ width: 100%;
+ animation: blurSwapExit var(--blur-swap-duration) var(--blur-swap-ease) both;
+}
+
+.contentAuto .layer[data-state="closed"] {
+ width: max-content;
+}
+
+/* 마운트 때 처음 놓이는 레이어는 등장 애니메이션 없이 그대로 둔다. */
+.layer[data-initial][data-state="open"] {
+ animation: none;
+}
+
+@keyframes blurSwapEnter {
+ from {
+ opacity: 0;
+ filter: blur(var(--blur-swap-blur));
+ transform: translateY(var(--blur-swap-offset));
+ }
+ to {
+ opacity: 1;
+ filter: blur(0);
+ transform: translateY(0);
+ }
+}
+
+@keyframes blurSwapExit {
+ from {
+ opacity: 1;
+ filter: blur(0);
+ transform: translateY(0);
+ }
+ to {
+ opacity: 0;
+ filter: blur(var(--blur-swap-blur));
+ transform: translateY(calc(-1 * var(--blur-swap-offset)));
+ }
+}
+
+@keyframes blurSwapEnterReduced {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes blurSwapExitReduced {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+/*
+ * 애니메이션을 끄지 않고 이름만 바꾼다. 나가는 레이어를 언제 DOM에서 뗄지는 animationend로
+ * 판단하므로, 애니메이션이 사라지면 잔상이 그대로 남는다.
+ */
+@media (prefers-reduced-motion: reduce) {
+ .root {
+ transition: none;
+ }
+
+ .layer[data-state="open"] {
+ animation-name: blurSwapEnterReduced;
+ }
+
+ .layer[data-state="closed"] {
+ animation-name: blurSwapExitReduced;
+ }
+}
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..a942d0d34c
--- /dev/null
+++ b/docs/registry/react/breeze/blur-swap/blur-swap.test.tsx
@@ -0,0 +1,167 @@
+import { act, render } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
+import type * as React from "react";
+
+/**
+ * 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();
+ unobserve = mock();
+ disconnect = mock();
+}
+
+function renderBlurSwap(props: Partial