フローティングメニュー デザイン9選 — ホバーで隣まで膨らむ
フローティングメニュー デザインとは、画面の端に浮かせた小さなメニュー帯の作り方のことで、数個のアイコンボタンと、今どの枠にいるかを示す印1つで成り立ちます。9つのうち1つは、ポインターが近づくと隣のアイコンを押し退けながら膨らみます。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ミニマルラインドック
- 02 ピルスライドインジケーター
- 03 ラベルツールチップ
- 04 アイコン拡大ウェーブ
- 05 グラスモーフィズムドック
- 06 ニューモーフィズムドック
- 07 半透明ブラーバッジ
- 08 ランチパッドグリッド展開
- 09 縦サイドドック
並べた順番は人気ではなく、アクティブの印が占める面積が少しずつ大きくなっていく順です。はじめの3つは、アイコンの上に足されるものが線・面・文字です。1本の下線(01)、枠を丸ごと埋めるカプセル(02)、アイコンの上へせり出す名前ラベル(03)という流れです。4つ目(04)からは印がアイコンそのものの大きさになり、ここから先は隣のアイコンが譲る場所まで計算しなければなりません。5つ目と6つ目は印の形ではなく帯そのものの素材を変えたものなので隣どうしに置きました — 曇りガラス(05)は backdrop-filter に、柔らかい影(06)は box-shadow 1対に頼っていて、狙う場所は同じでも受ける印象は正反対です。7つ目(07)は明るい背景でガラスが本当にガラスに見えるかを確かめる板、8つ目(08)はドックが板へ開く場合、最後(09)は横ではなく縦に立てた場合です。フローティングメニュー 参考として並べた9つとも、ライブラリなしで動きます。選ばれている状態を持つのは6つ(01・02・05・06・08・09)で、その状態は aria-current や aria-pressed といった属性に入れてあります。残る3つ(03・04・07)はホバーに応えるだけで、選択という概念そのものがありません。画面を分ける切替の側はCSS 画面切り替えにあり、メニューが一行を超えてサイトの地図になる側はメガメニュー cssが担います。
01ミニマルラインドック
「フローティングメニューとは」何が見えるか
細い枠線のバーに5つのアイコンが収まり、押した項目の下で下線が中央から両側へ伸びます。下線は幅を変えずに scaleX を0から1へ動かすだけなので、行全体のレイアウトが計算し直されることはありません。

<main class="stage">
<div class="mn is-demo" id="mn">
<nav class="mn__dock" aria-label="メインメニュー">
<button class="mn__btn" type="button" aria-label="ホーム">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="検索">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="お気に入り登録">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="通知">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
<button class="mn__btn" type="button" aria-label="マイページ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
<i class="mn__line" aria-hidden="true"></i>
</button>
</nav>
<p class="mn__cap">押した項目の下線が中央から伸びます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.mn {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.mn__dock {
display: flex;
gap: $sp-2;
padding: $sp-2 $sp-3;
border: 1.5px solid rgba(23, 20, 26, .3);
border-radius: $r-pill;
background: rgba(255, 255, 255, .94);
box-shadow: 0 8px 20px rgba(23, 20, 26, .08);
}
.mn__btn {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-sm;
background: none;
color: rgba(23, 20, 26, .48);
cursor: pointer;
transition: color $duration $easing, transform $duration $easing;
}
.mn__btn:hover { color: rgba(23, 20, 26, .85); }
.mn__btn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.mn__btn svg { width: 24px; height: 24px; display: block; }
.mn__btn.on { color: $color; transform: translateY(-2px); }
.mn__line {
position: absolute;
left: 11px;
right: 11px;
bottom: 2px;
height: 2px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transition: transform $duration $easing;
}
.mn__btn.on .mn__line { transform: scaleX(1); }
.mn__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, .62);
}
/* 자동 재생 — 항목마다 400ms 슬롯. delay 는 개별 속성으로(단축 리셋 방지). */
.is-demo .mn__line {
animation-name: mn-ln;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .mn__btn:nth-child(2) .mn__line { animation-delay: .4s; }
.is-demo .mn__btn:nth-child(3) .mn__line { animation-delay: .8s; }
.is-demo .mn__btn:nth-child(4) .mn__line { animation-delay: 1.2s; }
.is-demo .mn__btn:nth-child(5) .mn__line { animation-delay: 1.6s; }
.is-demo .mn__btn svg {
animation-name: mn-ic;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .mn__btn:nth-child(2) svg { animation-delay: .4s; }
.is-demo .mn__btn:nth-child(3) svg { animation-delay: .8s; }
.is-demo .mn__btn:nth-child(4) svg { animation-delay: 1.2s; }
.is-demo .mn__btn:nth-child(5) svg { animation-delay: 1.6s; }
@keyframes mn-ln {
0% { transform: scaleX(0); }
4% { transform: scaleX(1); }
15% { transform: scaleX(1); }
20% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@keyframes mn-ic {
0% { color: rgba(23, 20, 26, .48); }
20% { color: $subject-ink; }
100% { color: rgba(23, 20, 26, .48); }
}
@media (max-width: 340px) {
.mn__dock { gap: $sp-1; padding: $sp-2; }
.mn__btn { width: 40px; height: 40px; }
.mn__btn svg { width: 22px; height: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .mn__line,
.is-demo .mn__btn svg { animation-name: none; }
.mn__btn,
.mn__line { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mn {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.mn__dock {
display: flex;
gap: 8px;
padding: 8px 12px;
border: 1.5px solid rgba(23, 20, 26, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 8px 20px rgba(23, 20, 26, 0.08);
}
.mn__btn {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: rgba(23, 20, 26, 0.48);
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mn__btn:hover {
color: rgba(23, 20, 26, 0.85);
}
.mn__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.mn__btn svg {
width: 24px;
height: 24px;
display: block;
}
.mn__btn.on {
color: #17141a;
transform: translateY(-2px);
}
.mn__line {
position: absolute;
left: 11px;
right: 11px;
bottom: 2px;
height: 2px;
border-radius: 999px;
background: #17141a;
transform: scaleX(0);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mn__btn.on .mn__line {
transform: scaleX(1);
}
.mn__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.62);
}
/* 자동 재생 — 항목마다 400ms 슬롯. delay 는 개별 속성으로(단축 리셋 방지). */
.is-demo .mn__line {
animation-name: mn-ln;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .mn__btn:nth-child(2) .mn__line {
animation-delay: 0.4s;
}
.is-demo .mn__btn:nth-child(3) .mn__line {
animation-delay: 0.8s;
}
.is-demo .mn__btn:nth-child(4) .mn__line {
animation-delay: 1.2s;
}
.is-demo .mn__btn:nth-child(5) .mn__line {
animation-delay: 1.6s;
}
.is-demo .mn__btn svg {
animation-name: mn-ic;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .mn__btn:nth-child(2) svg {
animation-delay: 0.4s;
}
.is-demo .mn__btn:nth-child(3) svg {
animation-delay: 0.8s;
}
.is-demo .mn__btn:nth-child(4) svg {
animation-delay: 1.2s;
}
.is-demo .mn__btn:nth-child(5) svg {
animation-delay: 1.6s;
}
@keyframes mn-ln {
0% {
transform: scaleX(0);
}
4% {
transform: scaleX(1);
}
15% {
transform: scaleX(1);
}
20% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@keyframes mn-ic {
0% {
color: rgba(23, 20, 26, 0.48);
}
20% {
color: #17141a;
}
100% {
color: rgba(23, 20, 26, 0.48);
}
}
@media (max-width: 340px) {
.mn__dock {
gap: 4px;
padding: 8px;
}
.mn__btn {
width: 40px;
height: 40px;
}
.mn__btn svg {
width: 22px;
height: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .mn__line,
.is-demo .mn__btn svg {
animation-name: none;
}
.mn__btn,
.mn__line {
transition: none;
}
}// 실제 동작: 클릭 → 활성 항목 교체(aria-current). 밑줄은 transform scaleX 전환.
var root = document.getElementById('mn');
var btns = Array.prototype.slice.call(root.querySelectorAll('.mn__btn'));
function pick(i) {
btns.forEach(function (b, j) {
b.classList.toggle('on', i === j);
if (i === j) b.setAttribute('aria-current', 'page');
else b.removeAttribute('aria-current');
});
}
pick(0);
btns.forEach(function (b, i) {
b.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () { pick(i); });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MinimalLineDock.module.scss";
export type MinimalLineDockProps = {
/** 처음 켜 둘 항목 (0부터) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ICONS = [
{
label: "Home",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
),
},
{
label: "Search",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
),
},
{
label: "Likes",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
),
},
{
label: "Alerts",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
),
},
{
label: "Profile",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
),
},
];
export default function MinimalLineDock({
start = 0,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a",
}: MinimalLineDockProps) {
// 실제 동작: 클릭 → 활성 항목 교체(aria-current). 밑줄은 transform scaleX 전환.
const [on, setOn] = useState(start);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.mn} style={vars}>
<nav className={styles.dock} aria-label="Main menu">
{ICONS.map((it, i) => (
<button
key={it.label}
className={i === on ? `${styles.btn} ${styles.on}` : styles.btn}
type="button"
aria-label={it.label}
aria-current={i === on ? "page" : undefined}
onClick={() => setOn(i)}
>
{it.svg}
<i className={styles.line} aria-hidden="true" />
</button>
))}
</nav>
<p className={styles.cap}>An underline grows from the center of the item you pick</p>
</div>
);
}01 · ミニマルラインドック — 計測 累積面積 0.905% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
飾りを最小にしたいポートフォリオやブログの下部固定メニューに向きます。
.mn__line {
position: absolute;
left: 11px;
right: 11px;
bottom: 2px;
height: 2px;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transition: transform $duration $easing;
}
.mn__btn.on .mn__line { transform: scaleX(1); }
02ピルスライドインジケーター
画面で起きること
アクティブ枠を埋めるカプセル形の背景が、1枠の幅ずつ横へ滑っていきます。1枠はアイコンボタン48pxに間隔8pxを足した56pxの固定値で、アイコン自体は位置を動かしません。

<main class="stage">
<div class="pl is-demo" id="pl" style="--idx: 0">
<nav class="pl__dock" aria-label="メインメニュー">
<i class="pl__pill" aria-hidden="true"></i>
<button class="pl__btn" type="button" aria-label="ホーム">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="pl__btn" type="button" aria-label="フォルダ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button class="pl__btn" type="button" aria-label="チャット">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button class="pl__btn" type="button" aria-label="設定">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99.4 99.4 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
</button>
<button class="pl__btn" type="button" aria-label="マイページ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</nav>
<p class="pl__cap">アクティブ枠のカプセルが固定pxずつ移動します</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 등록해야 --idx 가 숫자로 보간된다 — 등록 안 하면 문자열이라 칸 사이를 못 건넌다
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl {
--idx: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.pl__dock {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .08);
}
.pl__pill {
position: absolute;
top: $sp-2;
left: $sp-2;
width: 48px;
height: 48px;
border-radius: $r-pill;
background: $color;
// 한 칸 = 항목 48px + gap 8px = 56px. 고정 px 이므로 항목은 flex: 0 0 auto 여야 한다
transform: translateX(calc(var(--idx) * 56px));
transition: transform $duration $easing;
}
.pl__btn {
position: relative;
flex: 0 0 auto;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: rgba(255, 255, 255, .55);
cursor: pointer;
transition: color $duration $easing;
}
.pl__btn svg { width: 24px; height: 24px; display: block; }
.pl__btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.pl__btn[aria-current="page"] { color: #fff; }
.pl__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, .62);
}
/* 자동 재생 — 알약이 다섯 칸을 돌고 90% 안에 첫 칸으로 복귀 */
.is-demo .pl__pill {
animation-name: pl-slide;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .pl__btn {
animation-name: pl-dim;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .pl__btn:nth-of-type(2) { animation-delay: .32s; }
.is-demo .pl__btn:nth-of-type(3) { animation-delay: .64s; }
.is-demo .pl__btn:nth-of-type(4) { animation-delay: .96s; }
.is-demo .pl__btn:nth-of-type(5) { animation-delay: 1.28s; }
@keyframes pl-slide {
0%, 12% { transform: translateX(0); }
16%, 28% { transform: translateX(56px); }
32%, 44% { transform: translateX(112px); }
48%, 60% { transform: translateX(168px); }
64%, 76% { transform: translateX(224px); }
86%, 100% { transform: translateX(0); }
}
@keyframes pl-dim {
0% { color: rgba(255, 255, 255, .55); }
4% { color: #fff; }
16% { color: rgba(255, 255, 255, .55); }
100% { color: rgba(255, 255, 255, .55); }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .pl__pill,
.is-demo .pl__btn { animation-name: none; }
.pl__pill,
.pl__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl {
--idx: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pl__dock {
position: relative;
display: flex;
gap: 8px;
padding: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.pl__pill {
position: absolute;
top: 8px;
left: 8px;
width: 48px;
height: 48px;
border-radius: 999px;
background: #2f6df6;
transform: translateX(calc(var(--idx) * 56px));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl__btn {
position: relative;
flex: 0 0 auto;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl__btn svg {
width: 24px;
height: 24px;
display: block;
}
.pl__btn:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
.pl__btn[aria-current=page] {
color: #fff;
}
.pl__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.62);
}
/* 자동 재생 — 알약이 다섯 칸을 돌고 90% 안에 첫 칸으로 복귀 */
.is-demo .pl__pill {
animation-name: pl-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .pl__btn {
animation-name: pl-dim;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .pl__btn:nth-of-type(2) {
animation-delay: 0.32s;
}
.is-demo .pl__btn:nth-of-type(3) {
animation-delay: 0.64s;
}
.is-demo .pl__btn:nth-of-type(4) {
animation-delay: 0.96s;
}
.is-demo .pl__btn:nth-of-type(5) {
animation-delay: 1.28s;
}
@keyframes pl-slide {
0%, 12% {
transform: translateX(0);
}
16%, 28% {
transform: translateX(56px);
}
32%, 44% {
transform: translateX(112px);
}
48%, 60% {
transform: translateX(168px);
}
64%, 76% {
transform: translateX(224px);
}
86%, 100% {
transform: translateX(0);
}
}
@keyframes pl-dim {
0% {
color: rgba(255, 255, 255, 0.55);
}
4% {
color: #fff;
}
16% {
color: rgba(255, 255, 255, 0.55);
}
100% {
color: rgba(255, 255, 255, 0.55);
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .pl__pill,
.is-demo .pl__btn {
animation-name: none;
}
.pl__pill,
.pl__btn {
transition: none;
}
}// 실제 동작: 클릭 → --idx 값 교체. 알약은 translateX(var(--idx) * 56px) 로 이동(항목 폭 48 + gap 8).
var root = document.getElementById('pl');
var btns = Array.prototype.slice.call(root.querySelectorAll('.pl__btn'));
btns.forEach(function (b, i) {
if (i === 0) b.setAttribute('aria-current', 'page');
b.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () {
root.style.setProperty('--idx', i);
btns.forEach(function (x) { x.removeAttribute('aria-current'); });
b.setAttribute('aria-current', 'page');
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PillSlideDock.module.scss";
export type PillSlideDockProps = {
/** 처음 켜 둘 칸 (0부터) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--idx"?: string;
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
const ICONS = [
{
label: "Home",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
),
},
{
label: "Files",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
),
},
{
label: "Chat",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
),
},
{
label: "Settings",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99.4 99.4 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
),
},
{
label: "Profile",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
),
},
];
export default function PillSlideDock({
start = 0,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: PillSlideDockProps) {
// 실제 동작: 클릭 → --idx 값 교체. 알약은 translateX(var(--idx) * 56px) 로 이동(항목 폭 48 + gap 8).
const [idx, setIdx] = useState(start);
const vars: Vars = {
"--idx": `${idx}`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.pl} style={vars}>
<nav className={styles.dock} aria-label="Main menu">
<i className={styles.pill} aria-hidden="true" />
{ICONS.map((it, i) => (
<button
key={it.label}
className={styles.btn}
type="button"
aria-label={it.label}
aria-current={i === idx ? "page" : undefined}
onClick={() => setIdx(i)}
>
{it.svg}
</button>
))}
</nav>
<p className={styles.cap}>The pill slides a fixed number of pixels to the active slot</p>
</div>
);
}02 · ピルスライドインジケーター — 計測 累積面積 7.554% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
動く背景そのものが現在地を示すので、アプリ画面の下部タブの切り替えに置くと読み取りやすい形です。
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pl__pill {
position: absolute;
width: 48px;
height: 48px;
border-radius: $r-pill;
background: $color;
transform: translateX(calc(var(--idx) * 56px));
transition: transform $duration $easing;
}
.pl__btn { flex: 0 0 auto; width: 48px; }
03ラベルツールチップ
どんな動きか
アイコンにポインターを載せると、名前ラベルがアイコンの上へ跳ね上がります。支点になるのはラベルの下辺なので transform-origin: bottom center が必要で、消えるときはフェードせずに1フレームで切れます。

<main class="stage">
<div class="tt is-demo" id="tt">
<nav class="tt__dock" aria-label="メインメニュー">
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">ホーム</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span class="tt__sr">ホーム</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">検索</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
<span class="tt__sr">検索</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">写真</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
<span class="tt__sr">写真</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">お気に入り</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
<span class="tt__sr">お気に入り</span>
</button>
<button class="tt__btn" type="button">
<span class="tt__tip" aria-hidden="true">設定</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99.4 99.4 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
<span class="tt__sr">設定</span>
</button>
</nav>
<p class="tt__cap">アイコンに載せると名前ラベルが跳ね上がります</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-yellow);
.tt {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
// 이름표는 절대 배치라 부모 높이에 안 들어간다 — 위쪽 자리를 미리 비워 둔다
padding-top: $sp-10;
}
.tt__dock {
display: flex;
gap: $sp-2;
padding: $sp-2;
border-radius: $r-pill;
background: $subject-ink;
}
.tt__btn {
position: relative;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: rgba(255, 255, 255, .6);
cursor: pointer;
transition: color $duration $easing, background-color $duration $easing;
}
.tt__btn svg { width: 24px; height: 24px; display: block; }
.tt__btn:hover,
.tt__btn:focus-visible { color: #fff; background: rgba(255, 255, 255, .12); }
.tt__btn:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.tt__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.tt__tip {
position: absolute;
bottom: calc(100% + #{$sp-2});
left: 50%;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $color;
color: $stage-yellow;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
// 경첩은 아이콘 쪽 변 — 아래에서 위로 펴진다
transform-origin: bottom center;
transform: translateX(-50%) scaleY(0);
transition: transform $duration $easing, opacity $dur-instant steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
.tt__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, .7);
}
/* 자동 재생 — 이름표가 왼쪽부터 차례로 올라온다. 사라짐은 컷(steps) */
.is-demo .tt__tip {
animation-name: tt-pop;
animation-duration: 2s;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.is-demo .tt__btn:nth-child(2) .tt__tip { animation-delay: .34s; }
.is-demo .tt__btn:nth-child(3) .tt__tip { animation-delay: .68s; }
.is-demo .tt__btn:nth-child(4) .tt__tip { animation-delay: 1.02s; }
.is-demo .tt__btn:nth-child(5) .tt__tip { animation-delay: 1.36s; }
.is-demo .tt__btn {
animation-name: tt-lit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .tt__btn:nth-child(2) { animation-delay: .34s; }
.is-demo .tt__btn:nth-child(3) { animation-delay: .68s; }
.is-demo .tt__btn:nth-child(4) { animation-delay: 1.02s; }
.is-demo .tt__btn:nth-child(5) { animation-delay: 1.36s; }
@keyframes tt-pop {
0% { opacity: 0; transform: translateX(-50%) scaleY(0); }
3% { opacity: 1; transform: translateX(-50%) scaleY(0); }
9% { opacity: 1; transform: translateX(-50%) scaleY(1); }
26% { opacity: 1; transform: translateX(-50%) scaleY(1); }
26.01% { opacity: 0; transform: translateX(-50%) scaleY(0); }
100% { opacity: 0; transform: translateX(-50%) scaleY(0); }
}
@keyframes tt-lit {
0% { color: rgba(255, 255, 255, .6); background: none; }
3% { color: #fff; background: rgba(255, 255, 255, .12); }
26% { color: rgba(255, 255, 255, .6); background: none; }
100% { color: rgba(255, 255, 255, .6); background: none; }
}
@media (max-width: 340px) {
.tt__btn { width: 44px; height: 44px; }
.tt { padding-top: $sp-8; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .tt__tip,
.is-demo .tt__btn { animation-name: none; }
.tt__tip,
.tt__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding-top: 40px;
}
.tt__dock {
display: flex;
gap: 8px;
padding: 8px;
border-radius: 999px;
background: #17141a;
}
.tt__btn {
position: relative;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), background-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tt__btn svg {
width: 24px;
height: 24px;
display: block;
}
.tt__btn:hover,
.tt__btn:focus-visible {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.tt__btn:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.tt__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.tt__tip {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
padding: 4px 8px;
border-radius: 4px;
background: #17141a;
color: #ffd23f;
font-size: 12px;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform-origin: bottom center;
transform: translateX(-50%) scaleY(0);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 140ms steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
.tt__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.7);
}
/* 자동 재생 — 이름표가 왼쪽부터 차례로 올라온다. 사라짐은 컷(steps) */
.is-demo .tt__tip {
animation-name: tt-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.is-demo .tt__btn:nth-child(2) .tt__tip {
animation-delay: 0.34s;
}
.is-demo .tt__btn:nth-child(3) .tt__tip {
animation-delay: 0.68s;
}
.is-demo .tt__btn:nth-child(4) .tt__tip {
animation-delay: 1.02s;
}
.is-demo .tt__btn:nth-child(5) .tt__tip {
animation-delay: 1.36s;
}
.is-demo .tt__btn {
animation-name: tt-lit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .tt__btn:nth-child(2) {
animation-delay: 0.34s;
}
.is-demo .tt__btn:nth-child(3) {
animation-delay: 0.68s;
}
.is-demo .tt__btn:nth-child(4) {
animation-delay: 1.02s;
}
.is-demo .tt__btn:nth-child(5) {
animation-delay: 1.36s;
}
@keyframes tt-pop {
0% {
opacity: 0;
transform: translateX(-50%) scaleY(0);
}
3% {
opacity: 1;
transform: translateX(-50%) scaleY(0);
}
9% {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
26% {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
26.01% {
opacity: 0;
transform: translateX(-50%) scaleY(0);
}
100% {
opacity: 0;
transform: translateX(-50%) scaleY(0);
}
}
@keyframes tt-lit {
0% {
color: rgba(255, 255, 255, 0.6);
background: none;
}
3% {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
26% {
color: rgba(255, 255, 255, 0.6);
background: none;
}
100% {
color: rgba(255, 255, 255, 0.6);
background: none;
}
}
@media (max-width: 340px) {
.tt__btn {
width: 44px;
height: 44px;
}
.tt {
padding-top: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .tt__tip,
.is-demo .tt__btn {
animation-name: none;
}
.tt__tip,
.tt__btn {
transition: none;
}
}// 실제 동작: 사람이 만지면 .is-demo 를 떼고, 그 뒤로는 :hover · :focus-visible 이 이름표를 연다.
var root = document.getElementById('tt');
['pointerenter', 'focusin'].forEach(function (t) {
root.addEventListener(t, function () { root.classList.remove('is-demo'); });
});import type { CSSProperties } from "react";
import styles from "./LabelTooltipDock.module.scss";
export type LabelTooltipDockProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ICONS = [
{
label: "Home",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
),
},
{
label: "Search",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
),
},
{
label: "Photos",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
),
},
{
label: "Favorites",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
),
},
{
label: "Settings",
svg: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m207.86 123.18l16.78-21a99 99 0 0 0-10.07-24.29l-26.7-3a81 81 0 0 0-6.81-6.81l-3-26.71a99.4 99.4 0 0 0-24.3-10l-21 16.77a82 82 0 0 0-9.64 0l-21-16.78a99 99 0 0 0-24.21 10.07l-3 26.7a81 81 0 0 0-6.81 6.81l-26.71 3a99.4 99.4 0 0 0-10 24.3l16.77 21a82 82 0 0 0 0 9.64l-16.78 21a99 99 0 0 0 10.07 24.29l26.7 3a81 81 0 0 0 6.81 6.81l3 26.71a99.4 99.4 0 0 0 24.3 10l21-16.77a82 82 0 0 0 9.64 0l21 16.78a99.4 99.4 0 0 0 24.29-10.07l3-26.7a81 81 0 0 0 6.81-6.81l26.71-3a99.4 99.4 0 0 0 10-24.3l-16.77-21a82 82 0 0 0-.08-9.64M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m88-29.84q.06-2.16 0-4.32l14.92-18.64a8 8 0 0 0 1.48-7.06a107.6 107.6 0 0 0-10.88-26.25a8 8 0 0 0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186 40.54a8 8 0 0 0-3.94-6a107.3 107.3 0 0 0-26.25-10.86a8 8 0 0 0-7.06 1.48L130.16 40h-4.32L107.2 25.11a8 8 0 0 0-7.06-1.48a107.6 107.6 0 0 0-26.25 10.88a8 8 0 0 0-3.93 6l-2.64 23.76q-1.56 1.49-3 3L40.54 70a8 8 0 0 0-6 3.94a107.7 107.7 0 0 0-10.87 26.25a8 8 0 0 0 1.49 7.06L40 125.84v4.32L25.11 148.8a8 8 0 0 0-1.48 7.06a107.6 107.6 0 0 0 10.88 26.25a8 8 0 0 0 6 3.93l23.72 2.64q1.49 1.56 3 3L70 215.46a8 8 0 0 0 3.94 6a107.7 107.7 0 0 0 26.25 10.87a8 8 0 0 0 7.06-1.49L125.84 216q2.16.06 4.32 0l18.64 14.92a8 8 0 0 0 7.06 1.48a107.2 107.2 0 0 0 26.25-10.88a8 8 0 0 0 3.93-6l2.64-23.72q1.56-1.48 3-3l23.78-2.8a8 8 0 0 0 6-3.94a107.7 107.7 0 0 0 10.87-26.25a8 8 0 0 0-1.49-7.06Zm-16.1-6.5a74 74 0 0 1 0 8.68a8 8 0 0 0 1.74 5.48l14.19 17.73a91.6 91.6 0 0 1-6.23 15l-22.6 2.56a8 8 0 0 0-5.1 2.64a74 74 0 0 1-6.14 6.14a8 8 0 0 0-2.64 5.1l-2.51 22.58a91.3 91.3 0 0 1-15 6.23l-17.74-14.19a8 8 0 0 0-5-1.75h-.48a74 74 0 0 1-8.68 0a8.06 8.06 0 0 0-5.48 1.74l-17.78 14.2a91.6 91.6 0 0 1-15-6.23L82.89 187a8 8 0 0 0-2.64-5.1a74 74 0 0 1-6.14-6.14a8 8 0 0 0-5.1-2.64l-22.58-2.52a91.3 91.3 0 0 1-6.23-15l14.19-17.74a8 8 0 0 0 1.74-5.48a74 74 0 0 1 0-8.68a8 8 0 0 0-1.74-5.48L40.2 100.45a91.6 91.6 0 0 1 6.23-15L69 82.89a8 8 0 0 0 5.1-2.64a74 74 0 0 1 6.14-6.14A8 8 0 0 0 82.89 69l2.51-22.57a91.3 91.3 0 0 1 15-6.23l17.74 14.19a8 8 0 0 0 5.48 1.74a74 74 0 0 1 8.68 0a8.06 8.06 0 0 0 5.48-1.74l17.77-14.19a91.6 91.6 0 0 1 15 6.23L173.11 69a8 8 0 0 0 2.64 5.1a74 74 0 0 1 6.14 6.14a8 8 0 0 0 5.1 2.64l22.58 2.51a91.3 91.3 0 0 1 6.23 15l-14.19 17.74a8 8 0 0 0-1.74 5.53Z"/></g></svg>
),
},
];
export default function LabelTooltipDock({
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: LabelTooltipDockProps) {
// 실제 동작: :hover · :focus-visible 이 이름표를 연다 — 상태 없이 CSS 가 전부 처리한다.
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.tt} style={vars}>
<nav className={styles.dock} aria-label="Main menu">
{ICONS.map((it) => (
<button key={it.label} className={styles.btn} type="button">
<span className={styles.tip} aria-hidden="true">{it.label}</span>
{it.svg}
<span className={styles.sr}>{it.label}</span>
</button>
))}
</nav>
<p className={styles.cap}>Hover an icon and its label pops up</p>
</div>
);
}03 · ラベルツールチップ — 計測 累積面積 10.12% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
アイコンだけのドックで、初めての訪問者に各ボタンの役割を伝えたいときの定番の手です。
.tt__tip {
position: absolute;
bottom: calc(100% + #{$sp-2});
left: 50%;
opacity: 0;
transform-origin: bottom center;
transform: translateX(-50%) scaleY(0);
transition: transform $duration $easing, opacity $dur-instant steps(1, end);
}
.tt__btn:hover .tt__tip,
.tt__btn:focus-visible .tt__tip {
opacity: 1;
transform: translateX(-50%) scaleY(1);
}
04アイコン拡大ウェーブ
何が見えるか
ポインターに近いアイコンほど大きく膨らみ、遠いアイコンほど膨らみが小さくなります。やっかいなのは倍率のほうではなく押し退けです。

<main class="stage">
<div class="mg is-demo" id="mg">
<nav class="mg__dock" id="mg-dock" aria-label="メインメニュー">
<button class="mg__btn" type="button" aria-label="ホーム">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="検索">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="フォルダ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="チャット">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="写真">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="通知">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button class="mg__btn" type="button" aria-label="マイページ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</nav>
<p class="mg__cap">ポインターに近いほど大きく、隣は押し退けられます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
// 등록해야 배율·밀림이 수로 보간된다 — 등록 안 하면 문자열이라 중간값이 없다
@property --s {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
@property --x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.mg {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-5;
// 아이콘이 위로 부풀어 오르는 만큼 위쪽 자리를 비워 둔다
padding-top: $sp-6;
}
.mg__dock {
display: flex;
align-items: flex-end;
gap: $sp-1;
padding: $sp-2 $sp-3;
border-radius: $r-card;
background: rgba(255, 255, 255, .1);
}
.mg__btn {
--s: 1;
--x: 0px;
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: end center;
padding: 0;
border: 0;
background: none;
color: $color;
cursor: pointer;
transform: translateX(var(--x));
transition: transform $duration linear;
}
.mg__btn:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; border-radius: $r-xs; }
.mg__btn svg {
width: 32px;
height: 32px;
display: block;
transform-origin: bottom center; // 규칙8: 독 바닥서 위로 커짐
scale: var(--s);
transition: scale $duration linear;
}
.mg__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, .62);
}
/* 자동 재생 — 물결이 왼쪽에서 오른쪽으로 지나간다. 지연은 개별 속성으로 */
.is-demo .mg__btn {
animation-name: mg-push;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .mg__btn svg {
animation-name: mg-swell;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .mg__btn:nth-child(2),
.is-demo .mg__btn:nth-child(2) svg { animation-delay: .2s; }
.is-demo .mg__btn:nth-child(3),
.is-demo .mg__btn:nth-child(3) svg { animation-delay: .4s; }
.is-demo .mg__btn:nth-child(4),
.is-demo .mg__btn:nth-child(4) svg { animation-delay: .6s; }
.is-demo .mg__btn:nth-child(5),
.is-demo .mg__btn:nth-child(5) svg { animation-delay: .8s; }
.is-demo .mg__btn:nth-child(6),
.is-demo .mg__btn:nth-child(6) svg { animation-delay: 1s; }
.is-demo .mg__btn:nth-child(7),
.is-demo .mg__btn:nth-child(7) svg { animation-delay: 1.2s; }
@keyframes mg-push {
0% { transform: translateX(0); }
4% { transform: translateX(9px); }
10% { transform: translateX(0); }
16% { transform: translateX(-9px); }
24% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes mg-swell {
0% { scale: 1; }
4% { scale: 1.3; }
10% { scale: 1.8; }
16% { scale: 1.3; }
24% { scale: 1; }
100% { scale: 1; }
}
@media (max-width: 340px) {
.mg__btn { width: 36px; height: 36px; }
.mg__btn svg { width: 26px; height: 26px; }
.mg__dock { padding: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .mg__btn,
.is-demo .mg__btn svg { animation-name: none; }
.mg__btn,
.mg__btn svg { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --s {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
@property --x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.mg {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding-top: 24px;
}
.mg__dock {
display: flex;
align-items: flex-end;
gap: 4px;
padding: 8px 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.1);
}
.mg__btn {
--s: 1;
--x: 0px;
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: end center;
padding: 0;
border: 0;
background: none;
color: #fff7e6;
cursor: pointer;
transform: translateX(var(--x));
transition: transform 140ms linear;
}
.mg__btn:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
border-radius: 4px;
}
.mg__btn svg {
width: 32px;
height: 32px;
display: block;
transform-origin: bottom center;
scale: var(--s);
transition: scale 140ms linear;
}
.mg__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.62);
}
/* 자동 재생 — 물결이 왼쪽에서 오른쪽으로 지나간다. 지연은 개별 속성으로 */
.is-demo .mg__btn {
animation-name: mg-push;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .mg__btn svg {
animation-name: mg-swell;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .mg__btn:nth-child(2),
.is-demo .mg__btn:nth-child(2) svg {
animation-delay: 0.2s;
}
.is-demo .mg__btn:nth-child(3),
.is-demo .mg__btn:nth-child(3) svg {
animation-delay: 0.4s;
}
.is-demo .mg__btn:nth-child(4),
.is-demo .mg__btn:nth-child(4) svg {
animation-delay: 0.6s;
}
.is-demo .mg__btn:nth-child(5),
.is-demo .mg__btn:nth-child(5) svg {
animation-delay: 0.8s;
}
.is-demo .mg__btn:nth-child(6),
.is-demo .mg__btn:nth-child(6) svg {
animation-delay: 1s;
}
.is-demo .mg__btn:nth-child(7),
.is-demo .mg__btn:nth-child(7) svg {
animation-delay: 1.2s;
}
@keyframes mg-push {
0% {
transform: translateX(0);
}
4% {
transform: translateX(9px);
}
10% {
transform: translateX(0);
}
16% {
transform: translateX(-9px);
}
24% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes mg-swell {
0% {
scale: 1;
}
4% {
scale: 1.3;
}
10% {
scale: 1.8;
}
16% {
scale: 1.3;
}
24% {
scale: 1;
}
100% {
scale: 1;
}
}
@media (max-width: 340px) {
.mg__btn {
width: 36px;
height: 36px;
}
.mg__btn svg {
width: 26px;
height: 26px;
}
.mg__dock {
padding: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .mg__btn,
.is-demo .mg__btn svg {
animation-name: none;
}
.mg__btn,
.mg__btn svg {
transition: none;
}
}// 실제 동작: 포인터 x → 아이콘마다 중심 거리 d 를 재고, 거리에 따라 배율을 준다.
// 이웃 밀림은 배율이 만든 폭 증가분의 절반씩을 누적해 좌우로 나눠 주는 계산.
var root = document.getElementById('mg');
var dock = document.getElementById('mg-dock');
var btns = Array.prototype.slice.call(dock.querySelectorAll('.mg__btn'));
var W = 44, RANGE = 90, MAX = 0.8;
function wave(x) {
var s = btns.map(function (b) {
var c = b.offsetLeft + W / 2;
var t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
return 1 + MAX * t * t;
});
var grown = s.map(function (v) { return (v - 1) * W; });
var total = grown.reduce(function (a, b) { return a + b; }, 0);
var before = 0;
btns.forEach(function (b, i) {
// 내 앞 아이콘들이 불어난 폭 + 내가 불어난 폭의 절반 − 전체의 절반(줄 중심 유지)
b.style.setProperty('--s', s[i].toFixed(3));
b.style.setProperty('--x', (before + grown[i] / 2 - total / 2).toFixed(1) + 'px');
before += grown[i];
});
}
function rest() { btns.forEach(function (b) { b.style.setProperty('--s', 1); b.style.setProperty('--x', '0px'); }); }
dock.addEventListener('pointermove', function (e) {
root.classList.remove('is-demo');
wave(e.clientX - dock.getBoundingClientRect().left);
});
dock.addEventListener('pointerleave', rest);import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MagnifyWaveDock.module.scss";
export type MagnifyWaveDockProps = {
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BtnVars = CSSProperties & { "--s"?: string; "--x"?: string };
/** 아이콘 하나의 배율(s)과 좌우 밀림(x, px) */
type Wave = { s: number; x: number };
/** 아이콘 폭 · 물결이 닿는 거리 · 최대 추가 배율 — vanilla 와 같은 값 */
const W = 44;
const RANGE = 90;
const MAX = 0.8;
const ITEMS = [
{
label: "Home",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2" /><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" /></g></svg>
),
},
{
label: "Search",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" /><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" /></g></svg>
),
},
{
label: "Files",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" /><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" /></g></svg>
),
},
{
label: "Chat",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2" /><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216" /></g></svg>
),
},
{
label: "Photos",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" /><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12" /></g></svg>
),
},
{
label: "Alerts",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g></svg>
),
},
{
label: "Profile",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" /><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" /></g></svg>
),
},
];
const REST: Wave[] = ITEMS.map(function (): Wave { return { s: 1, x: 0 }; });
export default function MagnifyWaveDock({
duration = 140,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: MagnifyWaveDockProps) {
const dockRef = useRef<HTMLElement>(null);
const [wv, setWv] = useState<Wave[]>(REST);
// 포인터 x → 아이콘마다 중심 거리 d 를 재고, 거리에 따라 배율을 준다.
// 이웃 밀림은 배율이 만든 폭 증가분의 절반씩을 누적해 좌우로 나눠 주는 계산.
function wave(x: number): void {
const dock = dockRef.current;
if (!dock) return;
const btns = Array.from(dock.querySelectorAll("button"));
const s = btns.map(function (b) {
const c = b.offsetLeft + W / 2;
const t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
return 1 + MAX * t * t;
});
const grown = s.map(function (v) { return (v - 1) * W; });
const total = grown.reduce(function (a, b) { return a + b; }, 0);
let before = 0;
const next = s.map(function (v, i): Wave {
// 내 앞 아이콘들이 불어난 폭 + 내가 불어난 폭의 절반 − 전체의 절반(줄 중심 유지)
const w = { s: v, x: before + grown[i] / 2 - total / 2 };
before += grown[i];
return w;
});
setWv(next);
}
function onMove(e: PEvt): void {
const dock = dockRef.current;
if (!dock) return;
wave(e.clientX - dock.getBoundingClientRect().left);
}
function onLeave(): void { setWv(REST); }
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.mg} style={vars}>
<nav
className={styles.dock}
ref={dockRef}
aria-label="Main menu"
onPointerMove={onMove}
onPointerLeave={onLeave}
>
{ITEMS.map(function (it, i) {
const bv: BtnVars = {
"--s": (wv[i] ? wv[i].s : 1).toFixed(3),
"--x": `${(wv[i] ? wv[i].x : 0).toFixed(1)}px`,
};
return (
<button className={styles.btn} type="button" key={it.label} aria-label={it.label} style={bv}>
{it.icon}
</button>
);
})}
</nav>
<p className={styles.cap}>Closer to the pointer means bigger; neighbors get pushed aside</p>
</div>
);
}04 · アイコン拡大ウェーブ — 計測 累積面積 9.015% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
膨らんで増えた幅を手前のアイコンから積み上げて配り、最後に増加分の合計の半分を引くと、行が中央に留まります。
function wave(x) {
var s = btns.map(function (b) {
var c = b.offsetLeft + W / 2;
var t = Math.max(0, 1 - Math.abs(x - c) / RANGE);
return 1 + MAX * t * t;
});
var grown = s.map(function (v) { return (v - 1) * W; });
var total = grown.reduce(function (a, b) { return a + b; }, 0);
var before = 0;
btns.forEach(function (b, i) {
b.style.setProperty('--s', s[i].toFixed(3));
b.style.setProperty('--x', (before + grown[i] / 2 - total / 2).toFixed(1) + 'px');
before += grown[i];
});
}
05グラスモーフィズムドック
画面で起きること
曇りガラスの帯の後ろに、紫と水色の丸いにじみを敷いてあります。ガラスは後ろに何かがあって初めてガラスに見えるので、このにじみは飾りではなく材料です。

<main class="stage">
<div class="gl is-demo" id="gl" style="--idx: 0">
<i class="gl__blob gl__blob--a" aria-hidden="true"></i>
<i class="gl__blob gl__blob--b" aria-hidden="true"></i>
<nav class="gl__dock" aria-label="メインメニュー">
<i class="gl__pill" aria-hidden="true"></i>
<button class="gl__btn" type="button" aria-label="ホーム">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="gl__btn" type="button" aria-label="検索">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="gl__btn" type="button" aria-label="お気に入り登録">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2"/><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8"/></g></svg>
</button>
<button class="gl__btn" type="button" aria-label="通知">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button class="gl__btn" type="button" aria-label="マイページ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</nav>
<p class="gl__cap">曇りガラスの奥で背景色が滲んで見えます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.gl {
--idx: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-6 $sp-8;
border-radius: $r-card;
overflow: hidden;
background: #241d33;
}
/* 유리 뒤에 깔아 두는 색 얼룩 — 이게 있어야 흐림이 눈에 보인다 */
.gl__blob {
position: absolute;
width: 168px;
height: 168px;
border-radius: 50%;
filter: blur(4px);
}
.gl__blob--a {
top: -40px;
left: -20px;
background: radial-gradient(circle, rgba(185, 160, 255, .95), rgba(185, 160, 255, 0) 70%);
}
.gl__blob--b {
bottom: -48px;
right: -16px;
background: radial-gradient(circle, rgba(110, 200, 255, .9), rgba(110, 200, 255, 0) 70%);
}
.gl__dock {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-2;
border: 1px solid rgba(255, 255, 255, .3);
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
backdrop-filter: blur(12px) saturate(1.6);
box-shadow: 0 12px 28px rgba(23, 20, 26, .4);
}
.gl__pill {
position: absolute;
top: $sp-2;
left: $sp-2;
width: 44px;
height: 44px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .32);
transform: translateX(calc(var(--idx) * 52px));
transition: transform $duration $easing;
}
.gl__btn {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: rgba(255, 255, 255, .72);
cursor: pointer;
transition: transform $duration $easing, color $duration $easing;
}
.gl__btn svg { width: 22px; height: 22px; display: block; }
.gl__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.gl__btn[aria-current="page"] { color: #fff; transform: translateY(-3px); }
.gl__cap {
position: relative;
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, .68);
}
/* 자동 재생 — 알약이 다섯 칸을 돌고 88% 에 첫 칸으로 복귀 */
.is-demo .gl__pill {
animation-name: gl-slide;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .gl__btn {
animation-name: gl-lift;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .gl__btn:nth-of-type(2) { animation-delay: .32s; }
.is-demo .gl__btn:nth-of-type(3) { animation-delay: .64s; }
.is-demo .gl__btn:nth-of-type(4) { animation-delay: .96s; }
.is-demo .gl__btn:nth-of-type(5) { animation-delay: 1.28s; }
@keyframes gl-slide {
0%, 12% { transform: translateX(0); }
16%, 28% { transform: translateX(52px); }
32%, 44% { transform: translateX(104px); }
48%, 60% { transform: translateX(156px); }
64%, 76% { transform: translateX(208px); }
88%, 100% { transform: translateX(0); }
}
@keyframes gl-lift {
0% { transform: translateY(0); color: rgba(255, 255, 255, .72); }
4% { transform: translateY(-3px); color: #fff; }
14% { transform: translateY(-3px); color: #fff; }
18% { transform: translateY(0); color: rgba(255, 255, 255, .72); }
100% { transform: translateY(0); color: rgba(255, 255, 255, .72); }
}
@media (max-width: 340px) {
.gl { padding: $sp-5 $sp-4; }
.gl__blob { width: 120px; height: 120px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .gl__pill,
.is-demo .gl__btn { animation-name: none; }
.gl__pill,
.gl__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --idx {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.gl {
--idx: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 24px 32px;
border-radius: 16px;
overflow: hidden;
background: #241d33;
}
/* 유리 뒤에 깔아 두는 색 얼룩 — 이게 있어야 흐림이 눈에 보인다 */
.gl__blob {
position: absolute;
width: 168px;
height: 168px;
border-radius: 50%;
filter: blur(4px);
}
.gl__blob--a {
top: -40px;
left: -20px;
background: radial-gradient(circle, rgba(185, 160, 255, 0.95), rgba(185, 160, 255, 0) 70%);
}
.gl__blob--b {
bottom: -48px;
right: -16px;
background: radial-gradient(circle, rgba(110, 200, 255, 0.9), rgba(110, 200, 255, 0) 70%);
}
.gl__dock {
position: relative;
display: flex;
gap: 8px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(12px) saturate(1.6);
box-shadow: 0 12px 28px rgba(23, 20, 26, 0.4);
}
.gl__pill {
position: absolute;
top: 8px;
left: 8px;
width: 44px;
height: 44px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.32);
transform: translateX(calc(var(--idx) * 52px));
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gl__btn {
position: relative;
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: rgba(255, 255, 255, 0.72);
cursor: pointer;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.gl__btn svg {
width: 22px;
height: 22px;
display: block;
}
.gl__btn:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.gl__btn[aria-current=page] {
color: #fff;
transform: translateY(-3px);
}
.gl__cap {
position: relative;
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.68);
}
/* 자동 재생 — 알약이 다섯 칸을 돌고 88% 에 첫 칸으로 복귀 */
.is-demo .gl__pill {
animation-name: gl-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .gl__btn {
animation-name: gl-lift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .gl__btn:nth-of-type(2) {
animation-delay: 0.32s;
}
.is-demo .gl__btn:nth-of-type(3) {
animation-delay: 0.64s;
}
.is-demo .gl__btn:nth-of-type(4) {
animation-delay: 0.96s;
}
.is-demo .gl__btn:nth-of-type(5) {
animation-delay: 1.28s;
}
@keyframes gl-slide {
0%, 12% {
transform: translateX(0);
}
16%, 28% {
transform: translateX(52px);
}
32%, 44% {
transform: translateX(104px);
}
48%, 60% {
transform: translateX(156px);
}
64%, 76% {
transform: translateX(208px);
}
88%, 100% {
transform: translateX(0);
}
}
@keyframes gl-lift {
0% {
transform: translateY(0);
color: rgba(255, 255, 255, 0.72);
}
4% {
transform: translateY(-3px);
color: #fff;
}
14% {
transform: translateY(-3px);
color: #fff;
}
18% {
transform: translateY(0);
color: rgba(255, 255, 255, 0.72);
}
100% {
transform: translateY(0);
color: rgba(255, 255, 255, 0.72);
}
}
@media (max-width: 340px) {
.gl {
padding: 20px 16px;
}
.gl__blob {
width: 120px;
height: 120px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .gl__pill,
.is-demo .gl__btn {
animation-name: none;
}
.gl__pill,
.gl__btn {
transition: none;
}
}// 실제 동작: 클릭 → --idx 교체. 알약이 유리 위를 미끄러지고 해당 아이콘이 살짝 들린다.
var root = document.getElementById('gl');
var btns = Array.prototype.slice.call(root.querySelectorAll('.gl__btn'));
btns[0].setAttribute('aria-current', 'page');
btns.forEach(function (b, i) {
b.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () {
root.style.setProperty('--idx', i);
btns.forEach(function (x) { x.removeAttribute('aria-current'); });
b.setAttribute('aria-current', 'page');
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GlassFrostDock.module.scss";
export type GlassFrostDockProps = {
/** 처음 켜져 있는 칸 */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--idx"?: number;
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
};
const ITEMS = [
{
label: "Home",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2" /><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" /></g></svg>
),
},
{
label: "Search",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" /><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" /></g></svg>
),
},
{
label: "Likes",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M232 102c0 66-104 122-104 122S24 168 24 102a54 54 0 0 1 54-54c22.59 0 41.94 12.31 50 32c8.06-19.69 27.41-32 50-32a54 54 0 0 1 54 54" opacity=".2" /><path d="M178 40c-20.65 0-38.73 8.88-50 23.89C116.73 48.88 98.65 40 78 40a62.07 62.07 0 0 0-62 62c0 70 103.79 126.66 108.21 129a8 8 0 0 0 7.58 0C136.21 228.66 240 172 240 102a62.07 62.07 0 0 0-62-62m-50 174.8c-18.26-10.64-96-59.11-96-112.8a46.06 46.06 0 0 1 46-46c19.45 0 35.78 10.36 42.6 27a8 8 0 0 0 14.8 0c6.82-16.67 23.15-27 42.6-27a46.06 46.06 0 0 1 46 46c0 53.61-77.76 102.15-96 112.8" /></g></svg>
),
},
{
label: "Alerts",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g></svg>
),
},
{
label: "Profile",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" /><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" /></g></svg>
),
},
];
export default function GlassFrostDock({
start = 0,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b9a0ff",
}: GlassFrostDockProps) {
// 클릭 → --idx 교체. 알약이 유리 위를 미끄러지고 해당 아이콘이 살짝 들린다.
const [idx, setIdx] = useState(start);
const vars: Vars = {
"--idx": idx,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.gl} style={vars}>
{/* 유리 뒤에 깔아 두는 색 얼룩 — 이게 있어야 흐림이 눈에 보인다 */}
<i className={`${styles.blob} ${styles.blobA}`} aria-hidden="true" />
<i className={`${styles.blob} ${styles.blobB}`} aria-hidden="true" />
<nav className={styles.dock} aria-label="Main menu">
<i className={styles.pill} aria-hidden="true" />
{ITEMS.map(function (it, i) {
return (
<button
className={styles.btn}
type="button"
key={it.label}
aria-label={it.label}
aria-current={i === idx ? "page" : undefined}
onClick={function () { setIdx(i); }}
>
{it.icon}
</button>
);
})}
</nav>
<p className={styles.cap}>Background colors smear through the frosted glass</p>
</div>
);
}05 · グラスモーフィズムドック — 計測 累積面積 6.322% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
アクティブなカプセルがガラスの上を滑り、その位置のアイコンだけが3px浮き上がります。
.gl__blob--a {
top: -40px;
left: -20px;
background: radial-gradient(circle, rgba(185, 160, 255, .95), rgba(185, 160, 255, 0) 70%);
}
.gl__dock {
border: 1px solid rgba(255, 255, 255, .3);
border-radius: $r-pill;
background: rgba(255, 255, 255, .14);
backdrop-filter: blur(12px) saturate(1.6);
}
.gl__btn[aria-current="page"] { color: #fff; transform: translateY(-3px); }
06ニューモーフィズムドック
どんな動きか
背景とボタンが同じ色で、影1対だけが高さの差をつくります。押された状態は新しい影を描いたものではなく、同じ影を inset で裏返しただけで、その状態は aria-pressed が持っています。

<main class="stage">
<div class="nu is-demo" id="nu">
<nav class="nu__dock" aria-label="メインメニュー">
<button class="nu__btn" type="button" aria-label="ホーム" aria-pressed="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="nu__btn" type="button" aria-label="検索" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="nu__btn" type="button" aria-label="フォルダ" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button class="nu__btn" type="button" aria-label="通知" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button class="nu__btn" type="button" aria-label="マイページ" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</nav>
<p class="nu__cap">押したアイコンは影が内側へ窪みます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: #6d5fae;
$bg: #e8e6f0;
$light: #ffffff;
$dark: #a9a3c0;
@include stage($bg);
.nu {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-5;
}
.nu__dock {
display: flex;
gap: $sp-3;
padding: $sp-3 $sp-4;
border-radius: $r-pill;
background: $bg;
box-shadow: 8px 8px 16px $dark, -8px -8px 16px $light;
}
.nu__btn {
flex: 0 0 auto;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: $bg;
color: rgba(60, 54, 84, .6);
cursor: pointer;
/* 떠오른 상태 — 바깥 그림자 두 개 */
box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light;
transition: box-shadow $duration $easing, color $duration $easing;
}
.nu__btn svg { width: 22px; height: 22px; display: block; }
.nu__btn:focus-visible { outline: 2px solid $color; outline-offset: 4px; }
.nu__btn[aria-pressed="true"] {
color: $color;
/* 눌린 상태 — 같은 그림자를 inset 으로 뒤집는다 */
box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light;
}
.nu__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(60, 54, 84, .82); // 뉴모피즘 연면 위 흐린 글자 3.40:1 → 4.6:1 이상
}
/* 자동 재생 — 왼쪽부터 하나씩 눌렸다 떠오른다. 마지막은 84% 에 풀린다 */
.is-demo .nu__btn {
animation-name: nu-press;
animation-duration: 2s;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.is-demo .nu__btn:nth-child(2) { animation-delay: .34s; }
.is-demo .nu__btn:nth-child(3) { animation-delay: .68s; }
.is-demo .nu__btn:nth-child(4) { animation-delay: 1.02s; }
.is-demo .nu__btn:nth-child(5) { animation-delay: 1.36s; }
@keyframes nu-press {
0% { box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light; color: rgba(60, 54, 84, .6); }
8% { box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light; color: $color; }
17% { box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light; color: $color; }
24% { box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light; color: rgba(60, 54, 84, .6); }
100% { box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light; color: rgba(60, 54, 84, .6); }
}
@media (max-width: 340px) {
.nu__dock { gap: $sp-2; padding: $sp-2 $sp-3; }
.nu__btn { width: 44px; height: 44px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .nu__btn { animation-name: none; }
.nu__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #e8e6f0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.nu {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.nu__dock {
display: flex;
gap: 12px;
padding: 12px 16px;
border-radius: 999px;
background: #e8e6f0;
box-shadow: 8px 8px 16px #a9a3c0, -8px -8px 16px #ffffff;
}
.nu__btn {
flex: 0 0 auto;
width: 48px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: #e8e6f0;
color: rgba(60, 54, 84, 0.6);
cursor: pointer;
/* 떠오른 상태 — 바깥 그림자 두 개 */
box-shadow: 5px 5px 10px #a9a3c0, -5px -5px 10px #ffffff;
transition: box-shadow 220ms ease-out, color 220ms ease-out;
}
.nu__btn svg {
width: 22px;
height: 22px;
display: block;
}
.nu__btn:focus-visible {
outline: 2px solid #6d5fae;
outline-offset: 4px;
}
.nu__btn[aria-pressed=true] {
color: #6d5fae;
/* 눌린 상태 — 같은 그림자를 inset 으로 뒤집는다 */
box-shadow: inset 5px 5px 10px #a9a3c0, inset -5px -5px 10px #ffffff;
}
.nu__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(60, 54, 84, 0.82);
}
/* 자동 재생 — 왼쪽부터 하나씩 눌렸다 떠오른다. 마지막은 84% 에 풀린다 */
.is-demo .nu__btn {
animation-name: nu-press;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.is-demo .nu__btn:nth-child(2) {
animation-delay: 0.34s;
}
.is-demo .nu__btn:nth-child(3) {
animation-delay: 0.68s;
}
.is-demo .nu__btn:nth-child(4) {
animation-delay: 1.02s;
}
.is-demo .nu__btn:nth-child(5) {
animation-delay: 1.36s;
}
@keyframes nu-press {
0% {
box-shadow: 5px 5px 10px #a9a3c0, -5px -5px 10px #ffffff;
color: rgba(60, 54, 84, 0.6);
}
8% {
box-shadow: inset 5px 5px 10px #a9a3c0, inset -5px -5px 10px #ffffff;
color: #6d5fae;
}
17% {
box-shadow: inset 5px 5px 10px #a9a3c0, inset -5px -5px 10px #ffffff;
color: #6d5fae;
}
24% {
box-shadow: 5px 5px 10px #a9a3c0, -5px -5px 10px #ffffff;
color: rgba(60, 54, 84, 0.6);
}
100% {
box-shadow: 5px 5px 10px #a9a3c0, -5px -5px 10px #ffffff;
color: rgba(60, 54, 84, 0.6);
}
}
@media (max-width: 340px) {
.nu__dock {
gap: 8px;
padding: 8px 12px;
}
.nu__btn {
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .nu__btn {
animation-name: none;
}
.nu__btn {
transition: none;
}
}// 실제 동작: 클릭 → aria-pressed 를 옮긴다. 눌린 표현은 CSS 가 [aria-pressed="true"] 로 그린다.
var root = document.getElementById('nu');
var btns = Array.prototype.slice.call(root.querySelectorAll('.nu__btn'));
btns.forEach(function (b) {
b.addEventListener('pointerdown', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () {
btns.forEach(function (x) { x.setAttribute('aria-pressed', String(x === b)); });
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NeuSoftDock.module.scss";
export type NeuSoftDockProps = {
/** 처음 눌려 있는 칸 */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ITEMS = [
{
label: "Home",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2" /><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" /></g></svg>
),
},
{
label: "Search",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" /><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" /></g></svg>
),
},
{
label: "Files",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" /><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" /></g></svg>
),
},
{
label: "Alerts",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2" /><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z" /></g></svg>
),
},
{
label: "Profile",
icon: (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2" /><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56" /></g></svg>
),
},
];
export default function NeuSoftDock({
start = 0,
duration = 220,
easing = "ease-out",
color = "#6d5fae",
}: NeuSoftDockProps) {
// 클릭 → aria-pressed 를 옮긴다. 눌린 표현은 CSS 가 [aria-pressed="true"] 로 그린다.
const [on, setOn] = useState(start);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.nu} style={vars}>
<nav className={styles.dock} aria-label="Main menu">
{ITEMS.map(function (it, i) {
return (
<button
className={styles.btn}
type="button"
key={it.label}
aria-label={it.label}
aria-pressed={i === on}
onClick={function () { setOn(i); }}
>
{it.icon}
</button>
);
})}
</nav>
<p className={styles.cap}>The pressed icon has its shadow carved inward</p>
</div>
);
}06 · ニューモーフィズムドック — 計測 累積面積 6.16% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
インテリアやベビー用品のように、柔らかい印象を売る画面に合います。
.nu__btn {
border-radius: 50%;
background: $bg;
box-shadow: 5px 5px 10px $dark, -5px -5px 10px $light;
transition: box-shadow $duration $easing, color $duration $easing;
}
.nu__btn[aria-pressed="true"] {
color: $color;
box-shadow: inset 5px 5px 10px $dark, inset -5px -5px 10px $light;
}
07半透明ブラーバッジ
何が見えるか
明るい背景では、ガラス効果はよく失敗します。白いバッジに白い背景では、ぼかす相手そのものがないからです。

<main class="stage">
<div class="bb is-demo" id="bb">
<div class="bb__scene">
<i class="bb__band bb__band--1" aria-hidden="true"></i>
<i class="bb__band bb__band--2" aria-hidden="true"></i>
<i class="bb__band bb__band--3" aria-hidden="true"></i>
<nav class="bb__dock" aria-label="メインメニュー">
<button class="bb__btn" type="button" aria-label="ホーム">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="bb__btn" type="button" aria-label="検索">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="bb__btn" type="button" aria-label="写真">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button class="bb__btn" type="button" aria-label="お気に入り">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
</button>
</nav>
</div>
<p class="bb__cap">通り過ぎる色がバッジの中で滲んで透けます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-paper);
.bb {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.bb__scene {
position: relative;
width: min(336px, 86vw);
height: 132px;
display: grid;
place-items: center;
border-radius: $r-card;
overflow: hidden;
background: #fff6f9;
}
/* 배경을 가로지르는 색 띠 — 배지가 유리임을 증명하는 재료 */
.bb__band {
position: absolute;
top: -20px;
width: 72px;
height: 176px;
border-radius: $r-pill;
}
.bb__band--1 { left: -80px; background: $color; }
.bb__band--2 { left: -80px; background: $subject-mint; }
.bb__band--3 { left: -80px; background: $subject-sky; }
.bb__dock {
position: relative;
display: flex;
gap: $sp-2;
padding: $sp-2;
border: 1px solid rgba(255, 255, 255, .7);
border-radius: $r-pill;
background: rgba(255, 255, 255, .55);
backdrop-filter: blur(8px);
box-shadow: 0 10px 22px rgba(23, 20, 26, .12);
}
.bb__btn {
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-pill;
background: none;
color: rgba(23, 20, 26, .62);
cursor: pointer;
transition: color $duration $easing, background-color $duration $easing;
}
.bb__btn svg { width: 22px; height: 22px; display: block; }
.bb__btn:hover { color: $subject-ink; background: rgba(255, 255, 255, .7); }
.bb__btn:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
.bb__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, .62);
}
/* 자동 재생 — 띠가 오른쪽 끝까지 갔다가 같은 길로 돌아온다(복귀 텔레포트 없음) */
.is-demo .bb__band {
animation-name: bb-drift;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.is-demo .bb__band--2 { animation-delay: -.22s; }
.is-demo .bb__band--3 { animation-delay: -.44s; }
@keyframes bb-drift {
0% { transform: translateX(0); }
50% { transform: translateX(420px); }
100% { transform: translateX(0); }
}
@media (max-width: 340px) {
.bb__scene { height: 120px; }
.bb__btn { width: 40px; height: 40px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .bb__band { animation-name: none; }
.bb__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bb {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.bb__scene {
position: relative;
width: min(336px, 86vw);
height: 132px;
display: grid;
place-items: center;
border-radius: 16px;
overflow: hidden;
background: #fff6f9;
}
/* 배경을 가로지르는 색 띠 — 배지가 유리임을 증명하는 재료 */
.bb__band {
position: absolute;
top: -20px;
width: 72px;
height: 176px;
border-radius: 999px;
}
.bb__band--1 {
left: -80px;
background: #ff9ebb;
}
.bb__band--2 {
left: -80px;
background: #4cd4a6;
}
.bb__band--3 {
left: -80px;
background: #6ec8ff;
}
.bb__dock {
position: relative;
display: flex;
gap: 8px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 999px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(8px);
box-shadow: 0 10px 22px rgba(23, 20, 26, 0.12);
}
.bb__btn {
flex: 0 0 auto;
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: rgba(23, 20, 26, 0.62);
cursor: pointer;
transition: color 300ms cubic-bezier(0.2, 0.8, 0.2, 1), background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bb__btn svg {
width: 22px;
height: 22px;
display: block;
}
.bb__btn:hover {
color: #17141a;
background: rgba(255, 255, 255, 0.7);
}
.bb__btn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.bb__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.62);
}
/* 자동 재생 — 띠가 오른쪽 끝까지 갔다가 같은 길로 돌아온다(복귀 텔레포트 없음) */
.is-demo .bb__band {
animation-name: bb-drift;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.is-demo .bb__band--2 {
animation-delay: -0.22s;
}
.is-demo .bb__band--3 {
animation-delay: -0.44s;
}
@keyframes bb-drift {
0% {
transform: translateX(0);
}
50% {
transform: translateX(420px);
}
100% {
transform: translateX(0);
}
}
@media (max-width: 340px) {
.bb__scene {
height: 120px;
}
.bb__btn {
width: 40px;
height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .bb__band {
animation-name: none;
}
.bb__btn {
transition: none;
}
}// 실제 동작: 사람이 만지면 자동 루프를 멈추고, 그 뒤로는 :hover 로 배지가 또렷해진다.
var root = document.getElementById('bb');
['pointerdown', 'focusin'].forEach(function (t) {
root.addEventListener(t, function () { root.classList.remove('is-demo'); });
});import type { CSSProperties } from "react";
import styles from "./BlurBadgeDock.module.scss";
export type BlurBadgeDockProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BlurBadgeDock({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#ff9ebb",
}: BlurBadgeDockProps) {
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.bb} style={vars}>
<div className={styles.scene}>
<i className={`${styles.band} ${styles.band1}`} aria-hidden="true" />
<i className={`${styles.band} ${styles.band2}`} aria-hidden="true" />
<i className={`${styles.band} ${styles.band3}`} aria-hidden="true" />
<nav className={styles.dock} aria-label="Main menu">
<button className={styles.btn} type="button" aria-label="Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Photos">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Favorites">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="m229.06 108.79l-48.7 42l14.88 62.79a8.4 8.4 0 0 1-12.52 9.17L128 189.09l-54.72 33.65a8.4 8.4 0 0 1-12.52-9.17l14.88-62.79l-48.7-42A8.46 8.46 0 0 1 31.73 94l63.91-5.2l24.62-59.6a8.36 8.36 0 0 1 15.48 0l24.62 59.6l63.91 5.2a8.46 8.46 0 0 1 4.79 14.79" opacity=".2"/><path d="M239.18 97.26A16.38 16.38 0 0 0 224.92 86l-59-4.76l-22.78-55.09a16.36 16.36 0 0 0-30.27 0L90.11 81.23L31.08 86a16.46 16.46 0 0 0-9.37 28.86l45 38.83L53 211.75a16.38 16.38 0 0 0 24.5 17.82l50.5-31.08l50.53 31.08A16.4 16.4 0 0 0 203 211.75l-13.76-58.07l45-38.83a16.43 16.43 0 0 0 4.94-17.59m-15.34 5.47l-48.7 42a8 8 0 0 0-2.56 7.91l14.88 62.8a.37.37 0 0 1-.17.48c-.18.14-.23.11-.38 0l-54.72-33.65a8 8 0 0 0-8.38 0l-54.72 33.67c-.15.09-.19.12-.38 0a.37.37 0 0 1-.17-.48l14.88-62.8a8 8 0 0 0-2.56-7.91l-48.7-42c-.12-.1-.23-.19-.13-.5s.18-.27.33-.29l63.92-5.16a8 8 0 0 0 6.72-4.94l24.62-59.61c.08-.17.11-.25.35-.25s.27.08.35.25L153 91.86a8 8 0 0 0 6.75 4.92l63.92 5.16c.15 0 .24 0 .33.29s0 .4-.16.5"/></g></svg>
</button>
</nav>
</div>
<p className={styles.cap}>Colors passing behind the bar blur inside the badge</p>
</div>
);
}07 · 半透明ブラーバッジ — 計測 累積面積 30.672% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
そこで色の帯3本をバッジの下へ通してあり、帯は右端まで進んでから同じ道を戻ってくるので、ループが巻き戻る瞬間に飛びが出ません。
.bb__dock {
border: 1px solid rgba(255, 255, 255, .7);
background: rgba(255, 255, 255, .55);
backdrop-filter: blur(8px);
}
.is-demo .bb__band--2 { animation-delay: -.22s; }
@keyframes bb-drift {
0% { transform: translateX(0); }
50% { transform: translateX(420px); }
100% { transform: translateX(0); }
}
08ランチパッドグリッド展開
画面で起きること
1行だったドックが7枠のグリッドへ開きます。板の大きさは畳んでも開いても変わらずアイコンだけが動くため、下にくっついたボタンが上下に揺れません。

<main class="stage">
<div class="lp is-demo" id="lp">
<div class="lp__grid" id="lp-grid">
<button class="lp__btn" type="button" aria-label="ホーム">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button class="lp__btn" type="button" aria-label="検索">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button class="lp__btn" type="button" aria-label="フォルダ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button class="lp__btn lp__btn--more" type="button" aria-label="チャット">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button class="lp__btn lp__btn--more" type="button" aria-label="写真">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button class="lp__btn lp__btn--more" type="button" aria-label="通知">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button class="lp__btn lp__btn--more" type="button" aria-label="マイページ">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</div>
<button class="lp__toggle" type="button" id="lp-toggle" aria-expanded="false" aria-controls="lp-grid">すべてのメニュー</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
// 등록해야 자리·배율이 수로 보간된다 — 등록 안 하면 칸 사이를 못 건넌다
@property --x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --y {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --s {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
.lp {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-2;
}
.lp__grid {
position: relative;
width: 216px;
height: 128px;
border-radius: $r-card;
background: rgba(23, 20, 26, .22);
transition: background-color $duration $easing;
}
/* 펼친 동안 판이 한 단계 짙어진다 — 열렸다는 사실이 판 전체로 보인다 */
.lp.open .lp__grid { background: rgba(23, 20, 26, .46); }
.lp__btn {
--x: 0px;
--y: 0px;
--s: 1;
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 40px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-sm;
background: rgba(255, 255, 255, .16);
color: $color;
cursor: pointer;
translate: calc(-50% + var(--x)) calc(-50% + var(--y));
scale: var(--s);
transition: translate $duration $easing, scale $duration $easing, opacity $dur-instant $easing;
}
.lp__btn svg { width: 22px; height: 22px; display: block; }
.lp__btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
/* 접힌 상태 — 앞 세 개만 한 줄로 */
.lp__btn:nth-child(1) { --x: -52px; }
.lp__btn:nth-child(2) { --x: 0px; }
.lp__btn:nth-child(3) { --x: 52px; }
/* 숨은 넷은 제 빈 칸에서 배율 0 으로 기다린다 — 가운데서 날아오면 앞 세 개와 겹친다 */
.lp__btn:nth-child(4) { --x: -52px; --y: 0px; }
.lp__btn:nth-child(5) { --x: 0px; --y: 0px; }
.lp__btn:nth-child(6) { --x: 52px; --y: 0px; }
.lp__btn:nth-child(7) { --x: 0px; --y: 44px; }
.lp__btn--more { --s: 0; opacity: 0; pointer-events: none; }
/* 펼친 상태 — 앞 세 개가 윗줄로 흩어지고 숨은 넷이 제자리에서 커진다 */
.lp.open .lp__btn--more { --s: 1; opacity: 1; pointer-events: auto; }
.lp.open .lp__btn:nth-child(1) { --y: -44px; }
.lp.open .lp__btn:nth-child(2) { --y: -44px; }
.lp.open .lp__btn:nth-child(3) { --y: -44px; }
.lp__toggle {
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .82);
color: #fff;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
cursor: pointer;
}
.lp__toggle:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
/* 자동 재생 — 한 줄에서 격자로 흩어졌다 84% 에 한 줄로 되돌아온다 */
.is-demo .lp__btn {
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .lp__btn:nth-child(1) { animation-name: lp-c1; }
.is-demo .lp__btn:nth-child(2) { animation-name: lp-c2; }
.is-demo .lp__btn:nth-child(3) { animation-name: lp-c3; }
.is-demo .lp__grid {
animation-name: lp-pad;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.is-demo .lp__btn--more { animation-name: lp-grow; }
@keyframes lp-c1 {
0%, 12% { --y: 0px; }
36%, 72% { --y: -44px; }
84%, 100% { --y: 0px; }
}
@keyframes lp-c2 {
0%, 12% { --y: 0px; }
36%, 72% { --y: -44px; }
84%, 100% { --y: 0px; }
}
@keyframes lp-c3 {
0%, 12% { --y: 0px; }
36%, 72% { --y: -44px; }
84%, 100% { --y: 0px; }
}
/* 앞 세 개가 윗줄로 다 올라간 뒤(36%)에야 커지고, 내려오기 전(72%)에 줄어든다 — 겹칠 틈이 없다 */
@keyframes lp-grow {
0%, 36% { --s: 0; opacity: 0; }
36.01% { opacity: 1; }
44%, 60% { --s: 1; opacity: 1; }
71.99% { opacity: 1; }
72%, 100% { --s: 0; opacity: 0; }
}
@keyframes lp-pad {
0%, 43.99% { background-color: rgba(23, 20, 26, .22); }
44%, 60% { background-color: rgba(23, 20, 26, .46); }
72%, 100% { background-color: rgba(23, 20, 26, .22); }
}
@media (max-width: 340px) {
.lp__grid { width: 200px; height: 124px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .lp__btn,
.is-demo .lp__grid { animation-name: none; }
.lp__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --x {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --y {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --s {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
.lp {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.lp__grid {
position: relative;
width: 216px;
height: 128px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.22);
transition: background-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* 펼친 동안 판이 한 단계 짙어진다 — 열렸다는 사실이 판 전체로 보인다 */
.lp.open .lp__grid {
background: rgba(23, 20, 26, 0.46);
}
.lp__btn {
--x: 0px;
--y: 0px;
--s: 1;
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 40px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
color: #fff7e6;
cursor: pointer;
translate: calc(-50% + var(--x)) calc(-50% + var(--y));
scale: var(--s);
transition: translate 300ms cubic-bezier(0.2, 0.8, 0.2, 1), scale 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lp__btn svg {
width: 22px;
height: 22px;
display: block;
}
.lp__btn:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
/* 접힌 상태 — 앞 세 개만 한 줄로 */
.lp__btn:nth-child(1) {
--x: -52px;
}
.lp__btn:nth-child(2) {
--x: 0px;
}
.lp__btn:nth-child(3) {
--x: 52px;
}
/* 숨은 넷은 제 빈 칸에서 배율 0 으로 기다린다 — 가운데서 날아오면 앞 세 개와 겹친다 */
.lp__btn:nth-child(4) {
--x: -52px;
--y: 0px;
}
.lp__btn:nth-child(5) {
--x: 0px;
--y: 0px;
}
.lp__btn:nth-child(6) {
--x: 52px;
--y: 0px;
}
.lp__btn:nth-child(7) {
--x: 0px;
--y: 44px;
}
.lp__btn--more {
--s: 0;
opacity: 0;
pointer-events: none;
}
/* 펼친 상태 — 앞 세 개가 윗줄로 흩어지고 숨은 넷이 제자리에서 커진다 */
.lp.open .lp__btn--more {
--s: 1;
opacity: 1;
pointer-events: auto;
}
.lp.open .lp__btn:nth-child(1) {
--y: -44px;
}
.lp.open .lp__btn:nth-child(2) {
--y: -44px;
}
.lp.open .lp__btn:nth-child(3) {
--y: -44px;
}
.lp__toggle {
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.82);
color: #fff;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
cursor: pointer;
}
.lp__toggle:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
/* 자동 재생 — 한 줄에서 격자로 흩어졌다 84% 에 한 줄로 되돌아온다 */
.is-demo .lp__btn {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .lp__btn:nth-child(1) {
animation-name: lp-c1;
}
.is-demo .lp__btn:nth-child(2) {
animation-name: lp-c2;
}
.is-demo .lp__btn:nth-child(3) {
animation-name: lp-c3;
}
.is-demo .lp__grid {
animation-name: lp-pad;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.is-demo .lp__btn--more {
animation-name: lp-grow;
}
@keyframes lp-c1 {
0%, 12% {
--y: 0px;
}
36%, 72% {
--y: -44px;
}
84%, 100% {
--y: 0px;
}
}
@keyframes lp-c2 {
0%, 12% {
--y: 0px;
}
36%, 72% {
--y: -44px;
}
84%, 100% {
--y: 0px;
}
}
@keyframes lp-c3 {
0%, 12% {
--y: 0px;
}
36%, 72% {
--y: -44px;
}
84%, 100% {
--y: 0px;
}
}
/* 앞 세 개가 윗줄로 다 올라간 뒤(36%)에야 커지고, 내려오기 전(72%)에 줄어든다 — 겹칠 틈이 없다 */
@keyframes lp-grow {
0%, 36% {
--s: 0;
opacity: 0;
}
36.01% {
opacity: 1;
}
44%, 60% {
--s: 1;
opacity: 1;
}
71.99% {
opacity: 1;
}
72%, 100% {
--s: 0;
opacity: 0;
}
}
@keyframes lp-pad {
0%, 43.99% {
background-color: rgba(23, 20, 26, 0.22);
}
44%, 60% {
background-color: rgba(23, 20, 26, 0.46);
}
72%, 100% {
background-color: rgba(23, 20, 26, 0.22);
}
}
@media (max-width: 340px) {
.lp__grid {
width: 200px;
height: 124px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .lp__btn,
.is-demo .lp__grid {
animation-name: none;
}
.lp__btn {
transition: none;
}
}// 실제 동작: 버튼 누름 → .open 토글. 격자 열 수와 숨은 넷의 표시를 CSS 가 바꾼다.
var root = document.getElementById('lp');
var btn = document.getElementById('lp-toggle');
btn.addEventListener('click', function () {
root.classList.remove('is-demo');
var on = root.classList.toggle('open');
btn.setAttribute('aria-expanded', String(on));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LaunchpadGridDock.module.scss";
export type LaunchpadGridDockProps = {
/** 처음부터 펼친 채로 둘지 */
defaultOpen?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function LaunchpadGridDock({
defaultOpen = false,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: LaunchpadGridDockProps) {
const [open, setOpen] = useState(defaultOpen);
// aria-controls 가 가리킬 id — 한 화면에 여러 개를 놔도 겹치지 않게
const [gridId] = useState(() => "lp-grid-" + Math.random().toString(36).slice(2, 8));
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
// 접힌 동안 숨은 넷은 탭 순서에서도 빠진다 (CSS 의 pointer-events: none 과 짝)
const hidden = open ? 0 : -1;
return (
<div className={open ? `${styles.lp} ${styles.isOpen}` : styles.lp} style={vars}>
<div className={styles.grid} id={gridId}>
<button className={styles.btn} type="button" aria-label="Home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg>
</button>
<button className={styles.btn} type="button" aria-label="Files">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
</button>
<button className={`${styles.btn} ${styles.more}`} type="button" aria-label="Chat" tabIndex={hidden}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
</button>
<button className={`${styles.btn} ${styles.more}`} type="button" aria-label="Photos" tabIndex={hidden}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 40H40a16 16 0 0 0-16 16v144a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V56a16 16 0 0 0-16-16m0 16v102.75l-26.07-26.06a16 16 0 0 0-22.63 0l-20 20l-44-44a16 16 0 0 0-22.62 0L40 149.37V56ZM40 172l52-52l80 80H40Zm176 28h-21.37l-36-36l20-20L216 181.38zm-72-100a12 12 0 1 1 12 12a12 12 0 0 1-12-12"/></g></svg>
</button>
<button className={`${styles.btn} ${styles.more}`} type="button" aria-label="Alerts" tabIndex={hidden}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
</button>
<button className={`${styles.btn} ${styles.more}`} type="button" aria-label="Profile" tabIndex={hidden}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M192 96a64 64 0 1 1-64-64a64 64 0 0 1 64 64" opacity=".2"/><path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8M72 96a56 56 0 1 1 56 56a56.06 56.06 0 0 1-56-56"/></g></svg>
</button>
</div>
<button
className={styles.toggle}
type="button"
aria-expanded={open}
aria-controls={gridId}
onClick={() => setOpen(!open)}
>
All apps
</button>
</div>
);
}08 · ランチパッドグリッド展開 — 計測 累積面積 19.061% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
位置と倍率は、登録した3つのプロパティが受け持ちます。
.lp__btn {
position: absolute;
top: 50%;
left: 50%;
translate: calc(-50% + var(--x)) calc(-50% + var(--y));
scale: var(--s);
transition: translate $duration $easing, scale $duration $easing, opacity $dur-instant $easing;
}
.lp__btn--more { --s: 0; opacity: 0; pointer-events: none; }
.lp.open .lp__btn--more { --s: 1; opacity: 1; pointer-events: auto; }
.lp.open .lp__btn:nth-child(1) { --y: -44px; }
09縦サイドドック
どんな動きか
ドックを画面の左端に縦へ立てた板です。アクティブな点が1枠36pxずつ下へ降り、名前ラベルはアイコンの右へ押し出されます。

<main class="stage">
<div class="sr is-demo" id="sr" style="--row: 0">
<div class="sr__frame">
<nav class="sr__rail" aria-label="メインメニュー">
<i class="sr__dot" aria-hidden="true"></i>
<button class="sr__btn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span class="sr__label">ホーム</span>
</button>
<button class="sr__btn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
<span class="sr__label">ドキュメント</span>
</button>
<button class="sr__btn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
<span class="sr__label">トーク</span>
</button>
<button class="sr__btn" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span class="sr__label">通知</span>
</button>
</nav>
<div class="sr__body" aria-hidden="true">
<span class="sr__line"></span>
<span class="sr__line sr__line--short"></span>
<span class="sr__line"></span>
<span class="sr__line sr__line--short"></span>
</div>
</div>
<p class="sr__cap">名前ラベルがアイコンの右へ押し出されます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
@property --row {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.sr {
--row: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
}
.sr__frame {
position: relative;
display: flex;
width: min(300px, 88vw);
height: 152px;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .06);
}
.sr__rail {
position: relative;
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: $sp-1;
padding: $sp-2;
background: rgba(255, 255, 255, .06);
}
.sr__dot {
position: absolute;
top: 20px;
left: 2px;
width: 3px;
height: 16px;
border-radius: $r-pill;
background: $color;
// 한 칸 = 버튼 32px + gap 4px = 36px
transform: translateY(calc(var(--row) * 36px));
transition: transform $duration $easing;
}
.sr__btn {
position: relative;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: $r-sm;
background: none;
color: rgba(255, 255, 255, .55);
cursor: pointer;
transition: color $duration $easing;
}
.sr__btn svg { width: 20px; height: 20px; display: block; }
.sr__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sr__btn[aria-current="page"] { color: #fff; }
.sr__label {
position: absolute;
top: 50%;
left: calc(100% + #{$sp-1});
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: $color;
color: #10231c;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translateY(-50%) translateX(-6px);
transition: transform $duration $easing, opacity $duration $easing;
}
.sr__btn:hover .sr__label,
.sr__btn:focus-visible .sr__label { opacity: 1; transform: translateY(-50%) translateX(0); }
.sr__body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: $sp-3;
padding: $sp-4;
}
.sr__line {
height: 8px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .12);
}
.sr__line--short { width: 60%; }
.sr__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, .62);
}
/* 자동 재생 — 이름표가 위에서부터 차례로 밀려 나오고 점이 따라 내려간다 */
.is-demo .sr__label {
animation-name: sr-slide;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .sr__btn:nth-of-type(2) .sr__label { animation-delay: .4s; }
.is-demo .sr__btn:nth-of-type(3) .sr__label { animation-delay: .8s; }
.is-demo .sr__btn:nth-of-type(4) .sr__label { animation-delay: 1.2s; }
.is-demo .sr__dot {
animation-name: sr-dot;
animation-duration: 2s;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.is-demo .sr__btn {
animation-name: sr-lit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .sr__btn:nth-of-type(2) { animation-delay: .4s; }
.is-demo .sr__btn:nth-of-type(3) { animation-delay: .8s; }
.is-demo .sr__btn:nth-of-type(4) { animation-delay: 1.2s; }
@keyframes sr-slide {
0% { opacity: 0; transform: translateY(-50%) translateX(-6px); }
6% { opacity: 1; transform: translateY(-50%) translateX(0); }
16% { opacity: 1; transform: translateY(-50%) translateX(0); }
20% { opacity: 0; transform: translateY(-50%) translateX(-6px); }
100% { opacity: 0; transform: translateY(-50%) translateX(-6px); }
}
@keyframes sr-dot {
0%, 16% { transform: translateY(0); }
20%, 36% { transform: translateY(36px); }
40%, 56% { transform: translateY(72px); }
60%, 76% { transform: translateY(108px); }
88%, 100% { transform: translateY(0); }
}
@keyframes sr-lit {
0% { color: rgba(255, 255, 255, .55); }
6% { color: #fff; }
20% { color: rgba(255, 255, 255, .55); }
100% { color: rgba(255, 255, 255, .55); }
}
@media (max-width: 340px) {
.sr__frame { height: 128px; }
.sr__body { padding: $sp-3; gap: $sp-2; }
.sr__cap { font-size: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sr__label,
.is-demo .sr__dot,
.is-demo .sr__btn { animation-name: none; }
.sr__label,
.sr__dot,
.sr__btn { transition: none; }
}@charset "UTF-8";
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --row {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.sr {
--row: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.sr__frame {
position: relative;
display: flex;
width: min(300px, 88vw);
height: 152px;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
}
.sr__rail {
position: relative;
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
background: rgba(255, 255, 255, 0.06);
}
.sr__dot {
position: absolute;
top: 20px;
left: 2px;
width: 3px;
height: 16px;
border-radius: 999px;
background: #4cd4a6;
transform: translateY(calc(var(--row) * 36px));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr__btn {
position: relative;
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr__btn svg {
width: 20px;
height: 20px;
display: block;
}
.sr__btn:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
.sr__btn[aria-current=page] {
color: #fff;
}
.sr__label {
position: absolute;
top: 50%;
left: calc(100% + 4px);
padding: 4px 8px;
border-radius: 4px;
background: #4cd4a6;
color: #10231c;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transform: translateY(-50%) translateX(-6px);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sr__btn:hover .sr__label,
.sr__btn:focus-visible .sr__label {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
.sr__body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
padding: 16px;
}
.sr__line {
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
.sr__line--short {
width: 60%;
}
.sr__cap {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.62);
}
/* 자동 재생 — 이름표가 위에서부터 차례로 밀려 나오고 점이 따라 내려간다 */
.is-demo .sr__label {
animation-name: sr-slide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .sr__btn:nth-of-type(2) .sr__label {
animation-delay: 0.4s;
}
.is-demo .sr__btn:nth-of-type(3) .sr__label {
animation-delay: 0.8s;
}
.is-demo .sr__btn:nth-of-type(4) .sr__label {
animation-delay: 1.2s;
}
.is-demo .sr__dot {
animation-name: sr-dot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.is-demo .sr__btn {
animation-name: sr-lit;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.is-demo .sr__btn:nth-of-type(2) {
animation-delay: 0.4s;
}
.is-demo .sr__btn:nth-of-type(3) {
animation-delay: 0.8s;
}
.is-demo .sr__btn:nth-of-type(4) {
animation-delay: 1.2s;
}
@keyframes sr-slide {
0% {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
}
6% {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
16% {
opacity: 1;
transform: translateY(-50%) translateX(0);
}
20% {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
}
100% {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
}
}
@keyframes sr-dot {
0%, 16% {
transform: translateY(0);
}
20%, 36% {
transform: translateY(36px);
}
40%, 56% {
transform: translateY(72px);
}
60%, 76% {
transform: translateY(108px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes sr-lit {
0% {
color: rgba(255, 255, 255, 0.55);
}
6% {
color: #fff;
}
20% {
color: rgba(255, 255, 255, 0.55);
}
100% {
color: rgba(255, 255, 255, 0.55);
}
}
@media (max-width: 340px) {
.sr__frame {
height: 128px;
}
.sr__body {
padding: 12px;
gap: 8px;
}
.sr__cap {
font-size: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.is-demo .sr__label,
.is-demo .sr__dot,
.is-demo .sr__btn {
animation-name: none;
}
.sr__label,
.sr__dot,
.sr__btn {
transition: none;
}
}// 실제 동작: 클릭 → --row 교체(활성 점이 같은 간격씩 내려간다). 호버·포커스는 CSS 가 이름표를 민다.
var root = document.getElementById('sr');
var btns = Array.prototype.slice.call(root.querySelectorAll('.sr__btn'));
btns[0].setAttribute('aria-current', 'page');
btns.forEach(function (b, i) {
b.addEventListener('pointerenter', function () { root.classList.remove('is-demo'); });
b.addEventListener('click', function () {
root.classList.remove('is-demo');
root.style.setProperty('--row', i);
btns.forEach(function (x) { x.removeAttribute('aria-current'); });
b.setAttribute('aria-current', 'page');
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SideRailDock.module.scss";
export type SideRailDockProps = {
/** 처음 켜져 있는 줄 (0부터) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--row"?: string; "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SideRailDock({
start = 0,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#4cd4a6",
}: SideRailDockProps) {
// 활성 줄 번호. 점은 이 수 × 36px 만큼 내려간다 (버튼 32px + gap 4px)
const [row, setRow] = useState(start);
const vars: Vars = {
"--row": `${row}`,
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
};
return (
<div className={styles.sr} style={vars}>
<div className={styles.frame}>
<nav className={styles.rail} aria-label="Main menu">
<i className={styles.dot} aria-hidden="true" />
<button
className={styles.btn}
type="button"
aria-current={row === 0 ? "page" : undefined}
onClick={() => setRow(0)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span className={styles.label}>Home</span>
</button>
<button
className={styles.btn}
type="button"
aria-current={row === 1 ? "page" : undefined}
onClick={() => setRow(1)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg>
<span className={styles.label}>Files</span>
</button>
<button
className={styles.btn}
type="button"
aria-current={row === 2 ? "page" : undefined}
onClick={() => setRow(2)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 128a96 96 0 0 1-144.07 83.11l-37.39 12.47a8 8 0 0 1-10.12-10.12l12.47-37.39A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24m0 192a87.87 87.87 0 0 1-44.06-11.81a8 8 0 0 0-6.54-.67L40 216l12.47-37.4a8 8 0 0 0-.66-6.54A88 88 0 1 1 128 216"/></g></svg>
<span className={styles.label}>Chat</span>
</button>
<button
className={styles.btn}
type="button"
aria-current={row === 3 ? "page" : undefined}
onClick={() => setRow(3)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 192H48a8 8 0 0 1-6.88-12C47.71 168.6 56 139.81 56 104a72 72 0 0 1 144 0c0 35.82 8.3 64.6 14.9 76a8 8 0 0 1-6.9 12" opacity=".2"/><path d="M221.8 175.94c-5.55-9.56-13.8-36.61-13.8-71.94a80 80 0 1 0-160 0c0 35.34-8.26 62.38-13.81 71.94A16 16 0 0 0 48 200h40.81a40 40 0 0 0 78.38 0H208a16 16 0 0 0 13.8-24.06M128 216a24 24 0 0 1-22.62-16h45.24A24 24 0 0 1 128 216m-80-32c7.7-13.24 16-43.92 16-80a64 64 0 1 1 128 0c0 36.05 8.28 66.73 16 80Z"/></g></svg>
<span className={styles.label}>Alerts</span>
</button>
</nav>
<div className={styles.body} aria-hidden="true">
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineShort}`} />
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineShort}`} />
</div>
</div>
<p className={styles.cap}>The name tag slides out to the right of the icon</p>
</div>
);
}09 · 縦サイドドック — 計測 累積面積 3.178% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
縦の中央揃えと押し出しがどちらも transform なので、1つの transform に順番どおり書かないと、後ろの指定が前の指定を打ち消してしまいます。
.sr__dot {
position: absolute;
width: 3px;
height: 16px;
background: $color;
transform: translateY(calc(var(--row) * 36px));
transition: transform $duration $easing;
}
.sr__label {
opacity: 0;
transform: translateY(-50%) translateX(-6px);
transition: transform $duration $easing, opacity $duration $easing;
}
.sr__btn:hover .sr__label { opacity: 1; transform: translateY(-50%) translateX(0); }
どこで壊れるか — 落とし穴
9つを組み上げるあいだ、いちばん手を入れ直したのはアニメーションそのものではなく、2つの要素が同じ場所を通り過ぎる一瞬でした。08番は当初、隠していた4つのアイコンをドックの真ん中からそれぞれの枠へ飛ばしていました。コードを読むかぎり何もおかしくないのに、代表画像を書き出してみると、虫めがねのアイコンの上に人のアイコンが半分乗ったコマが出てきたのです。手前の3つが上段へ上がっていく経路と、隠していた4つが飛んでくる経路が交差していたためで、2つの集団が同じ地点を別々の時刻に通るのではなく同時に通っていました。隠していた4つを最初から自分の空き枠に置いて倍率0のまま待たせ、手前の3つが上がりきってから大きくなるよう順番を分けると、重なりは出なくなりました。
03番は、同じ病気が別の顔で出たものでした。ラベルをだんだん薄くして消すようにしたところ、ポインターが隣のアイコンへ移るあいだラベルが2枚半透明で重なり、どちらの文字も読めませんでした。消える側を steps(1, end) で1フレームに切ってしまうと、その区間ごとなくなります。06番は別の理由で2度直しています。背景もボタンも影もすべて同じ色系統なので押した印が目に入らず、暗いほうの影を1段濃くして押されたアイコンの色まで変えたら、変わった画素の平均差が19.5から22.8へ上がりました。3つとも機械の検査には一度も引っかからず、人が見たときにだけ「ときどき妙なコマが混じる」と感じる種類のものです。
| 症状 | 本当の原因 | 直す場所 |
|---|---|---|
| アイコンが2つ重なったコマが撮れる | 2つの集団の移動経路が同じ時刻に交差している | 片方をその場で大きくし、順番を分ける |
| 名前ラベルが2枚、半透明で重なる | 消え方がフェードになっている | opacity を steps(1, end) で断つ |
| アクティブの印がアイコンから横へずれる | ボタンが均等配分で実寸が固定値と食い違う | ボタンに flex: 0 0 auto と固定幅 |
| 印が枠と枠のあいだをぱっと飛び越える | 数値プロパティが未登録で中間値が作られない | @property で syntax を宣言する |
画面のどこを見ているかを示す帯はスクロール インジケーターが同じ問いを上からもう一度答えます。この表の4行をすべて反映した9本のソースは、パスワード wd6vwhyu を入れると開く zip に収めてあり、バニラ版と React 版が同じ3つの変数を読みます。
アクセシビリティ
選ばれている状態を持つ6つは、それをクラスではなく属性に持たせました。01・02・05・09番は aria-current="page"、06番は aria-pressed、08番の開閉ボタンは aria-expanded と aria-controls を使います。このうち02・05・06・09番は CSS もその属性セレクタで描いているので、見えているものと伝わるものが食い違いようがありません。01番だけは aria-current を付けたうえで、下線を伸ばすほうは .mn__btn.on というクラスセレクタが受け持っています。03番のラベルは aria-hidden="true" にして、代わりに視覚的に隠したテキストをボタンの中へ入れてあります。同じ言葉が二重に読み上げられるのを避けるためです。ボタンの名前の与え方は2通りあり、01・02・04・05・06・07・08番はアイコンだけのボタンに aria-label を付け、03・09番はボタンの内側にあるテキスト(03番は視覚的に隠した .tt__sr、09番は右へ押し出される .sr__label)がそのまま名前になります。どのボタンも :focus-visible で輪郭を残すので、キーボードでたどっている最中も現在位置が見えます。アイコンはすべて Phosphor Icons のデュオトーン SVG をインラインで埋め込んだもので、フォントも CDN も呼びません。prefers-reduced-motion: reduce のときは自動再生のループと、滑り・跳ね上がりの動きだけを止め、アクティブの印そのものは残します。どの枠にいるかは演出ではなく情報だからです。現在地を支援技術がどう読むかは MDN の aria-current の解説にまとまっています。
同じ系統をもっと探すなら、ホバーのカテゴリとランディングページのカテゴリに、組み合わせやすい部品が集まっています。
FAQ
「フローティングメニュー スマホ」でアクティブの印を固定pxで動かすと、画面幅が変わったときにずれませんか
ずれます。ただし、ずれる原因は画面幅ではなくボタンの幅が可変であることのほうです。02番のように translateX(calc(var(--idx) * 56px)) で運ぶなら、ボタンは flex: 0 0 auto と固定幅でなければならず、そうしておけば画面が狭くなっても1枠は56pxのままです。逆に flex: 1 1 0 で余った幅を分け合う設定にすると、実際に描かれる幅が毎回変わるのでカプセルが隣の枠にはみ出します。狭い画面で縮めたいときは、メディアクエリでボタン幅と間隔、そして1枠の値を3つそろえて書き換えてください。ドック上の顔を大きく見せたいならプロフィールカード デザインのカードがその列を埋めます。
アイコンが大きくなるとき、隣を押し退ける計算はなぜ必要なのですか
倍率だけを与えるとアイコンどうしが重なるからです。scale は要素が占める場所を変えずに描かれる大きさだけを変えるので、1.8倍に膨らんだアイコンは自分の枠をはみ出して両隣の上へかぶさります。04番が各アイコンの増えた幅を手前から積み上げて移動量に配っているのはこのためで、最後に増加分の合計の半分を引いているのは、行全体が右へずれていかないようにするためです。
曇りガラスのドックが背景によってのっぺり見えます
backdrop-filter は後ろにある絵をぼかすフィルタなので、後ろが単色だと何も起きません。05番がドックの裏に色のにじみを2つ敷いたのも、07番が色の帯をバッジの下へ通したのも、どちらもそれが理由です。背景が真っ白な画面なら、ガラスに頼らず、ごく薄い影と1pxの枠線で浮いている感じを作ったほうがきれいにまとまります。









