Mac Dock CSS — 9 Floating Dock Styles, No Library
Mac dock CSS is the plain-CSS way to rebuild that floating strip of icons at a screen edge: a short bar, a few icon buttons, and one mark for where you are.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Minimal line dock
- 02 Pill slide indicator
- 03 Label tooltip
- 04 Icon magnify wave
- 05 Glassmorphism dock
- 06 Neumorphism dock
- 07 Translucent blur badge
- 08 Launchpad grid expand
- 09 Vertical side rail
The nine are ordered by how much room the mark takes, not by popularity. In the first three, what is added over the icon is a line, a surface, and a word: an underline growing out of its own center (01), a pill filling a whole slot (02), and a name tag rising above the icon (03). From the fourth demo on, the size of the icon itself becomes the mark, and from there the room the neighbors give up has to be worked out too. Demos 05 and 06 sit together because they change the material of the bar rather than the shape of the mark — frosted glass (05) leans on backdrop-filter while the soft shadow (06) rests on a single pair of box-shadow values, so the two aim at the same spot and leave opposite impressions. The seventh asks whether glass still looks like glass over a bright page, the eighth opens the dock out into a panel, and the last stands the whole thing on end. All nine work as floating navigation bar css with no library, and the ones that carry a selected state keep it in an attribute such as aria-current or aria-pressed rather than in a class. The screen-cutting side of the dock sits in CSS Screen Transition Effects, and the menu outgrowing one row into a site map is mega menu ui patterns.
01Minimal line dock
What this macos dock css shows
Five icons sit inside a thin outlined bar, and the underline beneath the selected one grows outward from its center while the previous underline folds back at the same pace.

<main class="stage">
<div class="mn is-demo" id="mn">
<nav class="mn__dock" aria-label="Main menu">
<button class="mn__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>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__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>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="Likes">
<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="Alerts">
<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="Profile">
<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">The underline grows outward from the center</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 · Minimal line dock — Measured area 0.905% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The underline never changes width — only scaleX travels from 0 to 1 — so the row never has to be laid out again.
.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); }
02Pill slide indicator
What happens on screen
The pill filling the active slot slides sideways one stride at a time while the icons hold their places. A stride is a fixed 56px: a 48px button plus an 8px gap.

<main class="stage">
<div class="pl is-demo" id="pl" style="--idx: 0">
<nav class="pl__dock" aria-label="Main menu">
<i class="pl__pill" aria-hidden="true"></i>
<button class="pl__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 class="pl__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 class="pl__btn" type="button" aria-label="Chat">
<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="Settings">
<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="Profile">
<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">The active pill slides by a fixed pixel stride</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 · Pill slide indicator — Measured area 7.554% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
That fixed number is why every button gets flex: 0 0 auto and a width of its own instead of an equal share of the row.
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl__pill {
position: absolute;
top: $sp-2;
left: $sp-2;
width: 48px;
height: 48px;
border-radius: $r-pill;
background: $color;
transform: translateX(calc(var(--idx) * 56px));
transition: transform $duration $easing;
}
.pl__btn {
position: relative;
flex: 0 0 auto;
width: 48px;
height: 48px;
}
03Label tooltip
What moves
Point at an icon and its name tag springs up above it.

<main class="stage">
<div class="tt is-demo" id="tt">
<nav class="tt__dock" aria-label="Main menu">
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">Home</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">Home</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">Search</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">Search</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">Photos</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">Photos</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">Favorites</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">Favorites</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">Settings</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">Settings</span>
</button>
</nav>
<p class="tt__cap">Hover an icon and its name tag springs up</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 · Label tooltip — Measured area 10.12% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The hinge is the bottom edge of the tag — that is what transform-origin: bottom center sets — and on the way out the tag does not fade at all: steps(1, end) cuts it in a single frame.
.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;
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);
}
04Icon magnify wave
What you see
The icon nearest the pointer swells the most and the neighbors step down with distance, on a curve of 1 + 0.8 * t * t, where t reaches zero once the pointer is 90px from that icon's center.

<main class="stage">
<div class="mg is-demo" id="mg">
<nav class="mg__dock" id="mg-dock" aria-label="Main menu">
<button class="mg__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 class="mg__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 class="mg__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 class="mg__btn" type="button" aria-label="Chat">
<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="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 class="mg__btn" type="button" aria-label="Alerts">
<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="Profile">
<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">Closer to the pointer means bigger; neighbors get pushed aside</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 · Icon magnify wave — Measured area 9.015% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The hard part is not the scale but the shove: each icon's extra width is added up from the left and handed back as a shift, and half of the total growth is subtracted at the end so the row keeps its center.
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];
});
}
05Glassmorphism dock
What happens on screen
Two round color blobs, one lilac and one sky blue, are laid behind the frosted bar. Glass reads as glass only when something sits behind it, so the blobs are material rather than decoration.

<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="Main menu">
<i class="gl__pill" aria-hidden="true"></i>
<button class="gl__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 class="gl__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 class="gl__btn" type="button" aria-label="Likes">
<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="Alerts">
<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="Profile">
<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">Background colors smear through the frosted glass</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 · Glassmorphism dock — Measured area 6.322% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The active pill glides along the bar, and the chosen icon lifts by 3px.
.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__btn[aria-current="page"] { color: #fff; transform: translateY(-3px); }
06Neumorphism dock
What moves
The background and the buttons share one color, and a single pair of shadows does all of the lifting.

<main class="stage">
<div class="nu is-demo" id="nu">
<nav class="nu__dock" aria-label="Main menu">
<button class="nu__btn" type="button" aria-label="Home" 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="Search" 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="Files" 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="Alerts" 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="Profile" 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">The pressed icon has its shadow carved inward</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 · Neumorphism dock — Measured area 6.16% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The pressed state does not draw a second shadow; it flips that same pair inward with inset, and aria-pressed is what holds the state.
.nu__btn {
flex: 0 0 auto;
width: 48px;
height: 48px;
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[aria-pressed="true"] {
color: $color;
box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light;
}
07Translucent blur badge
What you see
Glass fails most often on bright pages because a white badge over a white page has nothing left to blur.

<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="Main menu">
<button class="bb__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 class="bb__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 class="bb__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 class="bb__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 class="bb__cap">Passing colors show through the badge, blurred</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 · Translucent blur badge — Measured area 30.672% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
So three color bands travel beneath the badge, and each one runs to the far side and comes back along the same path, sparing the viewer the snap a one-way loop makes when it rewinds.
.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);
}
.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); }
}
08Launchpad grid expand
What happens on screen
A one-row dock opens out into a seven-slot grid. The panel is the same size folded or open and only the icons travel, so whatever sits below it never jumps.

<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="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 class="lp__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 class="lp__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 class="lp__btn lp__btn--more" type="button" aria-label="Chat">
<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="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 class="lp__btn lp__btn--more" type="button" aria-label="Alerts">
<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="Profile">
<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">All apps</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 · Launchpad grid expand — Measured area 19.061% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Position and scale live in three registered properties, which is what gives the browser numbers to interpolate between the slots.
.lp__btn {
--x: 0px;
--y: 0px;
--s: 1;
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:nth-child(4) { --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; }
@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; }
}
09Vertical side rail
What moves
This one stands the dock up against the left edge of the screen. The active dot steps down 36px per row, which is a 32px button plus a 4px gap, and the name tags slide out to the right of the icons in turn.

<main class="stage">
<div class="sr is-demo" id="sr" style="--row: 0">
<div class="sr__frame">
<nav class="sr__rail" aria-label="Main menu">
<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">Home</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">Docs</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">Threads</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">Alerts</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">Name tags slide out to the right of the icons</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 · Vertical side rail — Measured area 3.178% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The vertical centering and that slide are both transform, so they have to sit in one declaration in order, or the second wipes out the first.
.sr__dot {
position: absolute;
top: 20px;
left: 2px;
width: 3px;
height: 16px;
border-radius: $r-pill;
background: $color;
transform: translateY(calc(var(--row) * 36px));
transition: transform $duration $easing;
}
.sr__label {
position: absolute;
top: 50%;
left: calc(100% + #{$sp-1});
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); }
Where it breaks — the trap
Across all nine, the part rebuilt most often was never the animation itself but the instant two elements cross the same spot. Demo 08 originally launched its four hidden icons out of the middle of the dock toward their own slots. On paper that looked fine; the poster frame disagreed, catching a person icon sitting half on top of a magnifier. The route the first three take up to the top row and the route the hidden four fly in on are one and the same, and the two groups were crossing it at the same moment rather than one after the other. Parking the hidden four in their own empty slots at scale 0 from the start, then letting them grow only once the first three had arrived, ended the overlap.
Demo 03 was the same fault in a different disguise. Fading the name tags out meant that while the pointer traveled to the next icon, two half-transparent tags stacked up and neither one could be read. Cutting the exit to a single frame with steps(1, end) removed that window outright. Demo 06 was reworked twice for an unrelated reason: its background, buttons, and shadows all belong to one color family, so the pressed dent barely registered. Deepening the dark side of the shadow and darkening the pressed color raised the average difference across the changed pixels from 19.5 to 22.8. Not one of the three trips an automated check; each shows up only to a human eye, as an odd frame now and then.
| Symptom | Actual cause | Where to fix it |
|---|---|---|
| A frame catches two icons overlapping | Two groups cross one path at the same moment | Grow one group in place and split the timing |
| Two name tags stack up half-transparent | The exit is a fade | Cut opacity with steps(1, end) |
| The active mark stands beside the icon instead of under it | The buttons divide the row, so the rendered width differs from the fixed stride | flex: 0 0 auto plus a fixed width |
| The mark hops between slots with nothing in between | The numeric property was never registered | Declare its syntax through @property |
The bar telling you where you are on the page answers the same question from the top in Reading Progress Bar CSS. Every row above is already patched in the nine sources packed into the zip, which the archive password wd6vwhyu opens, and the vanilla build and the React build read the same three variables.
Accessibility (reduced-motion)
Six of the nine carry a selected state, and all six keep it in an attribute instead of a class; the other three (03, 04, and 07) have no selection at all and answer only while a pointer rests on them. Demos 01, 02, 05, and 09 use aria-current="page", demo 06 uses aria-pressed, and the toggle in demo 08 carries aria-expanded alongside aria-controls. A screen reader then announces the current choice, and in demos 02, 05, 06, and 09 the CSS paints from that same attribute selector, so what is shown and what is announced cannot drift apart — only demo 01 moves an .on class alongside the attribute. The tag in demo 03 is aria-hidden="true", with a visually hidden copy of the text placed inside the button instead, so the same word is never read twice. Icon-only buttons carry an aria-label, though in demos 03 and 09 the text inside the button serves as that name instead. Every button keeps its :focus-visible outline so that keyboard users can see where they are, and the icons are inline Phosphor duotone SVGs, which means no icon font and no CDN request at all. Under prefers-reduced-motion: reduce, the autoplay loops, the glides, and the springs stop, while the active mark itself stays, because which slot you are on is information rather than decoration. How a screen reader reports the current item is documented in the MDN aria-current reference.
Parts that pair well with these are gathered in the hover category and the landing page category.
FAQ
Why does a fixed pixel stride survive a narrow screen?
It survives as long as the buttons are not fluid, because the stride depends on button width rather than on screen width. Moving a pill with translateX(calc(var(--idx) * 56px)) the way demo 02 does requires buttons set to flex: 0 0 auto at a fixed width, and then one slot stays 56px however narrow the page gets. Hand them flex: 1 1 0 instead and the rendered width changes with every layout, so the pill ends up straddling the seam; to shrink the dock, move the button width, the gap, and the stride together inside a media query. Putting a bigger face on the dock is what a card from Profile Card Design Styles fills in.
Why does macos dock animation css have to push the neighbors when an icon grows?
Because scale changes only what is drawn, not the space the element takes up, so an icon scaled to 1.8 spills over the icons on either side of it. That is why demo 04 adds up each icon's extra width from the left and hands it back as a shift. Subtracting half of the total growth at the very end is what keeps the whole row from drifting to the right.
Why does my frosted dock look flat on some backgrounds?
backdrop-filter blurs whatever lies behind it, so a flat background leaves it nothing to work with. That is why demo 05 lays two color blobs behind the bar and demo 07 sends color bands under the badge. On a plain white page, a very light shadow with a 1px border floats better than glass ever will.









