플로팅 내비게이션 독 스타일 9종 — 호버 물결이 따라오는
플로팅 내비게이션은 화면 가장자리에 띄워 둔 작은 메뉴 막대이고, 아이콘 단추 몇 개와 지금 어느 칸인지 알려 주는 표시 하나로 이루어집니다. 아홉 개 중 하나는 이웃 아이콘까지 밀어내며 부풉니다.
01 dock-nav-minimal-line
02 dock-nav-pill-slide
03 dock-nav-label-tooltip
04 dock-nav-magnify-wave
05 dock-nav-glass-frost
06 dock-nav-neu-soft
07 dock-nav-blur-badge
08 dock-nav-launchpad-grid
09 dock-nav-side-rail
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 미니멀 라인 독
- 02 필 슬라이드 인디케이터
- 03 라벨 툴팁
- 04 아이콘 확대 물결
- 05 글래스모피즘 독
- 06 뉴모피즘 독
- 07 반투명 블러 배지
- 08 런치 패드 격자 확장
- 09 세로 사이드 독
아홉 개는 인기순이 아니라 표시가 차지하는 크기가 점점 커지는 순서로 놓았습니다. 처음 셋은 아이콘 위에 더해지는 것이 선·면·글자입니다. 밑줄 한 줄(01), 칸을 통째로 채우는 알약(02), 아이콘 위로 올라오는 이름표(03) 순서입니다. 네 번째(04)부터는 아이콘 자체의 크기가 표시가 되고, 이때부터 옆 아이콘의 자리까지 계산해야 합니다. 다섯째와 여섯째는 표시의 모양이 아니라 막대의 재질을 바꾼 쪽이라 나란히 두었습니다 — 흐린 유리(05)와 부드러운 그림자(06)는 겨냥하는 자리가 같은데 인상은 정반대입니다. 일곱째(07)는 밝은 배경에서 유리가 정말 유리로 보이는지 확인하는 판이고, 여덟째(08)는 독이 판으로 펼쳐지는 경우, 마지막(09)은 가로가 아니라 css 사이드바처럼 세로로 세운 경우입니다. 아홉 개 모두 라이브러리 없이 돌아가고, 선택 상태가 있는 판은 그것을 클래스가 아니라 aria-current나 aria-pressed 같은 속성에 담아 둡니다. 독이 화면을 가르는 전환 쪽은 CSS 화면 전환 효과에 있고, 메뉴가 한 줄을 넘어 사이트 지도가 되는 쪽은 메가 메뉴 ui가 맡습니다. 돌이 커지는 폭은 이웃까지 포함해 두 칸으로 제한했고, 라벨이 겹칠 때는 툴팁이 위로만 뜨도록 방향을 고정했습니다. 아홉 칸이 모두 라벨을 달면 아래 줄이 흔들리므로 라벨을 다는 칸은 다섯까지로 끊고, 물결이 남은 칸으로 이어지도록 딜레이는 40ms 간격으로 정했습니다.
01미니멀 라인 독
독 메뉴에서 무엇이 보이나
얇은 테두리 바 안에 아이콘 다섯 개가 놓이고, 누른 항목 아래에서 밑줄이 가운데부터 양옆으로 자랍니다.

<main class="stage">
<div class="mn is-demo" id="mn">
<nav class="mn__dock" aria-label="주요 메뉴">
<button class="mn__btn" type="button" aria-label="홈">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="검색">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="좋아요">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="알림">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="내 정보">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
</nav>
<p class="mn__cap">누른 항목 아래 밑줄이 가운데서 자랍니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.mn {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.mn__dock {
display: flex;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 1.5px solid rgba(23, 20, 26, .3);
border-radius: $r-pill;
background: rgba(255, 255, 255, .94);
box-shadow: 0 8px 20px rgba(23, 20, 26, .08);
}
.mn__btn {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-sm;
background: none;
color: rgba(23, 20, 26, .48);
cursor: pointer;
transition: color $duration $easing, transform $duration $easing;
}
.mn__btn:hover { color: rgba(23, 20, 26, .85); }
.mn__btn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.mn__btn svg { width: 24px; height: 24px; display: block; }
.mn__btn.on { color: $color; transform: translateY(-2px); }
.mn__line {
position: absolute;
left: 11px;
right: 11px;
bottom: 2px;
height: 2px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transition: transform $duration $easing;
}
.mn__btn.on .mn__line { transform: scaleX(1); }
.mn__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, .62);
}
/* 자동 재생 — 항목마다 400ms 슬롯. delay 는 개별 속성으로(단축 리셋 방지). */
.is-demo .mn__line {
animation-name: mn-ln;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .mn__btn:nth-child(2) .mn__line { animation-delay: .4s; }
.is-demo .mn__btn:nth-child(3) .mn__line { animation-delay: .8s; }
.is-demo .mn__btn:nth-child(4) .mn__line { animation-delay: 1.2s; }
.is-demo .mn__btn:nth-child(5) .mn__line { animation-delay: 1.6s; }
.is-demo .mn__btn svg {
animation-name: mn-ic;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .mn__btn:nth-child(2) svg { animation-delay: .4s; }
.is-demo .mn__btn:nth-child(3) svg { animation-delay: .8s; }
.is-demo .mn__btn:nth-child(4) svg { animation-delay: 1.2s; }
.is-demo .mn__btn:nth-child(5) svg { animation-delay: 1.6s; }
@keyframes mn-ln {
0% { transform: scaleX(0); }
4% { transform: scaleX(1); }
15% { transform: scaleX(1); }
20% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@keyframes mn-ic {
0% { color: rgba(23, 20, 26, .48); }
20% { color: $subject-ink; }
100% { color: rgba(23, 20, 26, .48); }
}
@media (max-width: 340px) {
.mn__dock { gap: $sp-1; padding: $sp-2; }
.mn__btn { width: 40px; height: 40px; }
.mn__btn svg { width: 22px; height: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .mn__line,
.is-demo .mn__btn svg { animation-name: none; }
.mn__btn,
.mn__line { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mn {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.mn__dock {
display: flex;
gap: 8px;
padding: 8px 12px;
border: 1.5px solid rgba(23, 20, 26, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 8px 20px rgba(23, 20, 26, 0.08);
}
.mn__btn {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: rgba(23, 20, 26, 0.48);
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mn__btn:hover {
color: rgba(23, 20, 26, 0.85);
}
.mn__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mn__btn svg {
width: 24px;
height: 24px;
display: block;
}
.mn__btn.on {
color: #17141a;
transform: translateY(-2px);
}
.mn__line {
position: absolute;
left: 11px;
right: 11px;
bottom: 2px;
height: 2px;
border-radius: 999px;
background: #17141a;
transform: scaleX(0);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mn__btn.on .mn__line {
transform: scaleX(1);
}
.mn__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.62);
}
/* 자동 재생 — 항목마다 400ms 슬롯. delay 는 개별 속성으로(단축 리셋 방지). */
.is-demo .mn__line {
animation-name: mn-ln;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .mn__btn:nth-child(2) .mn__line {
animation-delay: 0.4s;
}
.is-demo .mn__btn:nth-child(3) .mn__line {
animation-delay: 0.8s;
}
.is-demo .mn__btn:nth-child(4) .mn__line {
animation-delay: 1.2s;
}
.is-demo .mn__btn:nth-child(5) .mn__line {
animation-delay: 1.6s;
}
.is-demo .mn__btn svg {
animation-name: mn-ic;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .mn__btn:nth-child(2) svg {
animation-delay: 0.4s;
}
.is-demo .mn__btn:nth-child(3) svg {
animation-delay: 0.8s;
}
.is-demo .mn__btn:nth-child(4) svg {
animation-delay: 1.2s;
}
.is-demo .mn__btn:nth-child(5) svg {
animation-delay: 1.6s;
}
@keyframes mn-ln {
0% {
transform: scaleX(0);
}
4% {
transform: scaleX(1);
}
15% {
transform: scaleX(1);
}
20% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@keyframes mn-ic {
0% {
color: rgba(23, 20, 26, 0.48);
}
20% {
color: #17141a;
}
100% {
color: rgba(23, 20, 26, 0.48);
}
}
@media (max-width: 340px) {
.mn__dock {
gap: 4px;
padding: 8px;
}
.mn__btn {
width: 40px;
height: 40px;
}
.mn__btn svg {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .mn__line,
.is-demo .mn__btn svg {
animation-name: none;
}
.mn__btn,
.mn__line {
transition: none;
}
}// 실제 동작: 클릭 → 활성 항목 교체(aria-current). 밑줄은 transform scaleX 전환.
var root = document.getElementById('mn');
var btns = Array.prototype.slice.call(root.querySelectorAll('.mn__btn'));
function pick(i) {
btns.forEach(function (b, j) {
b.classList.toggle('on', i === j);
if (i === j) b.setAttribute('aria-current', 'page');
else b.removeAttribute('aria-current');
});
}
pick(0);
btns.forEach(function (b, i) {
b.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () { pick(i); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MinimalLineDock.module.scss";
export type MinimalLineDockProps = {
/** 처음 켜 둘 항목 (0부터) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ICONS = [
{
label: "Home",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
),
},
{
label: "Search",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
),
},
{
label: "Likes",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
),
},
{
label: "Alerts",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
),
},
{
label: "Profile",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
),
},
];
export default function MinimalLineDock({
start = 0,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: MinimalLineDockProps) {
// 실제 동작: 클릭 → 활성 항목 교체(aria-current). 밑줄은 transform scaleX 전환.
const [on, setOn] = useState(start);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.mn} style={vars}>
<nav className={styles.dock} aria-label="Main menu">
{ICONS.map((it, i) => (
<button
key={it.label}
className={i === on ? `${styles.btn} ${styles.on}` : styles.btn}
type="button"
aria-label={it.label}
aria-current={i === on ? "page" : undefined}
onClick={() => setOn(i)}
>
{it.svg}
<i className={styles.line} aria-hidden="true" />
</button>
))}
</nav>
<p className={styles.cap}>An underline grows from the center of the item you pick</p>
</div>
);
}01 · 미니멀 라인 독 — 측정 누적면적 0.905% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
밑줄은 폭을 바꾸지 않고 scaleX만 0에서 1로 움직이므로 줄 전체가 다시 계산되지 않습니다.
.mn__line {
position: absolute;
left: 11px;
right: 11px;
bottom: 2px;
height: 2px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transition: transform $duration $easing;
}
.mn__btn.on .mn__line { transform: scaleX(1); }
02필 슬라이드 인디케이터
화면에서 일어나는 일
활성 칸을 채우는 알약 배경이 한 칸 폭만큼씩 옆으로 미끄러집니다.

<main class="stage">
<div class="pl is-demo" id="pl" style="--idx: 0">
<nav class="pl__dock" aria-label="주요 메뉴">
<i class="pl__pill" aria-hidden="true"></i>
<button class="pl__btn" type="button" aria-label="홈">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="pl__btn" type="button" aria-label="폴더">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button class="pl__btn" type="button" aria-label="채팅">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button class="pl__btn" type="button" aria-label="설정">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99.4 99.4 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
</button>
<button class="pl__btn" type="button" aria-label="내 정보">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</nav>
<p class="pl__cap">활성 칸의 알약 배경이 고정 px 만큼 밀려갑니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 등록해야 --idx 가 숫자로 보간된다 — 등록 안 하면 문자열이라 칸 사이를 못 건넌다
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl {
--idx: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.pl__dock {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .08);
}
.pl__pill {
position: absolute;
top: $sp-2;
left: $sp-2;
width: 48px;
height: 48px;
border-radius: $r-pill;
background: $color;
// 한 칸 = 항목 48px + gap 8px = 56px. 고정 px 이므로 항목은 flex: 0 0 auto 여야 한다
transform: translateX(calc(var(--idx) * 56px));
transition: transform $duration $easing;
}
.pl__btn {
position: relative;
flex: 0 0 auto;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: rgba(255, 255, 255, .55);
cursor: pointer;
transition: color $duration $easing;
}
.pl__btn svg { width: 24px; height: 24px; display: block; }
.pl__btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.pl__btn[aria-current="page"] { color: #fff; }
.pl__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, .62);
}
/* 자동 재생 — 알약이 다섯 칸을 돌고 90% 안에 첫 칸으로 복귀 */
.is-demo .pl__pill {
animation-name: pl-slide;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .pl__btn {
animation-name: pl-dim;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .pl__btn:nth-of-type(2) { animation-delay: .32s; }
.is-demo .pl__btn:nth-of-type(3) { animation-delay: .64s; }
.is-demo .pl__btn:nth-of-type(4) { animation-delay: .96s; }
.is-demo .pl__btn:nth-of-type(5) { animation-delay: 1.28s; }
@keyframes pl-slide {
0%, 12% { transform: translateX(0); }
16%, 28% { transform: translateX(56px); }
32%, 44% { transform: translateX(112px); }
48%, 60% { transform: translateX(168px); }
64%, 76% { transform: translateX(224px); }
86%, 100% { transform: translateX(0); }
}
@keyframes pl-dim {
0% { color: rgba(255, 255, 255, .55); }
4% { color: #fff; }
16% { color: rgba(255, 255, 255, .55); }
100% { color: rgba(255, 255, 255, .55); }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .pl__pill,
.is-demo .pl__btn { animation-name: none; }
.pl__pill,
.pl__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl {
--idx: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pl__dock {
position: relative;
display: flex;
gap: 8px;
padding: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.pl__pill {
position: absolute;
top: 8px;
left: 8px;
width: 48px;
height: 48px;
border-radius: 999px;
background: #2f6df6;
transform: translateX(calc(var(--idx) * 56px));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl__btn {
position: relative;
flex: 0 0 auto;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl__btn svg {
width: 24px;
height: 24px;
display: block;
}
.pl__btn:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.pl__btn[aria-current=page] {
color: #fff;
}
.pl__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.62);
}
/* 자동 재생 — 알약이 다섯 칸을 돌고 90% 안에 첫 칸으로 복귀 */
.is-demo .pl__pill {
animation-name: pl-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .pl__btn {
animation-name: pl-dim;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .pl__btn:nth-of-type(2) {
animation-delay: 0.32s;
}
.is-demo .pl__btn:nth-of-type(3) {
animation-delay: 0.64s;
}
.is-demo .pl__btn:nth-of-type(4) {
animation-delay: 0.96s;
}
.is-demo .pl__btn:nth-of-type(5) {
animation-delay: 1.28s;
}
@keyframes pl-slide {
0%, 12% {
transform: translateX(0);
}
16%, 28% {
transform: translateX(56px);
}
32%, 44% {
transform: translateX(112px);
}
48%, 60% {
transform: translateX(168px);
}
64%, 76% {
transform: translateX(224px);
}
86%, 100% {
transform: translateX(0);
}
}
@keyframes pl-dim {
0% {
color: rgba(255, 255, 255, 0.55);
}
4% {
color: #fff;
}
16% {
color: rgba(255, 255, 255, 0.55);
}
100% {
color: rgba(255, 255, 255, 0.55);
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .pl__pill,
.is-demo .pl__btn {
animation-name: none;
}
.pl__pill,
.pl__btn {
transition: none;
}
}// 실제 동작: 클릭 → --idx 값 교체. 알약은 translateX(var(--idx) * 56px) 로 이동(항목 폭 48 + gap 8).
var root = document.getElementById('pl');
var btns = Array.prototype.slice.call(root.querySelectorAll('.pl__btn'));
btns.forEach(function (b, i) {
if (i === 0) b.setAttribute('aria-current', 'page');
b.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () {
root.style.setProperty('--idx', i);
btns.forEach(function (x) { x.removeAttribute('aria-current'); });
b.setAttribute('aria-current', 'page');
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PillSlideDock.module.scss";
export type PillSlideDockProps = {
/** 처음 켜 둘 칸 (0부터) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--idx"?: string;
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
const ICONS = [
{
label: "Home",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
),
},
{
label: "Files",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
),
},
{
label: "Chat",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
),
},
{
label: "Settings",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99.4 99.4 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
),
},
{
label: "Profile",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
),
},
];
export default function PillSlideDock({
start = 0,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PillSlideDockProps) {
// 실제 동작: 클릭 → --idx 값 교체. 알약은 translateX(var(--idx) * 56px) 로 이동(항목 폭 48 + gap 8).
const [idx, setIdx] = useState(start);
const vars: Vars = {
"--idx": `${idx}`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.pl} style={vars}>
<nav className={styles.dock} aria-label="Main menu">
<i className={styles.pill} aria-hidden="true" />
{ICONS.map((it, i) => (
<button
key={it.label}
className={styles.btn}
type="button"
aria-label={it.label}
aria-current={i === idx ? "page" : undefined}
onClick={() => setIdx(i)}
>
{it.svg}
</button>
))}
</nav>
<p className={styles.cap}>The pill slides a fixed number of pixels to the active slot</p>
</div>
);
}02 · 필 슬라이드 인디케이터 — 측정 누적면적 7.554% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
한 칸은 아이콘 단추 48px에 간격 8px을 더한 56px 고정값이고, 아이콘은 제자리에 그대로 있습니다.
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl__pill {
position: absolute;
width: 48px;
height: 48px;
border-radius: $r-pill;
background: $color;
transform: translateX(calc(var(--idx) * 56px));
transition: transform $duration $easing;
}
.pl__btn { flex: 0 0 auto; width: 48px; }
03라벨 툴팁 위로
어떤 움직임인가
아이콘에 손을 올리면 이름표가 아이콘 위로 튀어 오릅니다.

<main class="stage">
<div class="tt is-demo" id="tt">
<nav class="tt__dock" aria-label="주요 메뉴">
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">홈</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span class="tt__sr">홈</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">검색</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
<span class="tt__sr">검색</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">사진</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
<span class="tt__sr">사진</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">즐겨찾기</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
<span class="tt__sr">즐겨찾기</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">설정</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99.4 99.4 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
<span class="tt__sr">설정</span>
</button>
</nav>
<p class="tt__cap">아이콘에 손을 올리면 이름표가 튀어 오릅니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-yellow);
.tt {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
// 이름표는 절대 배치라 부모 높이에 안 들어간다 — 위쪽 자리를 미리 비워 둔다
padding-top: $sp-10;
}
.tt__dock {
display: flex;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-pill;
background: $subject-ink;
}
.tt__btn {
position: relative;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: rgba(255, 255, 255, .6);
cursor: pointer;
transition: color $duration $easing, background-color $duration $easing;
}
.tt__btn svg { width: 24px; height: 24px; display: block; }
.tt__btn:hover,
.tt__btn:focus-visible { color: #fff; background: rgba(255, 255, 255, .12); }
.tt__btn:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.tt__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.tt__tip {
position: absolute;
bottom: calc(100% + #{$sp-2});
left: 50%;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $color;
color: $stage-yellow;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
// 경첩은 아이콘 쪽 변 — 아래에서 위로 펴진다
transform-origin: bottom center;
transform: translateX(-50%) scaleY(0);
transition: transform $duration $easing, opacity $dur-instant steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
.tt__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, .7);
}
/* 자동 재생 — 이름표가 왼쪽부터 차례로 올라온다. 사라짐은 컷(steps) */
.is-demo .tt__tip {
animation-name: tt-pop;
animation-duration: 2s;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.is-demo .tt__btn:nth-child(2) .tt__tip { animation-delay: .34s; }
.is-demo .tt__btn:nth-child(3) .tt__tip { animation-delay: .68s; }
.is-demo .tt__btn:nth-child(4) .tt__tip { animation-delay: 1.02s; }
.is-demo .tt__btn:nth-child(5) .tt__tip { animation-delay: 1.36s; }
.is-demo .tt__btn {
animation-name: tt-lit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .tt__btn:nth-child(2) { animation-delay: .34s; }
.is-demo .tt__btn:nth-child(3) { animation-delay: .68s; }
.is-demo .tt__btn:nth-child(4) { animation-delay: 1.02s; }
.is-demo .tt__btn:nth-child(5) { animation-delay: 1.36s; }
@keyframes tt-pop {
0% { opacity: 0; transform: translateX(-50%) scaleY(0); }
3% { opacity: 1; transform: translateX(-50%) scaleY(0); }
9% { opacity: 1; transform: translateX(-50%) scaleY(1); }
26% { opacity: 1; transform: translateX(-50%) scaleY(1); }
26.01% { opacity: 0; transform: translateX(-50%) scaleY(0); }
100% { opacity: 0; transform: translateX(-50%) scaleY(0); }
}
@keyframes tt-lit {
0% { color: rgba(255, 255, 255, .6); background: none; }
3% { color: #fff; background: rgba(255, 255, 255, .12); }
26% { color: rgba(255, 255, 255, .6); background: none; }
100% { color: rgba(255, 255, 255, .6); background: none; }
}
@media (max-width: 340px) {
.tt__btn { width: 44px; height: 44px; }
.tt { padding-top: $sp-8; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .tt__tip,
.is-demo .tt__btn { animation-name: none; }
.tt__tip,
.tt__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding-top: 40px;
}
.tt__dock {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 999px;
background: #17141a;
}
.tt__btn {
position: relative;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tt__btn svg {
width: 24px;
height: 24px;
display: block;
}
.tt__btn:hover,
.tt__btn:focus-visible {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.tt__btn:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.tt__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.tt__tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
padding: 4px 8px;
border-radius: 4px;
background: #17141a;
color: #ffd23f;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform-origin: bottom center;
transform: translateX(-50%) scaleY(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 140ms steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
.tt__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.7);
}
/* 자동 재생 — 이름표가 왼쪽부터 차례로 올라온다. 사라짐은 컷(steps) */
.is-demo .tt__tip {
animation-name: tt-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.is-demo .tt__btn:nth-child(2) .tt__tip {
animation-delay: 0.34s;
}
.is-demo .tt__btn:nth-child(3) .tt__tip {
animation-delay: 0.68s;
}
.is-demo .tt__btn:nth-child(4) .tt__tip {
animation-delay: 1.02s;
}
.is-demo .tt__btn:nth-child(5) .tt__tip {
animation-delay: 1.36s;
}
.is-demo .tt__btn {
animation-name: tt-lit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .tt__btn:nth-child(2) {
animation-delay: 0.34s;
}
.is-demo .tt__btn:nth-child(3) {
animation-delay: 0.68s;
}
.is-demo .tt__btn:nth-child(4) {
animation-delay: 1.02s;
}
.is-demo .tt__btn:nth-child(5) {
animation-delay: 1.36s;
}
@keyframes tt-pop {
0% {
opacity: 0;
transform: translateX(-50%) scaleY(0);
}
3% {
opacity: 1;
transform: translateX(-50%) scaleY(0);
}
9% {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
26% {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
26.01% {
opacity: 0;
transform: translateX(-50%) scaleY(0);
}
100% {
opacity: 0;
transform: translateX(-50%) scaleY(0);
}
}
@keyframes tt-lit {
0% {
color: rgba(255, 255, 255, 0.6);
background: none;
}
3% {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
26% {
color: rgba(255, 255, 255, 0.6);
background: none;
}
100% {
color: rgba(255, 255, 255, 0.6);
background: none;
}
}
@media (max-width: 340px) {
.tt__btn {
width: 44px;
height: 44px;
}
.tt {
padding-top: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .tt__tip,
.is-demo .tt__btn {
animation-name: none;
}
.tt__tip,
.tt__btn {
transition: none;
}
}// 실제 동작: 사람이 만지면 .is-demo 를 떼고, 그 뒤로는 :hover · :focus-visible 이 이름표를 연다.
var root = document.getElementById('tt');
['pointerenter', 'focusin'].forEach(function (t) {
root.addEventListener(t, function () { root.classList.remove('is-demo'); });
});import type { CSSProperties } from "react";
import styles from "./LabelTooltipDock.module.scss";
export type LabelTooltipDockProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ICONS = [
{
label: "Home",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
),
},
{
label: "Search",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
),
},
{
label: "Photos",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
),
},
{
label: "Favorites",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
),
},
{
label: "Settings",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99.4 99.4 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
),
},
];
export default function LabelTooltipDock({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: LabelTooltipDockProps) {
// 실제 동작: :hover · :focus-visible 이 이름표를 연다 — 상태 없이 CSS 가 전부 처리한다.
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.tt} style={vars}>
<nav className={styles.dock} aria-label="Main menu">
{ICONS.map((it) => (
<button key={it.label} className={styles.btn} type="button">
<span className={styles.tip} aria-hidden="true">{it.label}</span>
{it.svg}
<span className={styles.sr}>{it.label}</span>
</button>
))}
</nav>
<p className={styles.cap}>Hover an icon and its label pops up</p>
</div>
);
}03 · 라벨 툴팁 — 측정 누적면적 10.12% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
경첩은 이름표의 아래쪽 변이라 transform-origin: bottom center가 필요하고, 사라질 때는 흐려지지 않고 한 프레임에 꺼집니다.
.tt__tip {
position: absolute;
bottom: calc(100% + #{$sp-2});
left: 50%;
opacity: 0;
transform-origin: bottom center;
transform: translateX(-50%) scaleY(0);
transition: transform $duration $easing, opacity $dur-instant steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
04아이콘 확대 물결
무엇이 보이나
커서에 가까운 아이콘이 가장 크게 부풀고 이웃은 거리에 따라 작아집니다. 어려운 건 배율이 아니라 밀림입니다.

<main class="stage">
<div class="mg is-demo" id="mg">
<nav class="mg__dock" id="mg-dock" aria-label="주요 메뉴">
<button class="mg__btn" type="button" aria-label="홈">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="검색">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="폴더">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="채팅">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="사진">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="알림">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="내 정보">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</nav>
<p class="mg__cap">커서에 가까울수록 크게, 이웃은 밀려납니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
// 등록해야 배율·밀림이 수로 보간된다 — 등록 안 하면 문자열이라 중간값이 없다
@property --s {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
@property --x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.mg {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-5;
// 아이콘이 위로 부풀어 오르는 만큼 위쪽 자리를 비워 둔다
padding-top: $sp-6;
}
.mg__dock {
display: flex;
align-items: flex-end;
gap: $sp-1;
padding: $sp-2 $sp-3;
border-radius: $r-card;
background: rgba(255, 255, 255, .1);
}
.mg__btn {
--s: 1;
--x: 0px;
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: end center;
padding: 0;
border: 0;
background: none;
color: $color;
cursor: pointer;
transform: translateX(var(--x));
transition: transform $duration linear;
}
.mg__btn:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; border-radius: $r-xs; }
.mg__btn svg {
width: 32px;
height: 32px;
display: block;
transform-origin: bottom center; // 규칙8: 독 바닥서 위로 커짐
scale: var(--s);
transition: scale $duration linear;
}
.mg__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, .62);
}
/* 자동 재생 — 물결이 왼쪽에서 오른쪽으로 지나간다. 지연은 개별 속성으로 */
.is-demo .mg__btn {
animation-name: mg-push;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .mg__btn svg {
animation-name: mg-swell;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .mg__btn:nth-child(2),
.is-demo .mg__btn:nth-child(2) svg { animation-delay: .2s; }
.is-demo .mg__btn:nth-child(3),
.is-demo .mg__btn:nth-child(3) svg { animation-delay: .4s; }
.is-demo .mg__btn:nth-child(4),
.is-demo .mg__btn:nth-child(4) svg { animation-delay: .6s; }
.is-demo .mg__btn:nth-child(5),
.is-demo .mg__btn:nth-child(5) svg { animation-delay: .8s; }
.is-demo .mg__btn:nth-child(6),
.is-demo .mg__btn:nth-child(6) svg { animation-delay: 1s; }
.is-demo .mg__btn:nth-child(7),
.is-demo .mg__btn:nth-child(7) svg { animation-delay: 1.2s; }
@keyframes mg-push {
0% { transform: translateX(0); }
4% { transform: translateX(9px); }
10% { transform: translateX(0); }
16% { transform: translateX(-9px); }
24% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes mg-swell {
0% { scale: 1; }
4% { scale: 1.3; }
10% { scale: 1.8; }
16% { scale: 1.3; }
24% { scale: 1; }
100% { scale: 1; }
}
@media (max-width: 340px) {
.mg__btn { width: 36px; height: 36px; }
.mg__btn svg { width: 26px; height: 26px; }
.mg__dock { padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .mg__btn,
.is-demo .mg__btn svg { animation-name: none; }
.mg__btn,
.mg__btn svg { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --s {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
@property --x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.mg {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding-top: 24px;
}
.mg__dock {
display: flex;
align-items: flex-end;
gap: 4px;
padding: 8px 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.1);
}
.mg__btn {
--s: 1;
--x: 0px;
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: end center;
padding: 0;
border: 0;
background: none;
color: #fff7e6;
cursor: pointer;
transform: translateX(var(--x));
transition: transform 140ms linear;
}
.mg__btn:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
border-radius: 4px;
}
.mg__btn svg {
width: 32px;
height: 32px;
display: block;
transform-origin: bottom center;
scale: var(--s);
transition: scale 140ms linear;
}
.mg__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.62);
}
/* 자동 재생 — 물결이 왼쪽에서 오른쪽으로 지나간다. 지연은 개별 속성으로 */
.is-demo .mg__btn {
animation-name: mg-push;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .mg__btn svg {
animation-name: mg-swell;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .mg__btn:nth-child(2),
.is-demo .mg__btn:nth-child(2) svg {
animation-delay: 0.2s;
}
.is-demo .mg__btn:nth-child(3),
.is-demo .mg__btn:nth-child(3) svg {
animation-delay: 0.4s;
}
.is-demo .mg__btn:nth-child(4),
.is-demo .mg__btn:nth-child(4) svg {
animation-delay: 0.6s;
}
.is-demo .mg__btn:nth-child(5),
.is-demo .mg__btn:nth-child(5) svg {
animation-delay: 0.8s;
}
.is-demo .mg__btn:nth-child(6),
.is-demo .mg__btn:nth-child(6) svg {
animation-delay: 1s;
}
.is-demo .mg__btn:nth-child(7),
.is-demo .mg__btn:nth-child(7) svg {
animation-delay: 1.2s;
}
@keyframes mg-push {
0% {
transform: translateX(0);
}
4% {
transform: translateX(9px);
}
10% {
transform: translateX(0);
}
16% {
transform: translateX(-9px);
}
24% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes mg-swell {
0% {
scale: 1;
}
4% {
scale: 1.3;
}
10% {
scale: 1.8;
}
16% {
scale: 1.3;
}
24% {
scale: 1;
}
100% {
scale: 1;
}
}
@media (max-width: 340px) {
.mg__btn {
width: 36px;
height: 36px;
}
.mg__btn svg {
width: 26px;
height: 26px;
}
.mg__dock {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .mg__btn,
.is-demo .mg__btn svg {
animation-name: none;
}
.mg__btn,
.mg__btn svg {
transition: none;
}
}// 실제 동작: 포인터 x → 아이콘마다 중심 거리 d 를 재고, 거리에 따라 배율을 준다.
// 이웃 밀림은 배율이 만든 폭 증가분의 절반씩을 누적해 좌우로 나눠 주는 계산.
var root = document.getElementById('mg');
var dock = document.getElementById('mg-dock');
var btns = Array.prototype.slice.call(dock.querySelectorAll('.mg__btn'));
var W = 44, RANGE = 90, MAX = 0.8;
function wave(x) {
var s = btns.map(function (b) {
var c = b.offsetLeft + W / 2;
var t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
return 1 + MAX * t * t;
});
var grown = s.map(function (v) { return (v - 1) * W; });
var total = grown.reduce(function (a, b) { return a + b; }, 0);
var before = 0;
btns.forEach(function (b, i) {
// 내 앞 아이콘들이 불어난 폭 + 내가 불어난 폭의 절반 − 전체의 절반(줄 중심 유지)
b.style.setProperty('--s', s[i].toFixed(3));
b.style.setProperty('--x', (before + grown[i] / 2 - total / 2).toFixed(1) + 'px');
before += grown[i];
});
}
function rest() { btns.forEach(function (b) { b.style.setProperty('--s', 1); b.style.setProperty('--x', '0px'); }); }
dock.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo');
wave(e.clientX - dock.getBoundingClientRect().left);
});
dock.addEventListener('pointerleave', rest);import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MagnifyWaveDock.module.scss";
export type MagnifyWaveDockProps = {
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BtnVars = CSSProperties & { "--s"?: string; "--x"?: string };
/** 아이콘 하나의 배율(s)과 좌우 밀림(x, px) */
type Wave = { s: number; x: number };
/** 아이콘 폭 · 물결이 닿는 거리 · 최대 추가 배율 — vanilla 와 같은 값 */
const W = 44;
const RANGE = 90;
const MAX = 0.8;
const ITEMS = [
{
label: "Home",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2" /><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" /></g></svg>
),
},
{
label: "Search",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" /><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" /></g></svg>
),
},
{
label: "Files",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" /><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" /></g></svg>
),
},
{
label: "Chat",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2" /><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216" /></g></svg>
),
},
{
label: "Photos",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" /><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12" /></g></svg>
),
},
{
label: "Alerts",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g></svg>
),
},
{
label: "Profile",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" /><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" /></g></svg>
),
},
];
const REST: Wave[] = ITEMS.map(function (): Wave { return { s: 1, x: 0 }; });
export default function MagnifyWaveDock({
duration = 140,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: MagnifyWaveDockProps) {
const dockRef = useRef<HTMLElement>(null);
const [wv, setWv] = useState<Wave[]>(REST);
// 포인터 x → 아이콘마다 중심 거리 d 를 재고, 거리에 따라 배율을 준다.
// 이웃 밀림은 배율이 만든 폭 증가분의 절반씩을 누적해 좌우로 나눠 주는 계산.
function wave(x: number): void {
const dock = dockRef.current;
if (!dock) return;
const btns = Array.from(dock.querySelectorAll("button"));
const s = btns.map(function (b) {
const c = b.offsetLeft + W / 2;
const t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
return 1 + MAX * t * t;
});
const grown = s.map(function (v) { return (v - 1) * W; });
const total = grown.reduce(function (a, b) { return a + b; }, 0);
let before = 0;
const next = s.map(function (v, i): Wave {
// 내 앞 아이콘들이 불어난 폭 + 내가 불어난 폭의 절반 − 전체의 절반(줄 중심 유지)
const w = { s: v, x: before + grown[i] / 2 - total / 2 };
before += grown[i];
return w;
});
setWv(next);
}
function onMove(e: PEvt): void {
const dock = dockRef.current;
if (!dock) return;
wave(e.clientX - dock.getBoundingClientRect().left);
}
function onLeave(): void { setWv(REST); }
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.mg} style={vars}>
<nav
className={styles.dock}
ref={dockRef}
aria-label="Main menu"
onPointerMove={onMove}
onPointerLeave={onLeave}
>
{ITEMS.map(function (it, i) {
const bv: BtnVars = {
"--s": (wv[i] ? wv[i].s : 1).toFixed(3),
"--x": `${(wv[i] ? wv[i].x : 0).toFixed(1)}px`,
};
return (
<button className={styles.btn} type="button" key={it.label} aria-label={it.label} style={bv}>
{it.icon}
</button>
);
})}
</nav>
<p className={styles.cap}>Closer to the pointer means bigger; neighbors get pushed aside</p>
</div>
);
}04 · 아이콘 확대 물결 — 측정 누적면적 9.015% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
부풀어 늘어난 폭을 앞 아이콘부터 누적해 나눠 주고 마지막에 전체 증가분의 절반을 빼야 줄이 가운데를 지킵니다.
function wave(x) {
var s = btns.map(function (b) {
var c = b.offsetLeft + W / 2;
var t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
return 1 + MAX * t * t;
});
var grown = s.map(function (v) { return (v - 1) * W; });
var total = grown.reduce(function (a, b) { return a + b; }, 0);
var before = 0;
btns.forEach(function (b, i) {
b.style.setProperty('--s', s[i].toFixed(3));
b.style.setProperty('--x', (before + grown[i] / 2 - total / 2).toFixed(1) + 'px');
before += grown[i];
});
}
05글래스모피즘 독
화면에서 일어나는 일
흐린 유리 막대 뒤에 보라·하늘색 원형 얼룩을 깔아 두었습니다. 유리는 뒤에 무언가 있어야 유리로 보이므로 얼룩이 장식이 아니라 재료입니다.

<main class="stage">
<div class="gl is-demo" id="gl" style="--idx: 0">
<i class="gl__blob gl__blob--a" aria-hidden="true"></i>
<i class="gl__blob gl__blob--b" aria-hidden="true"></i>
<nav class="gl__dock" aria-label="주요 메뉴">
<i class="gl__pill" aria-hidden="true"></i>
<button class="gl__btn" type="button" aria-label="홈">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="gl__btn" type="button" aria-label="검색">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="gl__btn" type="button" aria-label="좋아요">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
</button>
<button class="gl__btn" type="button" aria-label="알림">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button class="gl__btn" type="button" aria-label="내 정보">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</nav>
<p class="gl__cap">흐린 유리 뒤로 배경 색이 번져 보입니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.gl {
--idx: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-6 $sp-8;
border-radius: $r-card;
overflow: hidden;
background: #241d33;
}
/* 유리 뒤에 깔아 두는 색 얼룩 — 이게 있어야 흐림이 눈에 보인다 */
.gl__blob {
position: absolute;
width: 168px;
height: 168px;
border-radius: 50%;
filter: blur(4px);
}
.gl__blob--a {
top: -40px;
left: -20px;
background: radial-gradient(circle, rgba(185, 160, 255, .95), rgba(185, 160, 255, 0) 70%);
}
.gl__blob--b {
bottom: -48px;
right: -16px;
background: radial-gradient(circle, rgba(110, 200, 255, .9), rgba(110, 200, 255, 0) 70%);
}
.gl__dock {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-2;
border: 1px solid rgba(255, 255, 255, .3);
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
backdrop-filter: blur(12px) saturate(1.6);
box-shadow: 0 12px 28px rgba(23, 20, 26, .4);
}
.gl__pill {
position: absolute;
top: $sp-2;
left: $sp-2;
width: 44px;
height: 44px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .32);
transform: translateX(calc(var(--idx) * 52px));
transition: transform $duration $easing;
}
.gl__btn {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: rgba(255, 255, 255, .72);
cursor: pointer;
transition: transform $duration $easing, color $duration $easing;
}
.gl__btn svg { width: 22px; height: 22px; display: block; }
.gl__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.gl__btn[aria-current="page"] { color: #fff; transform: translateY(-3px); }
.gl__cap {
position: relative;
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, .68);
}
/* 자동 재생 — 알약이 다섯 칸을 돌고 88% 에 첫 칸으로 복귀 */
.is-demo .gl__pill {
animation-name: gl-slide;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .gl__btn {
animation-name: gl-lift;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .gl__btn:nth-of-type(2) { animation-delay: .32s; }
.is-demo .gl__btn:nth-of-type(3) { animation-delay: .64s; }
.is-demo .gl__btn:nth-of-type(4) { animation-delay: .96s; }
.is-demo .gl__btn:nth-of-type(5) { animation-delay: 1.28s; }
@keyframes gl-slide {
0%, 12% { transform: translateX(0); }
16%, 28% { transform: translateX(52px); }
32%, 44% { transform: translateX(104px); }
48%, 60% { transform: translateX(156px); }
64%, 76% { transform: translateX(208px); }
88%, 100% { transform: translateX(0); }
}
@keyframes gl-lift {
0% { transform: translateY(0); color: rgba(255, 255, 255, .72); }
4% { transform: translateY(-3px); color: #fff; }
14% { transform: translateY(-3px); color: #fff; }
18% { transform: translateY(0); color: rgba(255, 255, 255, .72); }
100% { transform: translateY(0); color: rgba(255, 255, 255, .72); }
}
@media (max-width: 340px) {
.gl { padding: $sp-5 $sp-4; }
.gl__blob { width: 120px; height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .gl__pill,
.is-demo .gl__btn { animation-name: none; }
.gl__pill,
.gl__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.gl {
--idx: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px 32px;
border-radius: 16px;
overflow: hidden;
background: #241d33;
}
/* 유리 뒤에 깔아 두는 색 얼룩 — 이게 있어야 흐림이 눈에 보인다 */
.gl__blob {
position: absolute;
width: 168px;
height: 168px;
border-radius: 50%;
filter: blur(4px);
}
.gl__blob--a {
top: -40px;
left: -20px;
background: radial-gradient(circle, rgba(185, 160, 255, 0.95), rgba(185, 160, 255, 0) 70%);
}
.gl__blob--b {
bottom: -48px;
right: -16px;
background: radial-gradient(circle, rgba(110, 200, 255, 0.9), rgba(110, 200, 255, 0) 70%);
}
.gl__dock {
position: relative;
display: flex;
gap: 8px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(12px) saturate(1.6);
box-shadow: 0 12px 28px rgba(23, 20, 26, 0.4);
}
.gl__pill {
position: absolute;
top: 8px;
left: 8px;
width: 44px;
height: 44px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.32);
transform: translateX(calc(var(--idx) * 52px));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gl__btn {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gl__btn svg {
width: 22px;
height: 22px;
display: block;
}
.gl__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.gl__btn[aria-current=page] {
color: #fff;
transform: translateY(-3px);
}
.gl__cap {
position: relative;
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.68);
}
/* 자동 재생 — 알약이 다섯 칸을 돌고 88% 에 첫 칸으로 복귀 */
.is-demo .gl__pill {
animation-name: gl-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .gl__btn {
animation-name: gl-lift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .gl__btn:nth-of-type(2) {
animation-delay: 0.32s;
}
.is-demo .gl__btn:nth-of-type(3) {
animation-delay: 0.64s;
}
.is-demo .gl__btn:nth-of-type(4) {
animation-delay: 0.96s;
}
.is-demo .gl__btn:nth-of-type(5) {
animation-delay: 1.28s;
}
@keyframes gl-slide {
0%, 12% {
transform: translateX(0);
}
16%, 28% {
transform: translateX(52px);
}
32%, 44% {
transform: translateX(104px);
}
48%, 60% {
transform: translateX(156px);
}
64%, 76% {
transform: translateX(208px);
}
88%, 100% {
transform: translateX(0);
}
}
@keyframes gl-lift {
0% {
transform: translateY(0);
color: rgba(255, 255, 255, 0.72);
}
4% {
transform: translateY(-3px);
color: #fff;
}
14% {
transform: translateY(-3px);
color: #fff;
}
18% {
transform: translateY(0);
color: rgba(255, 255, 255, 0.72);
}
100% {
transform: translateY(0);
color: rgba(255, 255, 255, 0.72);
}
}
@media (max-width: 340px) {
.gl {
padding: 20px 16px;
}
.gl__blob {
width: 120px;
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .gl__pill,
.is-demo .gl__btn {
animation-name: none;
}
.gl__pill,
.gl__btn {
transition: none;
}
}// 실제 동작: 클릭 → --idx 교체. 알약이 유리 위를 미끄러지고 해당 아이콘이 살짝 들린다.
var root = document.getElementById('gl');
var btns = Array.prototype.slice.call(root.querySelectorAll('.gl__btn'));
btns[0].setAttribute('aria-current', 'page');
btns.forEach(function (b, i) {
b.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () {
root.style.setProperty('--idx', i);
btns.forEach(function (x) { x.removeAttribute('aria-current'); });
b.setAttribute('aria-current', 'page');
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GlassFrostDock.module.scss";
export type GlassFrostDockProps = {
/** 처음 켜져 있는 칸 */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--idx"?: number;
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
const ITEMS = [
{
label: "Home",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2" /><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" /></g></svg>
),
},
{
label: "Search",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" /><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" /></g></svg>
),
},
{
label: "Likes",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" /><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" /></g></svg>
),
},
{
label: "Alerts",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g></svg>
),
},
{
label: "Profile",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" /><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" /></g></svg>
),
},
];
export default function GlassFrostDock({
start = 0,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b9a0ff",
}: GlassFrostDockProps) {
// 클릭 → --idx 교체. 알약이 유리 위를 미끄러지고 해당 아이콘이 살짝 들린다.
const [idx, setIdx] = useState(start);
const vars: Vars = {
"--idx": idx,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.gl} style={vars}>
{/* 유리 뒤에 깔아 두는 색 얼룩 — 이게 있어야 흐림이 눈에 보인다 */}
<i className={`${styles.blob} ${styles.blobA}`} aria-hidden="true" />
<i className={`${styles.blob} ${styles.blobB}`} aria-hidden="true" />
<nav className={styles.dock} aria-label="Main menu">
<i className={styles.pill} aria-hidden="true" />
{ITEMS.map(function (it, i) {
return (
<button
className={styles.btn}
type="button"
key={it.label}
aria-label={it.label}
aria-current={i === idx ? "page" : undefined}
onClick={function () { setIdx(i); }}
>
{it.icon}
</button>
);
})}
</nav>
<p className={styles.cap}>Background colors smear through the frosted glass</p>
</div>
);
}05 · 글래스모피즘 독 — 측정 누적면적 6.322% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
활성 알약이 유리 위를 미끄러지고 그 아이콘만 3px 들립니다.
.gl__blob--a {
top: -40px;
left: -20px;
background: radial-gradient(circle, rgba(185, 160, 255, .95), rgba(185, 160, 255, 0) 70%);
}
.gl__dock {
border: 1px solid rgba(255, 255, 255, .3);
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
backdrop-filter: blur(12px) saturate(1.6);
}
.gl__btn[aria-current="page"] { color: #fff; transform: translateY(-3px); }
06뉴모피즘 독
어떤 움직임인가
배경과 단추가 같은 색이고 그림자 한 쌍만으로 높낮이를 만듭니다.

<main class="stage">
<div class="nu is-demo" id="nu">
<nav class="nu__dock" aria-label="주요 메뉴">
<button class="nu__btn" type="button" aria-label="홈" aria-pressed="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="nu__btn" type="button" aria-label="검색" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="nu__btn" type="button" aria-label="폴더" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button class="nu__btn" type="button" aria-label="알림" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button class="nu__btn" type="button" aria-label="내 정보" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</nav>
<p class="nu__cap">누른 아이콘은 그림자가 안으로 파입니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: #6d5fae;
$bg: #e8e6f0;
$light: #ffffff;
$dark: #a9a3c0;
@include stage($bg);
.nu {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-5;
}
.nu__dock {
display: flex;
gap: $sp-3;
padding: $sp-3 $sp-4;
border-radius: $r-pill;
background: $bg;
box-shadow: 8px 8px 16px $dark, -8px -8px 16px $light;
}
.nu__btn {
flex: 0 0 auto;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: $bg;
color: rgba(60, 54, 84, .6);
cursor: pointer;
/* 떠오른 상태 — 바깥 그림자 두 개 */
box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light;
transition: box-shadow $duration $easing, color $duration $easing;
}
.nu__btn svg { width: 22px; height: 22px; display: block; }
.nu__btn:focus-visible { outline: 2px solid $color; outline-offset: 4px; }
.nu__btn[aria-pressed="true"] {
color: $color;
/* 눌린 상태 — 같은 그림자를 inset 으로 뒤집는다 */
box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light;
}
.nu__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(60, 54, 84, .82); // 뉴모피즘 연면 위 흐린 글자 3.40:1 → 4.6:1 이상
}
/* 자동 재생 — 왼쪽부터 하나씩 눌렸다 떠오른다. 마지막은 84% 에 풀린다 */
.is-demo .nu__btn {
animation-name: nu-press;
animation-duration: 2s;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.is-demo .nu__btn:nth-child(2) { animation-delay: .34s; }
.is-demo .nu__btn:nth-child(3) { animation-delay: .68s; }
.is-demo .nu__btn:nth-child(4) { animation-delay: 1.02s; }
.is-demo .nu__btn:nth-child(5) { animation-delay: 1.36s; }
@keyframes nu-press {
0% { box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light; color: rgba(60, 54, 84, .6); }
8% { box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light; color: $color; }
17% { box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light; color: $color; }
24% { box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light; color: rgba(60, 54, 84, .6); }
100% { box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light; color: rgba(60, 54, 84, .6); }
}
@media (max-width: 340px) {
.nu__dock { gap: $sp-2; padding: $sp-2 $sp-3; }
.nu__btn { width: 44px; height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .nu__btn { animation-name: none; }
.nu__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #e8e6f0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.nu {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.nu__dock {
display: flex;
gap: 12px;
padding: 12px 16px;
border-radius: 999px;
background: #e8e6f0;
box-shadow: 8px 8px 16px #a9a3c0, -8px -8px 16px #ffffff;
}
.nu__btn {
flex: 0 0 auto;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: #e8e6f0;
color: rgba(60, 54, 84, 0.6);
cursor: pointer;
/* 떠오른 상태 — 바깥 그림자 두 개 */
box-shadow: 5px 5px 10px #a9a3c0, -5px -5px 10px #ffffff;
transition: box-shadow 220ms ease-out, color 220ms ease-out;
}
.nu__btn svg {
width: 22px;
height: 22px;
display: block;
}
.nu__btn:focus-visible {
outline: 2px solid #6d5fae;
outline-offset: 4px;
}
.nu__btn[aria-pressed=true] {
color: #6d5fae;
/* 눌린 상태 — 같은 그림자를 inset 으로 뒤집는다 */
box-shadow: inset 5px 5px 10px #a9a3c0, inset -5px -5px 10px #ffffff;
}
.nu__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(60, 54, 84, 0.82);
}
/* 자동 재생 — 왼쪽부터 하나씩 눌렸다 떠오른다. 마지막은 84% 에 풀린다 */
.is-demo .nu__btn {
animation-name: nu-press;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.is-demo .nu__btn:nth-child(2) {
animation-delay: 0.34s;
}
.is-demo .nu__btn:nth-child(3) {
animation-delay: 0.68s;
}
.is-demo .nu__btn:nth-child(4) {
animation-delay: 1.02s;
}
.is-demo .nu__btn:nth-child(5) {
animation-delay: 1.36s;
}
@keyframes nu-press {
0% {
box-shadow: 5px 5px 10px #a9a3c0, -5px -5px 10px #ffffff;
color: rgba(60, 54, 84, 0.6);
}
8% {
box-shadow: inset 5px 5px 10px #a9a3c0, inset -5px -5px 10px #ffffff;
color: #6d5fae;
}
17% {
box-shadow: inset 5px 5px 10px #a9a3c0, inset -5px -5px 10px #ffffff;
color: #6d5fae;
}
24% {
box-shadow: 5px 5px 10px #a9a3c0, -5px -5px 10px #ffffff;
color: rgba(60, 54, 84, 0.6);
}
100% {
box-shadow: 5px 5px 10px #a9a3c0, -5px -5px 10px #ffffff;
color: rgba(60, 54, 84, 0.6);
}
}
@media (max-width: 340px) {
.nu__dock {
gap: 8px;
padding: 8px 12px;
}
.nu__btn {
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .nu__btn {
animation-name: none;
}
.nu__btn {
transition: none;
}
}// 실제 동작: 클릭 → aria-pressed 를 옮긴다. 눌린 표현은 CSS 가 [aria-pressed="true"] 로 그린다.
var root = document.getElementById('nu');
var btns = Array.prototype.slice.call(root.querySelectorAll('.nu__btn'));
btns.forEach(function (b) {
b.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () {
btns.forEach(function (x) { x.setAttribute('aria-pressed', String(x === b)); });
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NeuSoftDock.module.scss";
export type NeuSoftDockProps = {
/** 처음 눌려 있는 칸 */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ITEMS = [
{
label: "Home",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2" /><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" /></g></svg>
),
},
{
label: "Search",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" /><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" /></g></svg>
),
},
{
label: "Files",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" /><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" /></g></svg>
),
},
{
label: "Alerts",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g></svg>
),
},
{
label: "Profile",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" /><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" /></g></svg>
),
},
];
export default function NeuSoftDock({
start = 0,
duration = 220,
easing = "ease-out",
color = "#6d5fae",
}: NeuSoftDockProps) {
// 클릭 → aria-pressed 를 옮긴다. 눌린 표현은 CSS 가 [aria-pressed="true"] 로 그린다.
const [on, setOn] = useState(start);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.nu} style={vars}>
<nav className={styles.dock} aria-label="Main menu">
{ITEMS.map(function (it, i) {
return (
<button
className={styles.btn}
type="button"
key={it.label}
aria-label={it.label}
aria-pressed={i === on}
onClick={function () { setOn(i); }}
>
{it.icon}
</button>
);
})}
</nav>
<p className={styles.cap}>The pressed icon has its shadow carved inward</p>
</div>
);
}06 · 뉴모피즘 독 — 측정 누적면적 6.16% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
눌린 상태는 새 그림자를 그리는 것이 아니라 같은 그림자를 inset으로 뒤집은 것이고, 상태는 aria-pressed가 들고 있습니다.
.nu__btn {
border-radius: 50%;
background: $bg;
box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light;
transition: box-shadow $duration $easing, color $duration $easing;
}
.nu__btn[aria-pressed="true"] {
color: $color;
box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light;
}
07반투명 블러 배지
무엇이 보이나
밝은 배경에서는 유리 효과가 자주 실패합니다. 흰 배지에 흰 배경이면 흐릴 것이 없기 때문입니다.

<main class="stage">
<div class="bb is-demo" id="bb">
<div class="bb__scene">
<i class="bb__band bb__band--1" aria-hidden="true"></i>
<i class="bb__band bb__band--2" aria-hidden="true"></i>
<i class="bb__band bb__band--3" aria-hidden="true"></i>
<nav class="bb__dock" aria-label="주요 메뉴">
<button class="bb__btn" type="button" aria-label="홈">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="bb__btn" type="button" aria-label="검색">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="bb__btn" type="button" aria-label="사진">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button class="bb__btn" type="button" aria-label="즐겨찾기">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
</button>
</nav>
</div>
<p class="bb__cap">배지 안에서 지나가는 색이 흐려져 비칩니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-paper);
.bb {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.bb__scene {
position: relative;
width: min(336px, 86vw);
height: 132px;
display: grid;
place-items: center;
border-radius: $r-card;
overflow: hidden;
background: #fff6f9;
}
/* 배경을 가로지르는 색 띠 — 배지가 유리임을 증명하는 재료 */
.bb__band {
position: absolute;
top: -20px;
width: 72px;
height: 176px;
border-radius: $r-pill;
}
.bb__band--1 { left: -80px; background: $color; }
.bb__band--2 { left: -80px; background: $subject-mint; }
.bb__band--3 { left: -80px; background: $subject-sky; }
.bb__dock {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-2;
border: 1px solid rgba(255, 255, 255, .7);
border-radius: $r-pill;
background: rgba(255, 255, 255, .55);
backdrop-filter: blur(8px);
box-shadow: 0 10px 22px rgba(23, 20, 26, .12);
}
.bb__btn {
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: rgba(23, 20, 26, .62);
cursor: pointer;
transition: color $duration $easing, background-color $duration $easing;
}
.bb__btn svg { width: 22px; height: 22px; display: block; }
.bb__btn:hover { color: $subject-ink; background: rgba(255, 255, 255, .7); }
.bb__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.bb__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, .62);
}
/* 자동 재생 — 띠가 오른쪽 끝까지 갔다가 같은 길로 돌아온다(복귀 텔레포트 없음) */
.is-demo .bb__band {
animation-name: bb-drift;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.is-demo .bb__band--2 { animation-delay: -.22s; }
.is-demo .bb__band--3 { animation-delay: -.44s; }
@keyframes bb-drift {
0% { transform: translateX(0); }
50% { transform: translateX(420px); }
100% { transform: translateX(0); }
}
@media (max-width: 340px) {
.bb__scene { height: 120px; }
.bb__btn { width: 40px; height: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .bb__band { animation-name: none; }
.bb__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bb {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.bb__scene {
position: relative;
width: min(336px, 86vw);
height: 132px;
display: grid;
place-items: center;
border-radius: 16px;
overflow: hidden;
background: #fff6f9;
}
/* 배경을 가로지르는 색 띠 — 배지가 유리임을 증명하는 재료 */
.bb__band {
position: absolute;
top: -20px;
width: 72px;
height: 176px;
border-radius: 999px;
}
.bb__band--1 {
left: -80px;
background: #ff9ebb;
}
.bb__band--2 {
left: -80px;
background: #4cd4a6;
}
.bb__band--3 {
left: -80px;
background: #6ec8ff;
}
.bb__dock {
position: relative;
display: flex;
gap: 8px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 999px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(8px);
box-shadow: 0 10px 22px rgba(23, 20, 26, 0.12);
}
.bb__btn {
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: rgba(23, 20, 26, 0.62);
cursor: pointer;
transition: color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bb__btn svg {
width: 22px;
height: 22px;
display: block;
}
.bb__btn:hover {
color: #17141a;
background: rgba(255, 255, 255, 0.7);
}
.bb__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.bb__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.62);
}
/* 자동 재생 — 띠가 오른쪽 끝까지 갔다가 같은 길로 돌아온다(복귀 텔레포트 없음) */
.is-demo .bb__band {
animation-name: bb-drift;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.is-demo .bb__band--2 {
animation-delay: -0.22s;
}
.is-demo .bb__band--3 {
animation-delay: -0.44s;
}
@keyframes bb-drift {
0% {
transform: translateX(0);
}
50% {
transform: translateX(420px);
}
100% {
transform: translateX(0);
}
}
@media (max-width: 340px) {
.bb__scene {
height: 120px;
}
.bb__btn {
width: 40px;
height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .bb__band {
animation-name: none;
}
.bb__btn {
transition: none;
}
}// 실제 동작: 사람이 만지면 자동 루프를 멈추고, 그 뒤로는 :hover 로 배지가 또렷해진다.
var root = document.getElementById('bb');
['pointerdown', 'focusin'].forEach(function (t) {
root.addEventListener(t, function () { root.classList.remove('is-demo'); });
});import type { CSSProperties } from "react";
import styles from "./BlurBadgeDock.module.scss";
export type BlurBadgeDockProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BlurBadgeDock({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff9ebb",
}: BlurBadgeDockProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.bb} style={vars}>
<div className={styles.scene}>
<i className={`${styles.band} ${styles.band1}`} aria-hidden="true" />
<i className={`${styles.band} ${styles.band2}`} aria-hidden="true" />
<i className={`${styles.band} ${styles.band3}`} aria-hidden="true" />
<nav className={styles.dock} aria-label="Main menu">
<button className={styles.btn} type="button" aria-label="Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Photos">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Favorites">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
</button>
</nav>
</div>
<p className={styles.cap}>Colors passing behind the bar blur inside the badge</p>
</div>
);
}07 · 반투명 블러 배지 — 측정 누적면적 30.672% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
그래서 색 띠 세 개를 배지 밑으로 지나가게 했고, 띠는 끝까지 갔다가 같은 길로 되돌아와 되감는 순간의 튐이 없습니다.
.bb__dock {
border: 1px solid rgba(255, 255, 255, .7);
background: rgba(255, 255, 255, .55);
backdrop-filter: blur(8px);
}
.is-demo .bb__band--2 { animation-delay: -.22s; }
@keyframes bb-drift {
0% { transform: translateX(0); }
50% { transform: translateX(420px); }
100% { transform: translateX(0); }
}
08런치 패드 격자 확장
화면에서 일어나는 일
한 줄짜리 독이 일곱 칸 격자로 펼쳐집니다. 판 크기는 접히나 펴지나 같고 아이콘만 자리를 옮기므로, 아래에 붙은 단추가 들썩이지 않습니다.

<main class="stage">
<div class="lp is-demo" id="lp">
<div class="lp__grid" id="lp-grid">
<button class="lp__btn" type="button" aria-label="홈">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="lp__btn" type="button" aria-label="검색">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="lp__btn" type="button" aria-label="폴더">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button class="lp__btn lp__btn--more" type="button" aria-label="채팅">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button class="lp__btn lp__btn--more" type="button" aria-label="사진">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button class="lp__btn lp__btn--more" type="button" aria-label="알림">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button class="lp__btn lp__btn--more" type="button" aria-label="내 정보">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</div>
<button class="lp__toggle" type="button" id="lp-toggle" aria-expanded="false" aria-controls="lp-grid">전체 메뉴</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
// 등록해야 자리·배율이 수로 보간된다 — 등록 안 하면 칸 사이를 못 건넌다
@property --x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --y {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --s {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
.lp {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.lp__grid {
position: relative;
width: 216px;
height: 128px;
border-radius: $r-card;
background: rgba(23, 20, 26, .22);
transition: background-color $duration $easing;
}
/* 펼친 동안 판이 한 단계 짙어진다 — 열렸다는 사실이 판 전체로 보인다 */
.lp.open .lp__grid { background: rgba(23, 20, 26, .46); }
.lp__btn {
--x: 0px;
--y: 0px;
--s: 1;
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 40px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-sm;
background: rgba(255, 255, 255, .16);
color: $color;
cursor: pointer;
translate: calc(-50% + var(--x)) calc(-50% + var(--y));
scale: var(--s);
transition: translate $duration $easing, scale $duration $easing, opacity $dur-instant $easing;
}
.lp__btn svg { width: 22px; height: 22px; display: block; }
.lp__btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
/* 접힌 상태 — 앞 세 개만 한 줄로 */
.lp__btn:nth-child(1) { --x: -52px; }
.lp__btn:nth-child(2) { --x: 0px; }
.lp__btn:nth-child(3) { --x: 52px; }
/* 숨은 넷은 제 빈 칸에서 배율 0 으로 기다린다 — 가운데서 날아오면 앞 세 개와 겹친다 */
.lp__btn:nth-child(4) { --x: -52px; --y: 0px; }
.lp__btn:nth-child(5) { --x: 0px; --y: 0px; }
.lp__btn:nth-child(6) { --x: 52px; --y: 0px; }
.lp__btn:nth-child(7) { --x: 0px; --y: 44px; }
.lp__btn--more { --s: 0; opacity: 0; pointer-events: none; }
/* 펼친 상태 — 앞 세 개가 윗줄로 흩어지고 숨은 넷이 제자리에서 커진다 */
.lp.open .lp__btn--more { --s: 1; opacity: 1; pointer-events: auto; }
.lp.open .lp__btn:nth-child(1) { --y: -44px; }
.lp.open .lp__btn:nth-child(2) { --y: -44px; }
.lp.open .lp__btn:nth-child(3) { --y: -44px; }
.lp__toggle {
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .82);
color: #fff;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
cursor: pointer;
}
.lp__toggle:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
/* 자동 재생 — 한 줄에서 격자로 흩어졌다 84% 에 한 줄로 되돌아온다 */
.is-demo .lp__btn {
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .lp__btn:nth-child(1) { animation-name: lp-c1; }
.is-demo .lp__btn:nth-child(2) { animation-name: lp-c2; }
.is-demo .lp__btn:nth-child(3) { animation-name: lp-c3; }
.is-demo .lp__grid {
animation-name: lp-pad;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.is-demo .lp__btn--more { animation-name: lp-grow; }
@keyframes lp-c1 {
0%, 12% { --y: 0px; }
36%, 72% { --y: -44px; }
84%, 100% { --y: 0px; }
}
@keyframes lp-c2 {
0%, 12% { --y: 0px; }
36%, 72% { --y: -44px; }
84%, 100% { --y: 0px; }
}
@keyframes lp-c3 {
0%, 12% { --y: 0px; }
36%, 72% { --y: -44px; }
84%, 100% { --y: 0px; }
}
/* 앞 세 개가 윗줄로 다 올라간 뒤(36%)에야 커지고, 내려오기 전(72%)에 줄어든다 — 겹칠 틈이 없다 */
@keyframes lp-grow {
0%, 36% { --s: 0; opacity: 0; }
36.01% { opacity: 1; }
44%, 60% { --s: 1; opacity: 1; }
71.99% { opacity: 1; }
72%, 100% { --s: 0; opacity: 0; }
}
@keyframes lp-pad {
0%, 43.99% { background-color: rgba(23, 20, 26, .22); }
44%, 60% { background-color: rgba(23, 20, 26, .46); }
72%, 100% { background-color: rgba(23, 20, 26, .22); }
}
@media (max-width: 340px) {
.lp__grid { width: 200px; height: 124px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .lp__btn,
.is-demo .lp__grid { animation-name: none; }
.lp__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --y {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --s {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
.lp {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lp__grid {
position: relative;
width: 216px;
height: 128px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.22);
transition: background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* 펼친 동안 판이 한 단계 짙어진다 — 열렸다는 사실이 판 전체로 보인다 */
.lp.open .lp__grid {
background: rgba(23, 20, 26, 0.46);
}
.lp__btn {
--x: 0px;
--y: 0px;
--s: 1;
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 40px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
color: #fff7e6;
cursor: pointer;
translate: calc(-50% + var(--x)) calc(-50% + var(--y));
scale: var(--s);
transition: translate 300ms cubic-bezier(0.2, 0.8, 0.2, 1), scale 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lp__btn svg {
width: 22px;
height: 22px;
display: block;
}
.lp__btn:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
/* 접힌 상태 — 앞 세 개만 한 줄로 */
.lp__btn:nth-child(1) {
--x: -52px;
}
.lp__btn:nth-child(2) {
--x: 0px;
}
.lp__btn:nth-child(3) {
--x: 52px;
}
/* 숨은 넷은 제 빈 칸에서 배율 0 으로 기다린다 — 가운데서 날아오면 앞 세 개와 겹친다 */
.lp__btn:nth-child(4) {
--x: -52px;
--y: 0px;
}
.lp__btn:nth-child(5) {
--x: 0px;
--y: 0px;
}
.lp__btn:nth-child(6) {
--x: 52px;
--y: 0px;
}
.lp__btn:nth-child(7) {
--x: 0px;
--y: 44px;
}
.lp__btn--more {
--s: 0;
opacity: 0;
pointer-events: none;
}
/* 펼친 상태 — 앞 세 개가 윗줄로 흩어지고 숨은 넷이 제자리에서 커진다 */
.lp.open .lp__btn--more {
--s: 1;
opacity: 1;
pointer-events: auto;
}
.lp.open .lp__btn:nth-child(1) {
--y: -44px;
}
.lp.open .lp__btn:nth-child(2) {
--y: -44px;
}
.lp.open .lp__btn:nth-child(3) {
--y: -44px;
}
.lp__toggle {
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
cursor: pointer;
}
.lp__toggle:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
/* 자동 재생 — 한 줄에서 격자로 흩어졌다 84% 에 한 줄로 되돌아온다 */
.is-demo .lp__btn {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .lp__btn:nth-child(1) {
animation-name: lp-c1;
}
.is-demo .lp__btn:nth-child(2) {
animation-name: lp-c2;
}
.is-demo .lp__btn:nth-child(3) {
animation-name: lp-c3;
}
.is-demo .lp__grid {
animation-name: lp-pad;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.is-demo .lp__btn--more {
animation-name: lp-grow;
}
@keyframes lp-c1 {
0%, 12% {
--y: 0px;
}
36%, 72% {
--y: -44px;
}
84%, 100% {
--y: 0px;
}
}
@keyframes lp-c2 {
0%, 12% {
--y: 0px;
}
36%, 72% {
--y: -44px;
}
84%, 100% {
--y: 0px;
}
}
@keyframes lp-c3 {
0%, 12% {
--y: 0px;
}
36%, 72% {
--y: -44px;
}
84%, 100% {
--y: 0px;
}
}
/* 앞 세 개가 윗줄로 다 올라간 뒤(36%)에야 커지고, 내려오기 전(72%)에 줄어든다 — 겹칠 틈이 없다 */
@keyframes lp-grow {
0%, 36% {
--s: 0;
opacity: 0;
}
36.01% {
opacity: 1;
}
44%, 60% {
--s: 1;
opacity: 1;
}
71.99% {
opacity: 1;
}
72%, 100% {
--s: 0;
opacity: 0;
}
}
@keyframes lp-pad {
0%, 43.99% {
background-color: rgba(23, 20, 26, 0.22);
}
44%, 60% {
background-color: rgba(23, 20, 26, 0.46);
}
72%, 100% {
background-color: rgba(23, 20, 26, 0.22);
}
}
@media (max-width: 340px) {
.lp__grid {
width: 200px;
height: 124px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .lp__btn,
.is-demo .lp__grid {
animation-name: none;
}
.lp__btn {
transition: none;
}
}// 실제 동작: 버튼 누름 → .open 토글. 격자 열 수와 숨은 넷의 표시를 CSS 가 바꾼다.
var root = document.getElementById('lp');
var btn = document.getElementById('lp-toggle');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = root.classList.toggle('open');
btn.setAttribute('aria-expanded', String(on));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LaunchpadGridDock.module.scss";
export type LaunchpadGridDockProps = {
/** 처음부터 펼친 채로 둘지 */
defaultOpen?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LaunchpadGridDock({
defaultOpen = false,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: LaunchpadGridDockProps) {
const [open, setOpen] = useState(defaultOpen);
// aria-controls 가 가리킬 id — 한 화면에 여러 개를 놔도 겹치지 않게
const [gridId] = useState(() => "lp-grid-" + Math.random().toString(36).slice(2, 8));
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
// 접힌 동안 숨은 넷은 탭 순서에서도 빠진다 (CSS 의 pointer-events: none 과 짝)
const hidden = open ? 0 : -1;
return (
<div className={open ? `${styles.lp} ${styles.isOpen}` : styles.lp} style={vars}>
<div className={styles.grid} id={gridId}>
<button className={styles.btn} type="button" aria-label="Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Files">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button className={`${styles.btn} ${styles.more}`} type="button" aria-label="Chat" tabIndex={hidden}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button className={`${styles.btn} ${styles.more}`} type="button" aria-label="Photos" tabIndex={hidden}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button className={`${styles.btn} ${styles.more}`} type="button" aria-label="Alerts" tabIndex={hidden}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button className={`${styles.btn} ${styles.more}`} type="button" aria-label="Profile" tabIndex={hidden}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</div>
<button
className={styles.toggle}
type="button"
aria-expanded={open}
aria-controls={gridId}
onClick={() => setOpen(!open)}
>
All apps
</button>
</div>
);
}08 · 런치 패드 격자 확장 — 측정 누적면적 19.061% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
자리와 배율은 등록한 속성 세 개가 들고 있습니다.
.lp__btn {
position: absolute;
top: 50%;
left: 50%;
translate: calc(-50% + var(--x)) calc(-50% + var(--y));
scale: var(--s);
transition: translate $duration $easing, scale $duration $easing, opacity $dur-instant $easing;
}
.lp__btn--more { --s: 0; opacity: 0; pointer-events: none; }
.lp.open .lp__btn--more { --s: 1; opacity: 1; pointer-events: auto; }
.lp.open .lp__btn:nth-child(1) { --y: -44px; }
09세로 사이드 독
어떤 움직임인가
독을 왼쪽 가장자리에 세로로 세운 판입니다. 활성 점이 한 칸 36px씩 내려가고, 이름표는 아이콘 오른쪽으로 밀려 나옵니다.

<main class="stage">
<div class="sr is-demo" id="sr" style="--row: 0">
<div class="sr__frame">
<nav class="sr__rail" aria-label="주요 메뉴">
<i class="sr__dot" aria-hidden="true"></i>
<button class="sr__btn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span class="sr__label">홈</span>
</button>
<button class="sr__btn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
<span class="sr__label">문서</span>
</button>
<button class="sr__btn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
<span class="sr__label">대화</span>
</button>
<button class="sr__btn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span class="sr__label">알림</span>
</button>
</nav>
<div class="sr__body" aria-hidden="true">
<span class="sr__line"></span>
<span class="sr__line sr__line--short"></span>
<span class="sr__line"></span>
<span class="sr__line sr__line--short"></span>
</div>
</div>
<p class="sr__cap">이름표가 아이콘 오른쪽으로 밀려 나옵니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
@property --row {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.sr {
--row: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.sr__frame {
position: relative;
display: flex;
width: min(300px, 88vw);
height: 152px;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .06);
}
.sr__rail {
position: relative;
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-2;
background: rgba(255, 255, 255, .06);
}
.sr__dot {
position: absolute;
top: 20px;
left: 2px;
width: 3px;
height: 16px;
border-radius: $r-pill;
background: $color;
// 한 칸 = 버튼 32px + gap 4px = 36px
transform: translateY(calc(var(--row) * 36px));
transition: transform $duration $easing;
}
.sr__btn {
position: relative;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-sm;
background: none;
color: rgba(255, 255, 255, .55);
cursor: pointer;
transition: color $duration $easing;
}
.sr__btn svg { width: 20px; height: 20px; display: block; }
.sr__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sr__btn[aria-current="page"] { color: #fff; }
.sr__label {
position: absolute;
top: 50%;
left: calc(100% + #{$sp-1});
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $color;
color: #10231c;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translateY(-50%) translateX(-6px);
transition: transform $duration $easing, opacity $duration $easing;
}
.sr__btn:hover .sr__label,
.sr__btn:focus-visible .sr__label { opacity: 1; transform: translateY(-50%) translateX(0); }
.sr__body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-3;
padding: $sp-4;
}
.sr__line {
height: 8px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .12);
}
.sr__line--short { width: 60%; }
.sr__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, .62);
}
/* 자동 재생 — 이름표가 위에서부터 차례로 밀려 나오고 점이 따라 내려간다 */
.is-demo .sr__label {
animation-name: sr-slide;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .sr__btn:nth-of-type(2) .sr__label { animation-delay: .4s; }
.is-demo .sr__btn:nth-of-type(3) .sr__label { animation-delay: .8s; }
.is-demo .sr__btn:nth-of-type(4) .sr__label { animation-delay: 1.2s; }
.is-demo .sr__dot {
animation-name: sr-dot;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .sr__btn {
animation-name: sr-lit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .sr__btn:nth-of-type(2) { animation-delay: .4s; }
.is-demo .sr__btn:nth-of-type(3) { animation-delay: .8s; }
.is-demo .sr__btn:nth-of-type(4) { animation-delay: 1.2s; }
@keyframes sr-slide {
0% { opacity: 0; transform: translateY(-50%) translateX(-6px); }
6% { opacity: 1; transform: translateY(-50%) translateX(0); }
16% { opacity: 1; transform: translateY(-50%) translateX(0); }
20% { opacity: 0; transform: translateY(-50%) translateX(-6px); }
100% { opacity: 0; transform: translateY(-50%) translateX(-6px); }
}
@keyframes sr-dot {
0%, 16% { transform: translateY(0); }
20%, 36% { transform: translateY(36px); }
40%, 56% { transform: translateY(72px); }
60%, 76% { transform: translateY(108px); }
88%, 100% { transform: translateY(0); }
}
@keyframes sr-lit {
0% { color: rgba(255, 255, 255, .55); }
6% { color: #fff; }
20% { color: rgba(255, 255, 255, .55); }
100% { color: rgba(255, 255, 255, .55); }
}
@media (max-width: 340px) {
.sr__frame { height: 128px; }
.sr__body { padding: $sp-3; gap: $sp-2; }
.sr__cap { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sr__label,
.is-demo .sr__dot,
.is-demo .sr__btn { animation-name: none; }
.sr__label,
.sr__dot,
.sr__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --row {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.sr {
--row: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.sr__frame {
position: relative;
display: flex;
width: min(300px, 88vw);
height: 152px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
}
.sr__rail {
position: relative;
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background: rgba(255, 255, 255, 0.06);
}
.sr__dot {
position: absolute;
top: 20px;
left: 2px;
width: 3px;
height: 16px;
border-radius: 999px;
background: #4cd4a6;
transform: translateY(calc(var(--row) * 36px));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr__btn {
position: relative;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr__btn svg {
width: 20px;
height: 20px;
display: block;
}
.sr__btn:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.sr__btn[aria-current=page] {
color: #fff;
}
.sr__label {
position: absolute;
top: 50%;
left: calc(100% + 4px);
padding: 4px 8px;
border-radius: 4px;
background: #4cd4a6;
color: #10231c;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translateY(-50%) translateX(-6px);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr__btn:hover .sr__label,
.sr__btn:focus-visible .sr__label {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.sr__body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
padding: 16px;
}
.sr__line {
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.sr__line--short {
width: 60%;
}
.sr__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.62);
}
/* 자동 재생 — 이름표가 위에서부터 차례로 밀려 나오고 점이 따라 내려간다 */
.is-demo .sr__label {
animation-name: sr-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .sr__btn:nth-of-type(2) .sr__label {
animation-delay: 0.4s;
}
.is-demo .sr__btn:nth-of-type(3) .sr__label {
animation-delay: 0.8s;
}
.is-demo .sr__btn:nth-of-type(4) .sr__label {
animation-delay: 1.2s;
}
.is-demo .sr__dot {
animation-name: sr-dot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .sr__btn {
animation-name: sr-lit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .sr__btn:nth-of-type(2) {
animation-delay: 0.4s;
}
.is-demo .sr__btn:nth-of-type(3) {
animation-delay: 0.8s;
}
.is-demo .sr__btn:nth-of-type(4) {
animation-delay: 1.2s;
}
@keyframes sr-slide {
0% {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
}
6% {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
16% {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
20% {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
}
100% {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
}
}
@keyframes sr-dot {
0%, 16% {
transform: translateY(0);
}
20%, 36% {
transform: translateY(36px);
}
40%, 56% {
transform: translateY(72px);
}
60%, 76% {
transform: translateY(108px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes sr-lit {
0% {
color: rgba(255, 255, 255, 0.55);
}
6% {
color: #fff;
}
20% {
color: rgba(255, 255, 255, 0.55);
}
100% {
color: rgba(255, 255, 255, 0.55);
}
}
@media (max-width: 340px) {
.sr__frame {
height: 128px;
}
.sr__body {
padding: 12px;
gap: 8px;
}
.sr__cap {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sr__label,
.is-demo .sr__dot,
.is-demo .sr__btn {
animation-name: none;
}
.sr__label,
.sr__dot,
.sr__btn {
transition: none;
}
}// 실제 동작: 클릭 → --row 교체(활성 점이 같은 간격씩 내려간다). 호버·포커스는 CSS 가 이름표를 민다.
var root = document.getElementById('sr');
var btns = Array.prototype.slice.call(root.querySelectorAll('.sr__btn'));
btns[0].setAttribute('aria-current', 'page');
btns.forEach(function (b, i) {
b.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () {
root.classList.remove('is-demo');
root.style.setProperty('--row', i);
btns.forEach(function (x) { x.removeAttribute('aria-current'); });
b.setAttribute('aria-current', 'page');
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SideRailDock.module.scss";
export type SideRailDockProps = {
/** 처음 켜져 있는 줄 (0부터) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--row"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SideRailDock({
start = 0,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: SideRailDockProps) {
// 활성 줄 번호. 점은 이 수 × 36px 만큼 내려간다 (버튼 32px + gap 4px)
const [row, setRow] = useState(start);
const vars: Vars = {
"--row": `${row}`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.sr} style={vars}>
<div className={styles.frame}>
<nav className={styles.rail} aria-label="Main menu">
<i className={styles.dot} aria-hidden="true" />
<button
className={styles.btn}
type="button"
aria-current={row === 0 ? "page" : undefined}
onClick={() => setRow(0)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span className={styles.label}>Home</span>
</button>
<button
className={styles.btn}
type="button"
aria-current={row === 1 ? "page" : undefined}
onClick={() => setRow(1)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
<span className={styles.label}>Files</span>
</button>
<button
className={styles.btn}
type="button"
aria-current={row === 2 ? "page" : undefined}
onClick={() => setRow(2)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
<span className={styles.label}>Chat</span>
</button>
<button
className={styles.btn}
type="button"
aria-current={row === 3 ? "page" : undefined}
onClick={() => setRow(3)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span className={styles.label}>Alerts</span>
</button>
</nav>
<div className={styles.body} aria-hidden="true">
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineShort}`} />
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineShort}`} />
</div>
</div>
<p className={styles.cap}>The name tag slides out to the right of the icon</p>
</div>
);
}09 · 세로 사이드 독 — 측정 누적면적 3.178% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
세로 가운데 정렬과 밀림이 둘 다 transform이라 한 줄에 순서대로 적어야 뒤엣것이 앞엣것을 지우지 않습니다.
.sr__dot {
position: absolute;
width: 3px;
height: 16px;
background: $color;
transform: translateY(calc(var(--row) * 36px));
transition: transform $duration $easing;
}
.sr__label {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
transition: transform $duration $easing, opacity $duration $easing;
}
.sr__btn:hover .sr__label { opacity: 1; transform: translateY(-50%) translateX(0); }
어디서 깨지는가 — 함정
아홉 개를 만들며 가장 많이 다시 손댄 곳은 애니메이션 자체가 아니라 두 요소가 같은 자리를 지나가는 순간이었습니다. 08번은 처음에 숨은 아이콘 넷을 독 가운데에서 각자의 칸으로 날려 보냈습니다. 코드로 보면 멀쩡한데 대표 이미지를 뽑아 보니 돋보기 아이콘 위에 사람 아이콘이 반쯤 겹친 장면이 나왔습니다. 앞 세 개가 윗줄로 올라가는 길과 숨은 넷이 날아오는 길이 겹쳤던 것이고, 두 무리가 같은 지점을 각각 다른 시각에 지나는 게 아니라 동시에 지나고 있었습니다. 숨은 넷을 처음부터 제 빈 칸에 놓고 배율 0으로 기다리게 한 뒤, 앞 세 개가 다 올라간 다음에야 커지도록 순서를 나누자 겹침이 사라졌습니다.
03번은 다른 얼굴의 같은 병이었습니다. 이름표가 서서히 흐려지며 사라지게 했더니 손이 옆 아이콘으로 옮겨 가는 동안 이름표 두 개가 반투명하게 포개져 둘 다 읽을 수 없었습니다. 사라짐을 steps(1, end)로 한 프레임에 끊자 그 구간이 통째로 없어졌습니다. 06번은 또 다른 이유로 두 번 고쳤습니다. 배경·단추·그림자가 전부 같은 색 계열이라 눌린 표시가 눈에 안 들어왔던 것인데, 어두운 쪽 그림자를 한 단계 진하게 하고 눌린 아이콘 색까지 바꾸자 변한 픽셀의 평균 차이가 19.5에서 22.8로 올라갔습니다. 세 가지 모두 어느 검사에도 걸리지 않고 사람 눈에만 "가끔 이상한 장면"으로 보이는 종류였습니다.
| 증상 | 진짜 원인 | 고치는 자리 |
|---|---|---|
| 아이콘 두 개가 포개진 장면이 찍힌다 | 두 무리의 이동 경로가 같은 시각에 겹친다 | 한쪽을 제자리에서 키우고 순서를 나눈다 |
| 이름표 두 개가 반투명하게 겹친다 | 사라짐이 페이드다 | opacity를 steps(1, end)로 끊는다 |
| 활성 표시가 아이콘을 비껴 선다 | 단추가 균등 분배라 실제 폭이 고정값과 다르다 | 단추에 flex: 0 0 auto와 고정 폭 |
| 표시가 칸 사이를 툭툭 건너뛴다 | 숫자 속성이 등록되지 않아 중간값이 없다 | @property로 syntax를 선언한다 |
화면 어디를 보고 있는지 알려 주는 막대는 스크롤 인디케이터가 같은 질문을 위에서 다시 답합니다. 표의 네 줄을 다 반영한 아홉 벌 소스는 압축 비밀번호 wd6vwhyu를 넣으면 열리는 zip에 들어 있고, 바닐라와 React 판이 같은 변수 세 개를 씁니다.
접근성
선택 상태가 있는 여섯 개는 그것을 클래스가 아니라 속성에 담았고, 나머지 03·04·07번은 선택 상태 없이 손이 올라간 동안만 반응합니다. 01·02·05·09번은 aria-current="page", 06번은 aria-pressed, 08번의 토글 단추는 aria-expanded와 aria-controls를 씁니다. 이렇게 해 두면 화면 낭독기가 "지금 선택됨"을 읽어 주고, 02·05·06·09번은 CSS까지 그 속성 선택자로 그리므로 보이는 것과 전해지는 것이 어긋날 수 없습니다(01번만 속성과 .on 클래스를 함께 옮깁니다). 03번 이름표는 aria-hidden="true"이고 대신 화면에서 감춘 텍스트를 단추 안에 따로 넣었습니다. 같은 말이 두 번 읽히지 않게 하기 위해서입니다. 아이콘만 있는 단추에는 aria-label을 붙였고 03·09번은 단추 안의 글자가 그 이름을 대신하며, 모든 단추가 :focus-visible에서 윤곽선을 남기므로 키보드로 탭할 때 지금 어디 있는지 보입니다. 아이콘은 전부 Phosphor Icons의 듀오톤 SVG를 인라인으로 넣은 것이라 글꼴도 CDN도 부르지 않습니다. prefers-reduced-motion: reduce에서는 자동 재생 루프와 미끄러짐·튀어오름만 끄고 활성 표시 자체는 그대로 남습니다. 어느 칸인지는 연출이 아니라 정보이기 때문입니다. 화면 낭독기가 현재 위치를 어떻게 읽는지는 MDN의 aria-current 문서에 정리돼 있습니다.
같은 계열을 더 찾는다면 호버 카테고리와 랜딩 페이지 카테고리에 함께 쓰기 좋은 부품이 모여 있습니다.
FAQ
활성 표시를 고정 px로 옮기면 화면 크기가 바뀔 때 어긋나지 않나요?
어긋납니다. 단, 어긋나는 건 화면 크기가 아니라 단추 폭이 유동일 때입니다. 02번처럼 translateX(calc(var(--idx) * 56px))로 옮기려면 단추가 flex: 0 0 auto에 고정 폭이어야 하고, 그러면 화면이 좁아져도 한 칸 폭은 56px 그대로입니다. 반대로 flex: 1 1 0으로 남은 폭을 나눠 갖게 하면 실제 렌더 폭이 매번 달라져서 알약이 옆 칸에 걸칩니다. 좁은 화면에서 줄이려면 미디어 쿼리로 단추 폭과 간격, 그리고 한 칸 값을 함께 바꿔야 합니다. 독 위의 얼굴을 크게 보여 주고 싶다면 프로필 카드 디자인의 카드가 그 칸을 채웁니다.
맥북 독 효과처럼 아이콘이 커질 때 이웃을 밀어내는 계산은 왜 필요한가요?
배율만 주면 아이콘이 서로 겹치기 때문입니다. scale은 요소가 차지하는 자리를 바꾸지 않고 그리는 크기만 바꾸므로, 1.8배로 부푼 아이콘은 제자리를 넘어 양옆 아이콘 위로 번집니다. 04번이 각 아이콘이 늘어난 폭을 앞에서부터 누적해 이동값으로 나눠 주는 이유가 이것이고, 마지막에 전체 증가분의 절반을 빼는 것은 줄 전체가 오른쪽으로 밀려나지 않게 하기 위해서입니다.
흐린 유리 독이 배경에 따라 밋밋해 보입니다
backdrop-filter는 뒤에 있는 그림을 흐리는 필터라 뒤가 단색이면 아무 일도 일어나지 않습니다. 05번이 색 얼룩 두 개를 독 뒤에 깐 것, 07번이 색 띠를 배지 밑으로 지나가게 한 것이 모두 그래서입니다. 배경이 흰 화면이라면 유리 대신 아주 옅은 그림자와 1px 테두리로 떠 있는 느낌을 만드는 편이 낫습니다.









