ダッシュボード ウィジェット単体UI9選 —そのまま貼れる部品だけ
管理画面をまるごと使うのではなく、この部品だけを自分のプロジェクトにそのまま貼りたい人のために、単体で動くダッシュボード ウィジェットを9個集めました。ドーナツチャートは実際に数字が数え上がります。
01 sidebar
02 stat-cards
03 table
04 chart-card
05 filters
06 empty-state
07 settings-form
08 notifications-panel
09 user-menu
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 単体サイドバーナビ
- 02 スパークライン表示の統計カード
- 03 並び替え可能なミニテーブル
- 04 中央カウンター付きドーナツチャート
- 05 チップ型フィルターバー
- 06 追加モーフ付き空状態イラスト
- 07 保存状態付き設定フォームセクション
- 08 通知ドロップダウンパネル
- 09 ユーザーアバタードロップダウン
9個は人気順ではなく、ダッシュボード デザイン 参考として画面に入ってくる順番で並べました。まず骨格になるサイドバー、次に一目で把握できる要約(統計カード・表・チャート)、続いて実際に触る部分(フィルター・空の状態・設定)、最後にヘッダーの隅に付く小さなポップアップ2つ(通知・ユーザーメニュー)です。どのデモも完成した管理画面ではなく、その部品ひとつだけを画面中央に置いています。同じダッシュボード部品を統計カード中心に組み直したのがダッシュボード コンポーネント9選で、部品をブログに埋め込む用途に振ったのがシェアボタンなどの埋め込みウィジェットです。
01単体サイドバーナビ
何が動くか
折りたたみボタンを押すと、サイドバーの幅が実際に208pxから76pxへ縮み、文字ラベルが消えてアイコンだけが残ります。

<main class="stage">
<nav class="sd is-demo" aria-label="メインメニュー">
<div class="sd__brand"><span class="sd__mark" aria-hidden="true"></span><span class="sd__brand-text">Studio</span></div>
<ul class="sd__list">
<li class="sd__item is-active"><a class="sd__link" href="#"><span class="sd__ic" aria-hidden="true"><svg viewBox="0 0 256 256"><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><span class="sd__label">ホーム</span></a></li>
<li class="sd__item"><a class="sd__link" href="#"><span class="sd__ic" aria-hidden="true"><svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M208 88v24H69.77a8 8 0 0 0-7.59 5.47L32 208V64a8 8 0 0 1 8-8h53.33a8 8 0 0 1 4.8 1.6L128 80h72a8 8 0 0 1 8 8" opacity=".2"/><path d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z"/></g></svg></span><span class="sd__label">プロジェクト</span></a></li>
<li class="sd__item"><a class="sd__link" href="#"><span class="sd__ic" aria-hidden="true"><svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 128a95.76 95.76 0 0 1-31.8 71.37A72 72 0 0 0 128 160a40 40 0 1 0-40-40a40 40 0 0 0 40 40a72 72 0 0 0-64.2 39.37A96 96 0 1 1 224 128" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24M74.08 197.5a64 64 0 0 1 107.84 0a87.83 87.83 0 0 1-107.84 0M96 120a32 32 0 1 1 32 32a32 32 0 0 1-32-32m97.76 66.41a79.66 79.66 0 0 0-36.06-28.75a48 48 0 1 0-59.4 0a79.66 79.66 0 0 0-36.06 28.75a88 88 0 1 1 131.52 0"/></g></svg></span><span class="sd__label">チーム</span></a></li>
<li class="sd__item"><a class="sd__link" href="#"><span class="sd__ic" aria-hidden="true"><svg viewBox="0 0 256 256"><g fill="currentColor"><path d="m230.1 108.76l-31.85-18.14c-.64-1.16-1.31-2.29-2-3.41l-.12-36A104.6 104.6 0 0 0 162 32l-32 17.89h-4L94 32a104.6 104.6 0 0 0-34.11 19.25l-.16 36c-.7 1.12-1.37 2.26-2 3.41l-31.84 18.1a99.2 99.2 0 0 0 0 38.46l31.85 18.14c.64 1.16 1.31 2.29 2 3.41l.12 36A104.6 104.6 0 0 0 94 224l32-17.87h4L162 224a104.6 104.6 0 0 0 34.08-19.25l.16-36c.7-1.12 1.37-2.26 2-3.41l31.84-18.1a99.2 99.2 0 0 0 .02-38.48M128 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 32m109.94-52.79a8 8 0 0 0-3.89-5.4l-29.83-17l-.12-33.62a8 8 0 0 0-2.83-6.08a111.9 111.9 0 0 0-36.72-20.67a8 8 0 0 0-6.46.59L128 41.85L97.88 25a8 8 0 0 0-6.47-.6a111.9 111.9 0 0 0-36.68 20.75a8 8 0 0 0-2.83 6.07l-.15 33.65l-29.83 17a8 8 0 0 0-3.89 5.4a106.5 106.5 0 0 0 0 41.56a8 8 0 0 0 3.89 5.4l29.83 17l.12 33.63a8 8 0 0 0 2.83 6.08a111.9 111.9 0 0 0 36.72 20.67a8 8 0 0 0 6.46-.59L128 214.15L158.12 231a7.9 7.9 0 0 0 3.9 1a8.1 8.1 0 0 0 2.57-.42a112.1 112.1 0 0 0 36.68-20.73a8 8 0 0 0 2.83-6.07l.15-33.65l29.83-17a8 8 0 0 0 3.89-5.4a106.5 106.5 0 0 0-.03-41.52m-15 34.91l-28.57 16.25a8 8 0 0 0-3 3c-.58 1-1.19 2.06-1.81 3.06a7.94 7.94 0 0 0-1.22 4.21l-.15 32.25a95.9 95.9 0 0 1-25.37 14.3L134 199.13a8 8 0 0 0-3.91-1h-3.83a8.1 8.1 0 0 0-4.1 1l-28.84 16.1A96 96 0 0 1 67.88 201l-.11-32.2a8 8 0 0 0-1.22-4.22c-.62-1-1.23-2-1.8-3.06a8.1 8.1 0 0 0-3-3.06l-28.6-16.29a90.5 90.5 0 0 1 0-28.26l28.52-16.28a8 8 0 0 0 3-3c.58-1 1.19-2.06 1.81-3.06a7.94 7.94 0 0 0 1.22-4.21l.15-32.25a95.9 95.9 0 0 1 25.37-14.3L122 56.87a8 8 0 0 0 4.1 1h3.64a8 8 0 0 0 4.1-1l28.84-16.1A96 96 0 0 1 188.12 55l.11 32.2a8 8 0 0 0 1.22 4.22c.62 1 1.23 2 1.8 3.06a8.1 8.1 0 0 0 3 3.06l28.6 16.29a90.5 90.5 0 0 1 .05 28.29Z"/></g></svg></span><span class="sd__label">設定</span></a></li>
</ul>
<button class="sd__toggle" type="button" aria-label="サイドバーの折りたたみ/展開" aria-pressed="false">
<span class="sd__toggle-ic" aria-hidden="true"><svg viewBox="0 0 256 256"><g fill="currentColor"><path d="M160 48v160l-80-80Z" opacity=".2"/><path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z"/></g></svg></span>
</button>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.sd {
box-sizing: border-box;
width: 208px;
max-width: 78vw;
height: 268px;
max-height: 84vh;
display: flex;
flex-direction: column;
border-radius: $r-card;
padding: $sp-4 $sp-3;
background: rgba(255, 247, 230, .04);
border: 1px solid rgba(255, 247, 230, .12);
overflow: hidden;
transition: width $duration $easing;
}
.sd.is-collapsed { width: 76px; }
.sd__brand {
display: flex; align-items: center; gap: $sp-2;
margin-bottom: $sp-5;
overflow: hidden;
}
.sd__mark {
flex: 0 0 auto;
width: $sp-5; height: $sp-5;
border-radius: $r-xs;
background: $color;
}
.sd__brand-text {
font-weight: 800; font-size: 13px; line-height: 1; font-family: inherit;
color: $subject-cream;
white-space: nowrap;
transition: opacity $duration $easing;
}
.sd.is-collapsed .sd__brand-text { opacity: 0; }
.sd__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-1; flex: 1; }
.sd__item { position: relative; border-radius: $r-control; }
.sd__link {
position: relative;
display: flex; align-items: center; gap: $sp-3;
padding: $sp-2 $sp-3;
border-radius: $r-control;
text-decoration: none;
color: rgba(255, 247, 230, .62);
overflow: hidden;
transition: background $duration $easing, color $duration $easing;
}
.sd__item.is-active .sd__link { background: rgba(255, 247, 230, .1); color: $subject-cream; }
.sd__item.is-active .sd__link::before {
content: "";
position: absolute; left: 0; top: 18%; bottom: 18%;
width: 3px; border-radius: $r-xs;
background: $color;
}
.sd__ic { flex: 0 0 auto; width: $sp-4; height: $sp-4; opacity: .8; }
.sd__ic svg { display: block; width: 100%; height: 100%; }
.sd__label { font-weight: 600; font-size: 12px; line-height: 1; font-family: inherit; white-space: nowrap; transition: opacity $duration $easing; }
.sd.is-collapsed .sd__label { opacity: 0; }
.sd__toggle {
flex: 0 0 auto;
align-self: flex-end;
width: $sp-6; height: $sp-6;
margin-top: $sp-3;
border-radius: $r-pill;
border: 1px solid rgba(255, 247, 230, .2);
background: transparent;
cursor: pointer;
display: grid; place-items: center;
}
.sd__toggle-ic {
width: 14px; height: 14px;
color: $subject-cream; // Phosphor duotone(caret-left): g fill="currentColor" 가 이 값을 물려받는다
transform: rotate(0deg);
transition: transform $duration $easing;
}
.sd__toggle-ic svg { display: block; width: 100%; height: 100%; }
.sd.is-collapsed ~ .sd__toggle .sd__toggle-ic,
.sd.is-collapsed .sd__toggle-ic { transform: rotate(180deg); }
// ── 자동 루프(무조건 재생): 펼침 ↔ 접힘을 2초 주기로 오간다 ──────────────
.sd.is-demo { animation: sd-breathe 2s ease-in-out infinite; }
.sd.is-demo .sd__brand-text,
.sd.is-demo .sd__label { animation: sd-fade 2s ease-in-out infinite; }
.sd.is-demo .sd__toggle-ic { animation: sd-rotate 2s ease-in-out infinite; }
@keyframes sd-breathe {
0%, 20%, 100% { width: 208px; }
50%, 70% { width: 76px; }
}
@keyframes sd-fade {
0%, 20%, 100% { opacity: 1; }
50%, 70% { opacity: 0; }
}
@keyframes sd-rotate {
0%, 20%, 100% { transform: rotate(0deg); }
50%, 70% { transform: rotate(180deg); }
}
@media (max-width: 340px), (max-height: 220px) {
.sd { width: 168px; padding: $sp-3 $sp-2; }
.sd__brand { margin-bottom: $sp-3; }
.sd.is-demo { animation-duration: 2s; }
}
@media (prefers-reduced-motion: reduce) {
.sd.is-demo,
.sd.is-demo .sd__brand-text,
.sd.is-demo .sd__label,
.sd.is-demo .sd__toggle-ic { animation: none !important; }
.sd, .sd__brand-text, .sd__label, .sd__toggle-ic { transition: none; }
}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);
}
.sd {
box-sizing: border-box;
width: 208px;
max-width: 78vw;
height: 268px;
max-height: 84vh;
display: flex;
flex-direction: column;
border-radius: 16px;
padding: 16px 12px;
background: rgba(255, 247, 230, 0.04);
border: 1px solid rgba(255, 247, 230, 0.12);
overflow: hidden;
transition: width 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sd.is-collapsed {
width: 76px;
}
.sd__brand {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
overflow: hidden;
}
.sd__mark {
flex: 0 0 auto;
width: 20px;
height: 20px;
border-radius: 4px;
background: #2f6df6;
}
.sd__brand-text {
font-weight: 800;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #fff7e6;
white-space: nowrap;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sd.is-collapsed .sd__brand-text {
opacity: 0;
}
.sd__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.sd__item {
position: relative;
border-radius: 12px;
}
.sd__link {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 12px;
text-decoration: none;
color: rgba(255, 247, 230, 0.62);
overflow: hidden;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sd__item.is-active .sd__link {
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
}
.sd__item.is-active .sd__link::before {
content: "";
position: absolute;
left: 0;
top: 18%;
bottom: 18%;
width: 3px;
border-radius: 4px;
background: #2f6df6;
}
.sd__ic {
flex: 0 0 auto;
width: 16px;
height: 16px;
opacity: 0.8;
}
.sd__ic svg {
display: block;
width: 100%;
height: 100%;
}
.sd__label {
font-weight: 600;
font-size: 12px;
line-height: 1;
font-family: inherit;
white-space: nowrap;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sd.is-collapsed .sd__label {
opacity: 0;
}
.sd__toggle {
flex: 0 0 auto;
align-self: flex-end;
width: 24px;
height: 24px;
margin-top: 12px;
border-radius: 999px;
border: 1px solid rgba(255, 247, 230, 0.2);
background: transparent;
cursor: pointer;
display: grid;
place-items: center;
}
.sd__toggle-ic {
width: 14px;
height: 14px;
color: #fff7e6;
transform: rotate(0deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sd__toggle-ic svg {
display: block;
width: 100%;
height: 100%;
}
.sd.is-collapsed ~ .sd__toggle .sd__toggle-ic,
.sd.is-collapsed .sd__toggle-ic {
transform: rotate(180deg);
}
.sd.is-demo {
animation: sd-breathe 2s ease-in-out infinite;
}
.sd.is-demo .sd__brand-text,
.sd.is-demo .sd__label {
animation: sd-fade 2s ease-in-out infinite;
}
.sd.is-demo .sd__toggle-ic {
animation: sd-rotate 2s ease-in-out infinite;
}
@keyframes sd-breathe {
0%, 20%, 100% {
width: 208px;
}
50%, 70% {
width: 76px;
}
}
@keyframes sd-fade {
0%, 20%, 100% {
opacity: 1;
}
50%, 70% {
opacity: 0;
}
}
@keyframes sd-rotate {
0%, 20%, 100% {
transform: rotate(0deg);
}
50%, 70% {
transform: rotate(180deg);
}
}
@media (max-width: 340px), (max-height: 220px) {
.sd {
width: 168px;
padding: 12px 8px;
}
.sd__brand {
margin-bottom: 12px;
}
.sd.is-demo {
animation-duration: 2s;
}
}
@media (prefers-reduced-motion: reduce) {
.sd.is-demo,
.sd.is-demo .sd__brand-text,
.sd.is-demo .sd__label,
.sd.is-demo .sd__toggle-ic {
animation: none !important;
}
.sd, .sd__brand-text, .sd__label, .sd__toggle-ic {
transition: none;
}
}// 실제 동작: 버튼을 누르면 사이드바 폭이 실제로 줄어들고 라벨이 사라진다. 첫 클릭에 자동 루프(is-demo)를 멈춘다.
var nav = document.querySelector('.sd');
var btn = document.querySelector('.sd__toggle');
btn.addEventListener('click', function () {
nav.classList.remove('is-demo');
var collapsed = nav.classList.toggle('is-collapsed');
btn.setAttribute('aria-pressed', String(collapsed));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./Sidebar.module.scss";
export type SidebarProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ITEMS = ["홈", "프로젝트", "팀", "설정"];
// Phosphor Duotone(MIT) — house · folder-open · user-circle · gear-six, ITEMS 와 같은 순서
const ICONS = [
<g key="house" 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>,
<g key="folder" fill="currentColor"><path d="M208 88v24H69.77a8 8 0 0 0-7.59 5.47L32 208V64a8 8 0 0 1 8-8h53.33a8 8 0 0 1 4.8 1.6L128 80h72a8 8 0 0 1 8 8" opacity=".2" /><path d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z" /></g>,
<g key="user" fill="currentColor"><path d="M224 128a95.76 95.76 0 0 1-31.8 71.37A72 72 0 0 0 128 160a40 40 0 1 0-40-40a40 40 0 0 0 40 40a72 72 0 0 0-64.2 39.37A96 96 0 1 1 224 128" opacity=".2" /><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24M74.08 197.5a64 64 0 0 1 107.84 0a87.83 87.83 0 0 1-107.84 0M96 120a32 32 0 1 1 32 32a32 32 0 0 1-32-32m97.76 66.41a79.66 79.66 0 0 0-36.06-28.75a48 48 0 1 0-59.4 0a79.66 79.66 0 0 0-36.06 28.75a88 88 0 1 1 131.52 0" /></g>,
<g key="gear" fill="currentColor"><path d="m230.1 108.76l-31.85-18.14c-.64-1.16-1.31-2.29-2-3.41l-.12-36A104.6 104.6 0 0 0 162 32l-32 17.89h-4L94 32a104.6 104.6 0 0 0-34.11 19.25l-.16 36c-.7 1.12-1.37 2.26-2 3.41l-31.84 18.1a99.2 99.2 0 0 0 0 38.46l31.85 18.14c.64 1.16 1.31 2.29 2 3.41l.12 36A104.6 104.6 0 0 0 94 224l32-17.87h4L162 224a104.6 104.6 0 0 0 34.08-19.25l.16-36c.7-1.12 1.37-2.26 2-3.41l31.84-18.1a99.2 99.2 0 0 0 .02-38.48M128 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 32m109.94-52.79a8 8 0 0 0-3.89-5.4l-29.83-17l-.12-33.62a8 8 0 0 0-2.83-6.08a111.9 111.9 0 0 0-36.72-20.67a8 8 0 0 0-6.46.59L128 41.85L97.88 25a8 8 0 0 0-6.47-.6a111.9 111.9 0 0 0-36.68 20.75a8 8 0 0 0-2.83 6.07l-.15 33.65l-29.83 17a8 8 0 0 0-3.89 5.4a106.5 106.5 0 0 0 0 41.56a8 8 0 0 0 3.89 5.4l29.83 17l.12 33.63a8 8 0 0 0 2.83 6.08a111.9 111.9 0 0 0 36.72 20.67a8 8 0 0 0 6.46-.59L128 214.15L158.12 231a7.9 7.9 0 0 0 3.9 1a8.1 8.1 0 0 0 2.57-.42a112.1 112.1 0 0 0 36.68-20.73a8 8 0 0 0 2.83-6.07l.15-33.65l29.83-17a8 8 0 0 0 3.89-5.4a106.5 106.5 0 0 0-.03-41.52m-15 34.91l-28.57 16.25a8 8 0 0 0-3 3c-.58 1-1.19 2.06-1.81 3.06a7.94 7.94 0 0 0-1.22 4.21l-.15 32.25a95.9 95.9 0 0 1-25.37 14.3L134 199.13a8 8 0 0 0-3.91-1h-3.83a8.1 8.1 0 0 0-4.1 1l-28.84 16.1A96 96 0 0 1 67.88 201l-.11-32.2a8 8 0 0 0-1.22-4.22c-.62-1-1.23-2-1.8-3.06a8.1 8.1 0 0 0-3-3.06l-28.6-16.29a90.5 90.5 0 0 1 0-28.26l28.52-16.28a8 8 0 0 0 3-3c.58-1 1.19-2.06 1.81-3.06a7.94 7.94 0 0 0 1.22-4.21l.15-32.25a95.9 95.9 0 0 1 25.37-14.3L122 56.87a8 8 0 0 0 4.1 1h3.64a8 8 0 0 0 4.1-1l28.84-16.1A96 96 0 0 1 188.12 55l.11 32.2a8 8 0 0 0 1.22 4.22c.62 1 1.23 2 1.8 3.06a8.1 8.1 0 0 0 3 3.06l28.6 16.29a90.5 90.5 0 0 1 .05 28.29Z" /></g>,
];
const CARET_LEFT = (
<g fill="currentColor"><path d="M160 48v160l-80-80Z" opacity=".2" /><path d="M163.06 40.61a8 8 0 0 0-8.72 1.73l-80 80a8 8 0 0 0 0 11.32l80 80A8 8 0 0 0 168 208V48a8 8 0 0 0-4.94-7.39M152 188.69L91.31 128L152 67.31Z" /></g>
);
export default function Sidebar({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false);
const [active, setActive] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<nav className={`${styles.sd} ${collapsed ? styles.collapsed : ""}`} style={vars} aria-label="주 메뉴">
<div className={styles.brand}>
<span className={styles.mark} aria-hidden="true" />
<span className={styles.brandText}>Studio</span>
</div>
<ul className={styles.list}>
{ITEMS.map((label, i) => (
<li key={label} className={i === active ? styles.active : undefined}>
<a className={styles.link} href="#" onClick={(e: { preventDefault: () => void }) => { e.preventDefault(); setActive(i); }}>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256">{ICONS[i]}</svg>
</span>
<span className={styles.label}>{label}</span>
</a>
</li>
))}
</ul>
<button
type="button"
className={styles.toggle}
aria-label="사이드바 접기/펼치기"
aria-pressed={collapsed}
onClick={() => setCollapsed((v) => !v)}
>
<span className={styles.toggleIc} aria-hidden="true">
<svg viewBox="0 0 256 256">{CARET_LEFT}</svg>
</span>
</button>
</nav>
);
}01 · 単体サイドバーナビ — 計測 累積面積 12.547% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
今選ばれているメニューは、左の細い縦バーと薄い背景色で示されます。
.sd { width: 208px; transition: width $duration $easing; }
.sd.is-collapsed { width: 76px; }
.sd__label { transition: opacity $duration $easing; }
.sd.is-collapsed .sd__label { opacity: 0; }
02スパークライン表示の統計カード
何が動くか
期間の切り替えを押すと、訪問者数・コンバージョン率・売上の3つの数字が実際に入れ替わります。

<main class="stage">
<div class="sc" role="group" aria-label="主要指標">
<div class="sc__head">
<span class="sc__title">今週</span>
<div class="sc__switch" role="group" aria-label="期間">
<button class="sc__period is-active" type="button" data-period="week">週間</button>
<button class="sc__period" type="button" data-period="month">月間</button>
</div>
</div>
<div class="sc__row">
<div class="sc__card is-demo">
<span class="sc__label">訪問者</span>
<span class="sc__value" data-week="4,210" data-month="16,940">4,210</span>
<svg class="sc__spark" viewBox="0 0 60 24" aria-hidden="true">
<path class="sc__spark-path" pathLength="100" d="M2 18 L12 14 L22 16 L32 8 L42 10 L50 4 L58 6" />
</svg>
</div>
<div class="sc__card is-demo">
<span class="sc__label">コンバージョン率</span>
<span class="sc__value" data-week="3.2%" data-month="3.6%">3.2%</span>
<svg class="sc__spark" viewBox="0 0 60 24" aria-hidden="true">
<path class="sc__spark-path" pathLength="100" d="M2 8 L12 10 L22 6 L32 12 L42 9 L50 14 L58 11" />
</svg>
</div>
<div class="sc__card is-demo">
<span class="sc__label">売上</span>
<span class="sc__value" data-week="84.2万円" data-month="₩3,180만">84.2万円</span>
<svg class="sc__spark" viewBox="0 0 60 24" aria-hidden="true">
<path class="sc__spark-path" pathLength="100" d="M2 20 L12 16 L22 17 L32 11 L42 7 L50 9 L58 3" />
</svg>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.sc {
box-sizing: border-box;
width: min(420px, 92vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sc__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: $sp-3; }
.sc__title { font-weight: 800; font-size: 13px; color: $stage-ink; }
.sc__switch { display: flex; gap: $sp-1; padding: $sp-1; border-radius: $r-pill; background: rgba(23, 20, 26, .06); }
.sc__period {
border: 0; background: transparent; cursor: pointer;
padding: $sp-1 $sp-3; border-radius: $r-pill;
font-weight: 700; font-size: 10px; font-family: inherit; color: rgba(23, 20, 26, .72);
transition: background $duration $easing, color $duration $easing;
}
.sc__period.is-active { background: $stage-ink; color: $subject-cream; }
.sc__row { display: flex; gap: $sp-3; }
.sc__card {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
border-radius: $r-card;
padding: $sp-3;
background: #fff;
box-shadow: $shadow-raised;
position: relative;
overflow: hidden;
}
.sc__label { display: block; font-size: 10px; font-weight: 700; color: rgba(23, 20, 26, .72); margin-bottom: $sp-1; }
.sc__value {
display: block; font-size: clamp(14px, 4vw, 18px); font-weight: 800; color: $stage-ink;
transition: opacity $dur-quick $easing;
}
.sc__value.is-swap { opacity: 0; }
.sc__spark { display: block; width: 100%; height: 24px; margin-top: $sp-2; overflow: visible; }
.sc__spark-path {
fill: none;
stroke: $color;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
// ── 자동 루프: 스파크라인이 계속 그려진다 (호버 없이도 보이도록) ──────────────
.sc__card.is-demo .sc__spark-path { animation: sc-draw 2s $easing infinite; }
@keyframes sc-draw {
0% { stroke-dashoffset: 100; }
60% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: 0; }
}
@media (max-width: 340px) {
.sc__row { gap: $sp-2; }
.sc__card { padding: $sp-2; }
.sc__label { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.sc__card.is-demo .sc__spark-path { animation: none !important; stroke-dashoffset: 0; }
.sc__value { transition: none; }
}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);
}
.sc {
box-sizing: border-box;
width: min(420px, 92vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sc__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sc__title {
font-weight: 800;
font-size: 13px;
color: #17141a;
}
.sc__switch {
display: flex;
gap: 4px;
padding: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.06);
}
.sc__period {
border: 0;
background: transparent;
cursor: pointer;
padding: 4px 12px;
border-radius: 999px;
font-weight: 700;
font-size: 10px;
font-family: inherit;
color: rgba(23, 20, 26, 0.72);
transition: background 300ms ease-out, color 300ms ease-out;
}
.sc__period.is-active {
background: #17141a;
color: #fff7e6;
}
.sc__row {
display: flex;
gap: 12px;
}
.sc__card {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
border-radius: 16px;
padding: 12px;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
position: relative;
overflow: hidden;
}
.sc__label {
display: block;
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
margin-bottom: 4px;
}
.sc__value {
display: block;
font-size: clamp(14px, 4vw, 18px);
font-weight: 800;
color: #17141a;
transition: opacity 220ms ease-out;
}
.sc__value.is-swap {
opacity: 0;
}
.sc__spark {
display: block;
width: 100%;
height: 24px;
margin-top: 8px;
overflow: visible;
}
.sc__spark-path {
fill: none;
stroke: #2f6df6;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.sc__card.is-demo .sc__spark-path {
animation: sc-draw 2s ease-out infinite;
}
@keyframes sc-draw {
0% {
stroke-dashoffset: 100;
}
60% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 0;
}
}
@media (max-width: 340px) {
.sc__row {
gap: 8px;
}
.sc__card {
padding: 8px;
}
.sc__label {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc__card.is-demo .sc__spark-path {
animation: none !important;
stroke-dashoffset: 0;
}
.sc__value {
transition: none;
}
}// 실제 동작: 週間/月間 버튼을 누르면 세 카드 숫자가 실제로 교체된다(크로스페이드). 첫 클릭에 자동 루프를 멈춘다.
var buttons = document.querySelectorAll('.sc__period');
var cards = document.querySelectorAll('.sc__card');
buttons.forEach(function (b) {
b.addEventListener('click', function () {
cards.forEach(function (c) { c.classList.remove('is-demo'); });
buttons.forEach(function (o) { o.classList.remove('is-active'); });
b.classList.add('is-active');
var period = b.dataset.period;
cards.forEach(function (c) {
var v = c.querySelector('.sc__value');
v.classList.add('is-swap');
setTimeout(function () {
v.textContent = v.dataset[period];
v.classList.remove('is-swap');
}, 140);
});
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StatCards.module.scss";
export type StatCardsProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Period = "week" | "month";
const CARDS: { label: string; week: string; month: string; path: string }[] = [
{ label: "방문자", week: "4,210", month: "16,940", path: "M2 18 L12 14 L22 16 L32 8 L42 10 L50 4 L58 6" },
{ label: "전환율", week: "3.2%", month: "3.6%", path: "M2 8 L12 10 L22 6 L32 12 L42 9 L50 14 L58 11" },
{ label: "매출", week: "₩842만", month: "₩3,180만", path: "M2 20 L12 16 L22 17 L32 11 L42 7 L50 9 L58 3" },
];
export default function StatCards({ duration = 220, easing = "ease-out", color = "#2f6df6" }: StatCardsProps) {
const [period, setPeriod] = useState<Period>("week");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.sc} style={vars} role="group" aria-label="핵심 지표">
<div className={styles.head}>
<span className={styles.title}>이번 주</span>
<div className={styles.switch} role="group" aria-label="기간">
{(["week", "month"] as Period[]).map((p) => (
<button
key={p}
type="button"
className={period === p ? styles.active : undefined}
onClick={() => setPeriod(p)}
>
{p === "week" ? "주간" : "월간"}
</button>
))}
</div>
</div>
<div className={styles.row}>
{CARDS.map((c) => (
<div className={styles.card} key={c.label}>
<span className={styles.label}>{c.label}</span>
<span className={styles.value}>{period === "week" ? c.week : c.month}</span>
<svg className={styles.spark} viewBox="0 0 60 24" aria-hidden="true">
<path pathLength={100} d={c.path} />
</svg>
</div>
))}
</div>
</div>
);
}02 · スパークライン表示の統計カード — 計測 累積面積 0.374% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
下のミニグラフ線は SVG の pathLength を100に固定しているので、形が違っても同じ描画方法で動きます。
.sc__spark-path { stroke-dasharray: 100; stroke-dashoffset: 100; }
.sc__card.is-demo .sc__spark-path { animation: sc-draw 2s $easing infinite; }
@keyframes sc-draw {
0% { stroke-dashoffset: 100; }
60% { stroke-dashoffset: 0; }
}
03並び替え可能なミニテーブル
何が動くか
列見出しを押すと、矢印の向きが変わって行の順番が実際に入れ替わります。

<main class="stage">
<div class="tb-wrap is-demo">
<span class="tb__scan" aria-hidden="true"></span>
<table class="tb">
<thead>
<tr>
<th><button class="tb__sort" type="button" data-key="name" aria-sort="none">名前 <span class="tb__arrow" aria-hidden="true"></span></button></th>
<th><button class="tb__sort" type="button" data-key="amount" aria-sort="none">金額 <span class="tb__arrow" aria-hidden="true"></span></button></th>
<th>ステータス</th>
</tr>
</thead>
<tbody class="tb__body">
<tr data-name="キム・ドユン" data-amount="128000"><td>キム・ドユン</td><td>₩128,000</td><td><span class="tb__pill tb__pill--ok">完了</span></td></tr>
<tr data-name="イ・ソジュン" data-amount="54000"><td>イ・ソジュン</td><td>₩54,000</td><td><span class="tb__pill tb__pill--wait">保留</span></td></tr>
<tr data-name="パク・ハウン" data-amount="212000"><td>パク・ハウン</td><td>₩212,000</td><td><span class="tb__pill tb__pill--ok">完了</span></td></tr>
<tr data-name="チョン・ユナ" data-amount="98000"><td>チョン・ユナ</td><td>₩98,000</td><td><span class="tb__pill tb__pill--err">失敗</span></td></tr>
</tbody>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.tb-wrap {
position: relative;
box-sizing: border-box;
width: min(420px, 92vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
isolation: isolate;
}
.tb__scan {
position: absolute;
left: 0; right: 0; top: -30px;
height: 30px;
background: linear-gradient(180deg, rgba(47, 109, 246, 0), rgba(47, 109, 246, .16));
pointer-events: none;
z-index: 1;
opacity: 0;
}
.tb {
box-sizing: border-box;
width: 100%;
border-collapse: collapse;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
background: #fff;
}
.tb th, .tb td {
box-sizing: border-box;
text-align: left;
padding: $sp-2 $sp-3;
}
.tb thead th {
font-size: 10px; font-weight: 700; color: rgba(23, 20, 26, .72);
border-bottom: 1px solid rgba(23, 20, 26, .08);
}
.tb__sort {
border: 0; background: transparent; cursor: pointer; padding: 0;
font: inherit; font-weight: 700; color: inherit;
display: inline-flex; align-items: center; gap: 4px;
}
.tb__arrow {
width: 0; height: 0;
border-left: 4px solid transparent; border-right: 4px solid transparent;
border-top: 5px solid rgba(23, 20, 26, .35);
transition: transform $duration $easing;
}
.tb__sort[aria-sort="ascending"] .tb__arrow { transform: rotate(180deg); }
.tb__sort[aria-sort="descending"] .tb__arrow { transform: rotate(0deg); }
.tb__body tr { border-bottom: 1px solid rgba(23, 20, 26, .06); }
.tb__body tr:last-child { border-bottom: 0; }
.tb__pill {
display: inline-block;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
font-weight: 700; font-size: 10px;
}
.tb__pill--ok { background: #e3f3e6; color: #47905f; }
.tb__pill--wait { background: #fff2d6; color: rgba(23, 20, 26, .72); }
.tb__pill--err { background: $error-soft; color: rgba(23, 20, 26, .72); }
// ── 자동 루프: 하이라이트 띠가 표 위에서 아래로 계속 훑고 지나간다 ──────────────
.tb-wrap.is-demo .tb__scan { animation: tb-scan 2s linear infinite; }
@keyframes tb-scan {
0% { transform: translateY(0); opacity: 1; }
92% { opacity: 1; }
100% { transform: translateY(200px); opacity: 0; }
}
@media (max-width: 340px) {
.tb { font-size: 11px; }
.tb th, .tb td { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.tb-wrap.is-demo .tb__scan { animation: none !important; }
.tb__arrow { transition: none; }
}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);
}
.tb-wrap {
position: relative;
box-sizing: border-box;
width: min(420px, 92vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
isolation: isolate;
}
.tb__scan {
position: absolute;
left: 0;
right: 0;
top: -30px;
height: 30px;
background: linear-gradient(180deg, rgba(47, 109, 246, 0), rgba(47, 109, 246, 0.16));
pointer-events: none;
z-index: 1;
opacity: 0;
}
.tb {
box-sizing: border-box;
width: 100%;
border-collapse: collapse;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
background: #fff;
}
.tb th, .tb td {
box-sizing: border-box;
text-align: left;
padding: 8px 12px;
}
.tb thead th {
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
}
.tb__sort {
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
font: inherit;
font-weight: 700;
color: inherit;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tb__arrow {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid rgba(23, 20, 26, 0.35);
transition: transform 220ms ease-out;
}
.tb__sort[aria-sort=ascending] .tb__arrow {
transform: rotate(180deg);
}
.tb__sort[aria-sort=descending] .tb__arrow {
transform: rotate(0deg);
}
.tb__body tr {
border-bottom: 1px solid rgba(23, 20, 26, 0.06);
}
.tb__body tr:last-child {
border-bottom: 0;
}
.tb__pill {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
font-weight: 700;
font-size: 10px;
}
.tb__pill--ok {
background: #e3f3e6;
color: #47905f;
}
.tb__pill--wait {
background: #fff2d6;
color: rgba(23, 20, 26, 0.72);
}
.tb__pill--err {
background: #ffe0d8;
color: rgba(23, 20, 26, 0.72);
}
.tb-wrap.is-demo .tb__scan {
animation: tb-scan 2s linear infinite;
}
@keyframes tb-scan {
0% {
transform: translateY(0);
opacity: 1;
}
92% {
opacity: 1;
}
100% {
transform: translateY(200px);
opacity: 0;
}
}
@media (max-width: 340px) {
.tb {
font-size: 11px;
}
.tb th, .tb td {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.tb-wrap.is-demo .tb__scan {
animation: none !important;
}
.tb__arrow {
transition: none;
}
}// 실제 동작: 열 제목을 누르면 행이 실제로 그 값 기준으로 다시 정렬된다(asc/desc 토글).
var wrap = document.querySelector('.tb-wrap');
var tbody = document.querySelector('.tb__body');
var buttons = document.querySelectorAll('.tb__sort');
buttons.forEach(function (btn) {
var dir = 1;
btn.addEventListener('click', function () {
wrap.classList.remove('is-demo');
buttons.forEach(function (o) { if (o !== btn) o.setAttribute('aria-sort', 'none'); });
dir = btn.getAttribute('aria-sort') === 'ascending' ? -1 : 1;
btn.setAttribute('aria-sort', dir === 1 ? 'ascending' : 'descending');
var key = btn.dataset.key;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function (a, b) {
var av = a.dataset[key], bv = b.dataset[key];
if (key === 'amount') { av = Number(av); bv = Number(bv); return (av - bv) * dir; }
return av.localeCompare(bv) * dir;
});
rows.forEach(function (r) { tbody.appendChild(r); });
});
});import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SortableTable.module.scss";
export type SortableTableProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Key = "name" | "amount";
type Status = "ok" | "wait" | "err";
type Row = { name: string; amount: number; status: Status; statusLabel: string };
const ROWS: Row[] = [
{ name: "김도윤", amount: 128000, status: "ok", statusLabel: "완료" },
{ name: "이서준", amount: 54000, status: "wait", statusLabel: "대기" },
{ name: "박하은", amount: 212000, status: "ok", statusLabel: "완료" },
{ name: "정유나", amount: 98000, status: "err", statusLabel: "실패" },
];
export default function SortableTable({ duration = 220, easing = "ease-out", color = "#2f6df6" }: SortableTableProps) {
const [sort, setSort] = useState<{ key: Key; dir: 1 | -1 } | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const rows = useMemo(() => {
if (!sort) return ROWS;
const copy = [...ROWS];
copy.sort((a, b) => {
if (sort.key === "amount") return (a.amount - b.amount) * sort.dir;
return a.name.localeCompare(b.name) * sort.dir;
});
return copy;
}, [sort]);
function toggle(key: Key) {
setSort((s) => ({ key, dir: s && s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }));
}
function ariaSort(key: Key): "ascending" | "descending" | "none" {
if (!sort || sort.key !== key) return "none";
return sort.dir === 1 ? "ascending" : "descending";
}
return (
<table className={styles.tb} style={vars}>
<thead>
<tr>
<th>
<button type="button" aria-sort={ariaSort("name")} onClick={() => toggle("name")}>
이름 <span className={styles.arrow} />
</button>
</th>
<th>
<button type="button" aria-sort={ariaSort("amount")} onClick={() => toggle("amount")}>
금액 <span className={styles.arrow} />
</button>
</th>
<th>상태</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.name}>
<td>{r.name}</td>
<td>₩{r.amount.toLocaleString()}</td>
<td><span className={`${styles.pill} ${styles[r.status]}`}>{r.statusLabel}</span></td>
</tr>
))}
</tbody>
</table>
);
}03 · 並び替え可能なミニテーブル — 計測 累積面積 54.986% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
金額列は数値として、名前列は文字列として、それぞれ別の比較方法で並べ替えています。
rows.sort(function (a, b) {
var av = a.dataset[key], bv = b.dataset[key];
if (key === 'amount') { av = Number(av); bv = Number(bv); return (av - bv) * dir; }
return av.localeCompare(bv) * dir;
});
rows.forEach(function (r) { tbody.appendChild(r); });
04中央カウンター付きドーナツチャート
何が動くか
凡例を押すと、その区間が実際にリングから外れて、中央の数字が新しい合計まで数え上がります。

<main class="stage">
<div class="cc is-demo">
<div class="cc__ring-wrap">
<div class="cc__ring" aria-hidden="true"></div>
<div class="cc__center">
<span class="cc__num">0</span><span class="cc__unit">%</span>
<span class="cc__ghost" aria-hidden="true"></span>
</div>
</div>
<ul class="cc__legend">
<li><button class="cc__legend-btn is-on" type="button" data-seg="a"><span class="cc__dot cc__dot--a" aria-hidden="true"></span>完了48%</button></li>
<li><button class="cc__legend-btn is-on" type="button" data-seg="b"><span class="cc__dot cc__dot--b" aria-hidden="true"></span>進行中24%</button></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
@property --pct-a {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --pct-b {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --cc-n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.cc {
box-sizing: border-box;
width: min(300px, 88vw);
display: flex;
align-items: center;
gap: $sp-5;
border-radius: $r-card;
padding: $sp-4;
background: #fff;
box-shadow: $shadow-raised;
}
.cc__ring-wrap { position: relative; flex: 0 0 auto; width: 96px; height: 96px; }
.cc__ring {
--pct-a: 48%;
--pct-b: 72%;
width: 100%; height: 100%;
border-radius: 50%;
background: conic-gradient($color 0 var(--pct-a), $stage-yellow var(--pct-a) var(--pct-b), rgba(23, 20, 26, .08) var(--pct-b) 100%);
-webkit-mask: radial-gradient(farthest-side, transparent 60%, #000 61%);
mask: radial-gradient(farthest-side, transparent 60%, #000 61%);
transition: --pct-a $duration $easing, --pct-b $duration $easing;
}
.cc__center {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-weight: 800; color: $stage-ink;
} // baseline 은 한 줄을 상자 위쪽에 붙여 숫자가 링 띠(파란 호) 위로 올라갔다 — % 가 잉크 on 파랑 4.03:1. 가운데로 내려 도넛 구멍(흰 카드 면) 위에서 15.58:1
.cc__num { font-size: 20px; }
.cc__unit { font-size: 12px; margin-left: $sp-1; }
.cc.is-demo .cc__num, .cc.is-demo .cc__unit { opacity: 0; }
.cc__ghost {
position: absolute; inset: 0;
display: none;
align-items: center; justify-content: center;
font-size: 20px; font-weight: 800; color: $stage-ink;
}
.cc__ghost::before { content: counter(cc) "%"; counter-reset: cc var(--cc-n); }
.cc.is-demo .cc__ghost { display: flex; }
.cc__legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.cc__legend-btn {
display: flex; align-items: center; gap: $sp-2;
border: 0; background: transparent; cursor: pointer; padding: 0;
font: inherit; font-weight: 700; font-size: 12px; color: rgba(23, 20, 26, .4);
transition: color $dur-quick $easing;
}
.cc__legend-btn.is-on { color: $stage-ink; }
.cc__dot { width: 9px; height: 9px; border-radius: 50%; }
.cc__dot--a { background: $color; }
.cc__dot--b { background: $stage-yellow; border: 1px solid rgba(23, 20, 26, .25); }
// ── 자동 루프: 링이 0에서 실제 값까지 계속 그려지고 가운데 합계가 세어 올라간다 ──────────────
.cc.is-demo .cc__ring { animation: cc-draw 2s $easing infinite; }
.cc.is-demo .cc__ghost { animation: cc-count 2s linear infinite; }
@keyframes cc-draw {
0% { --pct-a: 0%; --pct-b: 0%; }
65%, 100% { --pct-a: 48%; --pct-b: 72%; }
}
@keyframes cc-count {
0% { --cc-n: 0; }
65%, 100% { --cc-n: 72; }
}
@media (max-width: 340px) {
.cc { flex-direction: column; align-items: flex-start; gap: $sp-3; }
.cc__ring-wrap { width: 80px; height: 80px; }
}
@media (prefers-reduced-motion: reduce) {
.cc.is-demo .cc__ring, .cc.is-demo .cc__ghost { animation: none !important; }
.cc.is-demo .cc__ring { --pct-a: 48%; --pct-b: 72%; }
.cc.is-demo .cc__ghost::before { content: "72%"; }
.cc__ring { transition: none; }
}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);
}
@property --pct-a {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --pct-b {
syntax: "<percentage>";
inherits: true;
initial-value: 0%;
}
@property --cc-n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.cc {
box-sizing: border-box;
width: min(300px, 88vw);
display: flex;
align-items: center;
gap: 20px;
border-radius: 16px;
padding: 16px;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cc__ring-wrap {
position: relative;
flex: 0 0 auto;
width: 96px;
height: 96px;
}
.cc__ring {
--pct-a: 48%;
--pct-b: 72%;
width: 100%;
height: 100%;
border-radius: 50%;
background: conic-gradient(#2f6df6 0 var(--pct-a), #ffd23f var(--pct-a) var(--pct-b), rgba(23, 20, 26, 0.08) var(--pct-b) 100%);
-webkit-mask: radial-gradient(farthest-side, transparent 60%, #000 61%);
mask: radial-gradient(farthest-side, transparent 60%, #000 61%);
transition: --pct-a 600ms ease-out, --pct-b 600ms ease-out;
}
.cc__center {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
color: #17141a;
}
.cc__num {
font-size: 20px;
}
.cc__unit {
font-size: 12px;
margin-left: 4px;
}
.cc.is-demo .cc__num, .cc.is-demo .cc__unit {
opacity: 0;
}
.cc__ghost {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 800;
color: #17141a;
}
.cc__ghost::before {
content: counter(cc) "%";
counter-reset: cc var(--cc-n);
}
.cc.is-demo .cc__ghost {
display: flex;
}
.cc__legend {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.cc__legend-btn {
display: flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
font: inherit;
font-weight: 700;
font-size: 12px;
color: rgba(23, 20, 26, 0.4);
transition: color 220ms ease-out;
}
.cc__legend-btn.is-on {
color: #17141a;
}
.cc__dot {
width: 9px;
height: 9px;
border-radius: 50%;
}
.cc__dot--a {
background: #2f6df6;
}
.cc__dot--b {
background: #ffd23f;
border: 1px solid rgba(23, 20, 26, 0.25);
}
.cc.is-demo .cc__ring {
animation: cc-draw 2s ease-out infinite;
}
.cc.is-demo .cc__ghost {
animation: cc-count 2s linear infinite;
}
@keyframes cc-draw {
0% {
--pct-a: 0%;
--pct-b: 0%;
}
65%, 100% {
--pct-a: 48%;
--pct-b: 72%;
}
}
@keyframes cc-count {
0% {
--cc-n: 0;
}
65%, 100% {
--cc-n: 72;
}
}
@media (max-width: 340px) {
.cc {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.cc__ring-wrap {
width: 80px;
height: 80px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc.is-demo .cc__ring, .cc.is-demo .cc__ghost {
animation: none !important;
}
.cc.is-demo .cc__ring {
--pct-a: 48%;
--pct-b: 72%;
}
.cc.is-demo .cc__ghost::before {
content: "72%";
}
.cc__ring {
transition: none;
}
}// 실제 동작: 범례를 누르면 그 구간이 실제로 링에서 빠지고 가운데 합계 숫자가 다시 계산된다.
var card = document.querySelector('.cc');
var ring = document.querySelector('.cc__ring');
var num = document.querySelector('.cc__num');
var segs = { a: 48, b: 24 };
var active = { a: true, b: true };
function paint() {
var a = active.a ? segs.a : 0;
var b = active.b ? segs.b : 0;
ring.style.setProperty('--pct-a', a + '%');
ring.style.setProperty('--pct-b', (a + b) + '%');
return a + b;
}
function countTo(target) {
var from = Number(num.textContent) || 0;
var start = null;
function step(ts) {
if (!start) start = ts;
var p = Math.min(1, (ts - start) / 300);
num.textContent = Math.round(from + (target - from) * p);
if (p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
document.querySelectorAll('.cc__legend-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
card.classList.remove('is-demo');
var seg = btn.dataset.seg;
active[seg] = !active[seg];
btn.classList.toggle('is-on', active[seg]);
countTo(paint());
});
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChartCard.module.scss";
export type ChartCardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--pct-a"?: string; "--pct-b"?: string };
const SEGS = { a: 48, b: 24 };
export default function ChartCard({ duration = 600, easing = "ease-out", color = "#2f6df6" }: ChartCardProps) {
const [active, setActive] = useState({ a: true, b: true });
const [shown, setShown] = useState(0);
const raf = useRef<number | null>(null);
const target = (active.a ? SEGS.a : 0) + (active.b ? SEGS.b : 0);
useEffect(() => {
const from = shown;
const start = performance.now();
function step(ts: number) {
const p = Math.min(1, (ts - start) / 300);
setShown(Math.round(from + (target - from) * p));
if (p < 1) raf.current = requestAnimationFrame(step);
}
raf.current = requestAnimationFrame(step);
return () => { if (raf.current) cancelAnimationFrame(raf.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [target]);
const pctA = active.a ? SEGS.a : 0;
const pctAB = pctA + (active.b ? SEGS.b : 0);
const vars: Vars = {
"--duration": `${duration}ms`, "--easing": easing, "--color": color,
"--pct-a": `${pctA}%`, "--pct-b": `${pctAB}%`,
};
return (
<div className={styles.cc} style={vars}>
<div className={styles.ringWrap}>
<div className={styles.ring} />
<div className={styles.center}>
<span className={styles.num}>{shown}</span><span className={styles.unit}>%</span>
</div>
</div>
<ul className={styles.legend}>
<li>
<button type="button" className={active.a ? styles.on : undefined} onClick={() => setActive((s) => ({ ...s, a: !s.a }))}>
<span className={`${styles.dot} ${styles.dotA}`} />완료 {SEGS.a}%
</button>
</li>
<li>
<button type="button" className={active.b ? styles.on : undefined} onClick={() => setActive((s) => ({ ...s, b: !s.b }))}>
<span className={`${styles.dot} ${styles.dotB}`} />진행 {SEGS.b}%
</button>
</li>
</ul>
</div>
);
}04 · 中央カウンター付きドーナツチャート — 計測 累積面積 2.739% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
リングは SVG ではなく conic-gradient と登録済みのカスタムプロパティ(@property)だけで描いています。
@property --pct-a { syntax: "<percentage>"; inherits: true; initial-value: 0%; }
.cc__ring {
background: conic-gradient($color 0 var(--pct-a), $stage-yellow var(--pct-a) var(--pct-b), rgba(23,20,26,.08) var(--pct-b) 100%);
mask: radial-gradient(farthest-side, transparent 60%, #000 61%);
transition: --pct-a $duration $easing, --pct-b $duration $easing;
}
05チップ型フィルターバー
何が動くか
チップを押すたびに実際に選択状態(色が付いた錠剤の形)に変わり、下の選択数も一緒に増えます。

<main class="stage">
<div class="fl">
<div class="fl__row">
<button class="fl__chip is-demo" type="button" aria-pressed="false">進行中</button>
<button class="fl__chip is-demo" type="button" aria-pressed="false">完了</button>
<button class="fl__chip is-demo" type="button" aria-pressed="false">保留中</button>
<button class="fl__chip is-demo" type="button" aria-pressed="false">緊急</button>
<button class="fl__clear" type="button" hidden>全解除 ✕</button>
</div>
<p class="fl__count"><span class="fl__count-num">0</span>件選択中</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-orange);
.fl {
box-sizing: border-box;
width: min(400px, 90vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.fl__row { display: flex; flex-wrap: wrap; gap: $sp-2; align-items: center; }
.fl__chip {
box-sizing: border-box;
border: 1px solid rgba(23, 20, 26, .4);
background: transparent;
color: $subject-ink;
padding: $sp-2 $sp-4;
border-radius: $r-pill;
font: inherit; font-weight: 700; font-size: 12px;
cursor: pointer;
transition: background $duration $easing, border-color $duration $easing, transform $duration $easing;
}
.fl__chip[aria-pressed="true"] {
background: $subject-cream;
border-color: $subject-cream;
color: $subject-orange-deep;
} // 크림 면 위 deep 5.04:1
.fl__clear {
border: 0; background: transparent; cursor: pointer;
color: rgba(23, 20, 26, .8);
font: inherit; font-weight: 700; font-size: 11px;
text-decoration: underline;
padding: $sp-2 $sp-1;
}
.fl__count { margin: $sp-3 0 0; font-size: 12px; font-weight: 600; color: $subject-ink; } // 주황 면 위 알파 잉크 .72 는 3.76:1 — 알파를 빼야 5.50:1 (패턴 ①·주황 면 예외)
.fl__count-num { color: $subject-ink; font-weight: 800; }
// ── 자동 루프: 칩이 하나씩 순서대로 눌렸다 풀린다 ──────────────
.fl__chip.is-demo { animation: fl-press 2s $easing infinite; }
.fl__chip.is-demo:nth-child(1) { animation-delay: 0s; }
.fl__chip.is-demo:nth-child(2) { animation-delay: .3s; }
.fl__chip.is-demo:nth-child(3) { animation-delay: .6s; }
.fl__chip.is-demo:nth-child(4) { animation-delay: .9s; }
@keyframes fl-press {
0%, 100% { transform: scale(1); background: transparent; border-color: rgba(23, 20, 26, .4); color: $subject-ink; }
20% { transform: scale(1.06); background: $subject-cream; border-color: $subject-cream; color: $subject-orange-deep; }
40% { transform: scale(1); background: $subject-cream; border-color: $subject-cream; color: $subject-orange-deep; }
}
@media (max-width: 340px) {
.fl__chip { padding: $sp-1 $sp-3; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.fl__chip.is-demo { animation: none !important; }
.fl__chip { transition: none; }
}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);
}
.fl {
box-sizing: border-box;
width: min(400px, 90vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.fl__row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.fl__chip {
box-sizing: border-box;
border: 1px solid rgba(23, 20, 26, 0.4);
background: transparent;
color: #17141a;
padding: 8px 16px;
border-radius: 999px;
font: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), border-color 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.fl__chip[aria-pressed=true] {
background: #fff7e6;
border-color: #fff7e6;
color: #c93000;
}
.fl__clear {
border: 0;
background: transparent;
cursor: pointer;
color: rgba(23, 20, 26, 0.8);
font: inherit;
font-weight: 700;
font-size: 11px;
text-decoration: underline;
padding: 8px 4px;
}
.fl__count {
margin: 12px 0 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.fl__count-num {
color: #17141a;
font-weight: 800;
}
.fl__chip.is-demo {
animation: fl-press 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.fl__chip.is-demo:nth-child(1) {
animation-delay: 0s;
}
.fl__chip.is-demo:nth-child(2) {
animation-delay: 0.3s;
}
.fl__chip.is-demo:nth-child(3) {
animation-delay: 0.6s;
}
.fl__chip.is-demo:nth-child(4) {
animation-delay: 0.9s;
}
@keyframes fl-press {
0%, 100% {
transform: scale(1);
background: transparent;
border-color: rgba(23, 20, 26, 0.4);
color: #17141a;
}
20% {
transform: scale(1.06);
background: #fff7e6;
border-color: #fff7e6;
color: #c93000;
}
40% {
transform: scale(1);
background: #fff7e6;
border-color: #fff7e6;
color: #c93000;
}
}
@media (max-width: 340px) {
.fl__chip {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.fl__chip.is-demo {
animation: none !important;
}
.fl__chip {
transition: none;
}
}// 실제 동작: 칩을 누르면 실제로 눌린 상태로 바뀌고 개수가 갱신되며, 하나 이상이면 '전체 해제'가 나타난다.
var chips = document.querySelectorAll('.fl__chip');
var clearBtn = document.querySelector('.fl__clear');
var countEl = document.querySelector('.fl__count-num');
function refresh() {
var n = document.querySelectorAll('.fl__chip[aria-pressed="true"]').length;
countEl.textContent = String(n);
clearBtn.hidden = n === 0;
}
chips.forEach(function (c) {
c.addEventListener('click', function () {
c.classList.remove('is-demo');
var on = c.getAttribute('aria-pressed') === 'true';
c.setAttribute('aria-pressed', String(!on));
refresh();
});
});
clearBtn.addEventListener('click', function () {
chips.forEach(function (c) { c.setAttribute('aria-pressed', 'false'); });
refresh();
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./Filters.module.scss";
export type FiltersProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const CHIPS = ["진행중", "완료", "보류", "긴급"];
export default function Filters({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: FiltersProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle(chip: string) {
setSelected((s) => {
const next = new Set(s);
next.has(chip) ? next.delete(chip) : next.add(chip);
return next;
});
}
return (
<div className={styles.fl} style={vars}>
<div className={styles.row}>
{CHIPS.map((chip) => (
<button
key={chip}
type="button"
className={styles.chip}
aria-pressed={selected.has(chip)}
onClick={() => toggle(chip)}
>
{chip}
</button>
))}
{selected.size > 0 && (
<button type="button" className={styles.clear} onClick={() => setSelected(new Set())}>
전체 해제 ✕
</button>
)}
</div>
<p className={styles.count}><span className={styles.countNum}>{selected.size}</span>개 선택됨</p>
</div>
);
}05 · チップ型フィルターバー — 計測 累積面積 5.824% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
ひとつでも選ぶと「全解除」の文字ボタンが現れます。
c.addEventListener('click', function () {
c.classList.remove('is-demo');
var on = c.getAttribute('aria-pressed') === 'true';
c.setAttribute('aria-pressed', String(!on));
refresh();
});
06追加モーフ付き空状態イラスト
何が動くか
図形だけで描いた空の箱イラストがゆっくり浮かび続け、「最初の項目を追加」ボタンを押すとその場所が実際にリストカード1枚へ変わります。もう一度押すと元の空の状態に戻ります。

<main class="stage">
<div class="es is-demo">
<div class="es__stack">
<div class="es__empty">
<div class="es__box">
<span class="es__shape es__shape--a" aria-hidden="true"></span>
<span class="es__shape es__shape--b" aria-hidden="true"></span>
<span class="es__shape es__shape--c" aria-hidden="true"></span>
</div>
<p class="es__title">まだ項目がありません</p>
<p class="es__desc">最初の項目を追加すると、ここにカードとして表示されます。</p>
</div>
<div class="es__item" aria-hidden="true">
<span class="es__item-dot" aria-hidden="true"></span>
<div class="es__item-body">
<strong>新規プロジェクト</strong>
<span>追加されました</span>
</div>
</div>
</div>
<button class="es__add" type="button">+ 最初の項目を追加</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.es {
box-sizing: border-box;
width: min(280px, 84vw);
text-align: center;
}
.es__stack { position: relative; min-height: 128px; }
.es__empty, .es__item {
position: absolute; inset: 0;
display: flex; flex-direction: column; align-items: center; justify-content: center;
transition: opacity $duration $easing, transform $duration $easing;
}
.es__item { flex-direction: row; text-align: left; gap: $sp-3; padding: $sp-3; border-radius: $r-card;
background: rgba(255, 247, 230, .06); border: 1px solid rgba(255, 247, 230, .14);
opacity: 0; transform: scale(.9); pointer-events: none;
}
.es.is-added .es__item { opacity: 1; transform: scale(1); pointer-events: auto; }
.es.is-added .es__empty { opacity: 0; transform: scale(.9); pointer-events: none; }
.es__box { position: relative; width: 72px; height: 56px; margin: 0 auto $sp-3; }
.es__shape { position: absolute; border-radius: $r-sm; background: rgba(255, 247, 230, .14); border: 1px solid rgba(255, 247, 230, .24); }
.es__shape--a { width: 56px; height: 40px; left: 8px; top: 12px; animation: es-float 2s ease-in-out infinite; }
.es__shape--b { width: 22px; height: 22px; right: 0; top: 0; border-radius: 50%; background: $color; border-color: transparent; animation: es-float 2s ease-in-out infinite .15s; }
.es__shape--c { width: 30px; height: 8px; left: 18px; bottom: -2px; border-radius: $r-xs; animation: es-float 2s ease-in-out infinite .3s; }
@keyframes es-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
.es__title { margin: 0 0 $sp-1; font-weight: 800; font-size: 13px; color: $subject-cream; }
.es__desc { margin: 0; font-size: 11px; line-height: 1.4; color: rgba(255, 247, 230, .58); }
.es__item-dot { flex: 0 0 auto; width: $sp-4; height: $sp-4; border-radius: 50%; background: $color; margin-top: $sp-1; }
.es__item-body { display: flex; flex-direction: column; gap: $sp-1; }
.es__item-body strong { font-size: 12px; color: $subject-cream; }
.es__item-body span { font-size: 10px; color: rgba(255, 247, 230, .55); }
.es__add {
margin-top: $sp-4;
border: 1px solid rgba(255, 247, 230, .3);
background: transparent;
color: $subject-cream;
padding: $sp-2 $sp-4;
border-radius: $r-control;
font: inherit; font-weight: 700; font-size: 12px;
cursor: pointer;
}
// ── 자동 루프: 빈 상태 ↔ 추가된 카드가 2초마다 서로 바뀐다 ──────────────
.es.is-demo .es__empty { animation: es-swap-out 2s $easing infinite; }
.es.is-demo .es__item { animation: es-swap-in 2s $easing infinite; }
@keyframes es-swap-out {
0%, 45% { opacity: 1; transform: scale(1); }
60%, 100% { opacity: 0; transform: scale(.9); }
}
@keyframes es-swap-in {
0%, 45% { opacity: 0; transform: scale(.9); pointer-events: none; }
60%, 100% { opacity: 1; transform: scale(1); pointer-events: none; }
}
@media (max-width: 340px) {
.es__stack { min-height: 108px; }
.es__box { width: 60px; height: 48px; }
}
@media (prefers-reduced-motion: reduce) {
.es__shape { animation: none !important; }
.es.is-demo .es__empty, .es.is-demo .es__item { animation: none !important; }
.es__empty, .es__item { transition: none; }
}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);
}
.es {
box-sizing: border-box;
width: min(280px, 84vw);
text-align: center;
}
.es__stack {
position: relative;
min-height: 128px;
}
.es__empty, .es__item {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: opacity 600ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.es__item {
flex-direction: row;
text-align: left;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
border: 1px solid rgba(255, 247, 230, 0.14);
opacity: 0;
transform: scale(0.9);
pointer-events: none;
}
.es.is-added .es__item {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.es.is-added .es__empty {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
}
.es__box {
position: relative;
width: 72px;
height: 56px;
margin: 0 auto 12px;
}
.es__shape {
position: absolute;
border-radius: 8px;
background: rgba(255, 247, 230, 0.14);
border: 1px solid rgba(255, 247, 230, 0.24);
}
.es__shape--a {
width: 56px;
height: 40px;
left: 8px;
top: 12px;
animation: es-float 2s ease-in-out infinite;
}
.es__shape--b {
width: 22px;
height: 22px;
right: 0;
top: 0;
border-radius: 50%;
background: #2f6df6;
border-color: transparent;
animation: es-float 2s ease-in-out infinite 0.15s;
}
.es__shape--c {
width: 30px;
height: 8px;
left: 18px;
bottom: -2px;
border-radius: 4px;
animation: es-float 2s ease-in-out infinite 0.3s;
}
@keyframes es-float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.es__title {
margin: 0 0 4px;
font-weight: 800;
font-size: 13px;
color: #fff7e6;
}
.es__desc {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: rgba(255, 247, 230, 0.58);
}
.es__item-dot {
flex: 0 0 auto;
width: 16px;
height: 16px;
border-radius: 50%;
background: #2f6df6;
margin-top: 4px;
}
.es__item-body {
display: flex;
flex-direction: column;
gap: 4px;
}
.es__item-body strong {
font-size: 12px;
color: #fff7e6;
}
.es__item-body span {
font-size: 10px;
color: rgba(255, 247, 230, 0.55);
}
.es__add {
margin-top: 16px;
border: 1px solid rgba(255, 247, 230, 0.3);
background: transparent;
color: #fff7e6;
padding: 8px 16px;
border-radius: 12px;
font: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
}
.es.is-demo .es__empty {
animation: es-swap-out 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.es.is-demo .es__item {
animation: es-swap-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes es-swap-out {
0%, 45% {
opacity: 1;
transform: scale(1);
}
60%, 100% {
opacity: 0;
transform: scale(0.9);
}
}
@keyframes es-swap-in {
0%, 45% {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
}
60%, 100% {
opacity: 1;
transform: scale(1);
pointer-events: none;
}
}
@media (max-width: 340px) {
.es__stack {
min-height: 108px;
}
.es__box {
width: 60px;
height: 48px;
}
}
@media (prefers-reduced-motion: reduce) {
.es__shape {
animation: none !important;
}
.es.is-demo .es__empty, .es.is-demo .es__item {
animation: none !important;
}
.es__empty, .es__item {
transition: none;
}
}// 실제 동작: 버튼을 누르면 빈 ステータス 일러스트가 실제로 카드 한 장으로 바뀐다(되돌리기도 가능).
var wrap = document.querySelector('.es');
var addBtn = document.querySelector('.es__add');
var item = document.querySelector('.es__item');
addBtn.addEventListener('click', function () {
wrap.classList.remove('is-demo');
var added = wrap.classList.toggle('is-added');
item.setAttribute('aria-hidden', String(!added));
addBtn.textContent = added ? '項目を元に戻す' : '+ 最初の項目を追加';
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EmptyState.module.scss";
export type EmptyStateProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function EmptyState({ duration = 600, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: EmptyStateProps) {
const [added, setAdded] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.es} style={vars}>
<div className={styles.stack}>
<div className={`${styles.empty} ${added ? styles.hide : ""}`}>
<div className={styles.box}>
<span className={`${styles.shape} ${styles.shapeA}`} />
<span className={`${styles.shape} ${styles.shapeB}`} />
<span className={`${styles.shape} ${styles.shapeC}`} />
</div>
<p className={styles.title}>아직 항목이 없어요</p>
<p className={styles.desc}>첫 항목을 추가하면 여기에 카드로 나타납니다.</p>
</div>
<div className={`${styles.item} ${added ? styles.show : ""}`} aria-hidden={!added}>
<span className={styles.itemDot} />
<div className={styles.itemBody}>
<strong>새 프로젝트</strong>
<span>방금 추가됨</span>
</div>
</div>
</div>
<button type="button" className={styles.add} onClick={() => setAdded((v) => !v)}>
{added ? "항목 되돌리기" : "+ 첫 항목 추가"}
</button>
</div>
);
}06 · 追加モーフ付き空状態イラスト — 計測 累積面積 24.73% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
.es__empty, .es__item { position: absolute; inset: 0; transition: opacity $duration $easing, transform $duration $easing; }
.es__item { opacity: 0; transform: scale(.9); }
.es.is-added .es__item { opacity: 1; transform: scale(1); }
.es.is-added .es__empty { opacity: 0; transform: scale(.9); }
07保存状態付き設定フォームセクション
何が動くか
トグルを切り替えて保存ボタンを押すと、ボタンが実際に3段階に変わります。くるくる回るリング状のスピナーになり、700ミリ秒後に緑色の「保存済み」になり、1.6秒後に元の文字に戻ります。

<main class="stage">
<form class="sf" novalidate>
<h2 class="sf__title">通知</h2>
<label class="sf__row">
<span>メール通知</span>
<span class="sf__switch is-demo" role="switch" aria-checked="true" tabindex="0"><span class="sf__knob" aria-hidden="true"></span></span>
</label>
<label class="sf__row">
<span>週次レポート</span>
<span class="sf__switch" role="switch" aria-checked="false" tabindex="0"><span class="sf__knob" aria-hidden="true"></span></span>
</label>
<button class="sf__save" type="submit">
<span class="sf__save-label">保存</span>
</button>
</form>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.sf {
box-sizing: border-box;
width: min(300px, 88vw);
border-radius: $r-card;
padding: $sp-4;
background: #fff;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sf__title { margin: 0 0 $sp-3; font-size: 13px; font-weight: 800; color: $stage-ink; }
.sf__row {
display: flex; align-items: center; justify-content: space-between;
padding: $sp-2 0;
font-size: 12px; font-weight: 600; color: rgba(23, 20, 26, .72);
border-bottom: 1px solid rgba(23, 20, 26, .06);
}
.sf__switch {
position: relative;
width: 38px; height: 22px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .15);
cursor: pointer;
transition: background $duration $easing;
flex: 0 0 auto;
}
.sf__switch[aria-checked="true"] { background: $color; }
.sf__knob {
position: absolute; top: 2px; left: 2px;
width: 18px; height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(23, 20, 26, .3);
transition: transform $duration $easing;
}
.sf__switch[aria-checked="true"] .sf__knob { transform: translateX(16px); }
.sf__save {
margin-top: $sp-4;
width: 100%;
border: 0; border-radius: $r-control;
padding: $sp-2 $sp-4;
background: $stage-ink;
color: $subject-cream;
font: inherit; font-weight: 700; font-size: 12px;
cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: $sp-2;
}
.sf__save::before {
content: "";
width: 12px; height: 12px;
border-radius: 50%;
border: 2px solid transparent;
display: none;
}
.sf__save.is-busy::before { display: block; border-color: rgba(255, 247, 230, .3); border-top-color: $subject-cream; animation: sf-spin .6s linear infinite; }
.sf__save.is-done { background: #47905f; }
@keyframes sf-spin { to { transform: rotate(360deg); } }
// ── 자동 루프: 스위치 노브에 은은한 링 글로우가 반복된다 ──────────────
.sf__switch.is-demo .sf__knob { animation: sf-glow 2s ease-in-out infinite; }
@keyframes sf-glow {
0%, 100% { box-shadow: 0 1px 3px rgba(23, 20, 26, .3), 0 0 0 0 rgba(47, 109, 246, 0); }
50% { box-shadow: 0 1px 3px rgba(23, 20, 26, .3), 0 0 0 6px rgba(47, 109, 246, .18); }
}
@media (max-width: 340px) {
.sf { padding: $sp-3; }
.sf__row { font-size: 11px; }
}
@media (max-height: 220px) {
.sf { padding: $sp-2 $sp-3; }
.sf__title { margin-bottom: $sp-1; }
.sf__row { padding: 4px 0; }
.sf__save { margin-top: $sp-2; padding: $sp-1 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.sf__switch.is-demo .sf__knob { animation: none !important; }
.sf__save.is-busy::before { animation: none !important; }
.sf__switch, .sf__knob { transition: none; }
}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);
}
.sf {
box-sizing: border-box;
width: min(300px, 88vw);
border-radius: 16px;
padding: 16px;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sf__title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 800;
color: #17141a;
}
.sf__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
border-bottom: 1px solid rgba(23, 20, 26, 0.06);
}
.sf__switch {
position: relative;
width: 38px;
height: 22px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.15);
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
flex: 0 0 auto;
}
.sf__switch[aria-checked=true] {
background: #2f6df6;
}
.sf__knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(23, 20, 26, 0.3);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sf__switch[aria-checked=true] .sf__knob {
transform: translateX(16px);
}
.sf__save {
margin-top: 16px;
width: 100%;
border: 0;
border-radius: 12px;
padding: 8px 16px;
background: #17141a;
color: #fff7e6;
font: inherit;
font-weight: 700;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.sf__save::before {
content: "";
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid transparent;
display: none;
}
.sf__save.is-busy::before {
display: block;
border-color: rgba(255, 247, 230, 0.3);
border-top-color: #fff7e6;
animation: sf-spin 0.6s linear infinite;
}
.sf__save.is-done {
background: #47905f;
}
@keyframes sf-spin {
to {
transform: rotate(360deg);
}
}
.sf__switch.is-demo .sf__knob {
animation: sf-glow 2s ease-in-out infinite;
}
@keyframes sf-glow {
0%, 100% {
box-shadow: 0 1px 3px rgba(23, 20, 26, 0.3), 0 0 0 0 rgba(47, 109, 246, 0);
}
50% {
box-shadow: 0 1px 3px rgba(23, 20, 26, 0.3), 0 0 0 6px rgba(47, 109, 246, 0.18);
}
}
@media (max-width: 340px) {
.sf {
padding: 12px;
}
.sf__row {
font-size: 11px;
}
}
@media (max-height: 220px) {
.sf {
padding: 8px 12px;
}
.sf__title {
margin-bottom: 4px;
}
.sf__row {
padding: 4px 0;
}
.sf__save {
margin-top: 8px;
padding: 4px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sf__switch.is-demo .sf__knob {
animation: none !important;
}
.sf__save.is-busy::before {
animation: none !important;
}
.sf__switch, .sf__knob {
transition: none;
}
}// 실제 동작: 스위치를 누르면 실제로 켜짐/꺼짐이 바뀌고, 저장을 누르면 버튼이 저장중 → 저장됨으로 실제 ステータス 전이를 한다.
document.querySelectorAll('.sf__switch').forEach(function (sw) {
function toggle() {
sw.classList.remove('is-demo');
var on = sw.getAttribute('aria-checked') === 'true';
sw.setAttribute('aria-checked', String(!on));
}
sw.addEventListener('click', toggle);
sw.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } });
});
var form = document.querySelector('.sf');
var save = document.querySelector('.sf__save');
var label = document.querySelector('.sf__save-label');
form.addEventListener('submit', function (e) {
e.preventDefault();
if (save.classList.contains('is-busy')) return;
save.classList.add('is-busy');
label.textContent = '保存中';
setTimeout(function () {
save.classList.remove('is-busy');
save.classList.add('is-done');
label.textContent = '保存済み';
setTimeout(function () {
save.classList.remove('is-done');
label.textContent = '保存';
}, 1600);
}, 700);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SettingsForm.module.scss";
export type SettingsFormProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SaveState = "idle" | "busy" | "done";
export default function SettingsForm({ duration = 300, easing = "cubic-bezier(.2,.8,.2,1)", color = "#2f6df6" }: SettingsFormProps) {
const [emailAlert, setEmailAlert] = useState(true);
const [weeklyReport, setWeeklyReport] = useState(false);
const [save, setSave] = useState<SaveState>("idle");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onSubmit(e: { preventDefault: () => void }) {
e.preventDefault();
if (save === "busy") return;
setSave("busy");
setTimeout(() => {
setSave("done");
setTimeout(() => setSave("idle"), 1600);
}, 700);
}
const label = save === "busy" ? "저장 중" : save === "done" ? "저장됨" : "저장";
return (
<form className={styles.sf} style={vars} onSubmit={onSubmit}>
<h2 className={styles.title}>알림</h2>
<label className={styles.row}>
<span>이메일 알림</span>
<span className={styles.switch} role="switch" aria-checked={emailAlert} tabIndex={0}
onClick={() => setEmailAlert((v) => !v)}
onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setEmailAlert((v) => !v); } }}
data-on={emailAlert}
>
<span className={styles.knob} />
</span>
</label>
<label className={styles.row}>
<span>주간 리포트</span>
<span className={styles.switch} role="switch" aria-checked={weeklyReport} tabIndex={0}
onClick={() => setWeeklyReport((v) => !v)}
onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setWeeklyReport((v) => !v); } }}
data-on={weeklyReport}
>
<span className={styles.knob} />
</span>
</label>
<button type="submit" className={`${styles.save} ${save === "busy" ? styles.busy : ""} ${save === "done" ? styles.done : ""}`}>
{label}
</button>
</form>
);
}07 · 保存状態付き設定フォームセクション — 計測 累積面積 0.167% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
設定ページ丸ごとではなく、このセクションだけを移植できるよう<form>ひとつで閉じています。
save.classList.add('is-busy'); label.textContent = '保存中';
setTimeout(function () {
save.classList.remove('is-busy'); save.classList.add('is-done'); label.textContent = '保存済み';
setTimeout(function () { save.classList.remove('is-done'); label.textContent = '保存'; }, 1600);
}, 700);
08通知ドロップダウンパネル
何が動くか
ベルアイコンを押すと、パネルが実際にその場から広がって開きます。

<main class="stage">
<div class="np">
<button class="np__bell is-demo" type="button" aria-label="通知" aria-expanded="true">
<span class="np__bell-ic" aria-hidden="true"></span>
<span class="np__badge">2</span>
</button>
<div class="np__panel is-open" role="menu" aria-label="通知リスト">
<p class="np__panel-title">通知</p>
<button class="np__item is-unread" type="button" role="menuitem">
<span class="np__dot" aria-hidden="true"></span>
<span class="np__item-body"><strong>新しいコメント</strong><span>パク・ハウンさんがコメントを残しました</span></span>
</button>
<button class="np__item is-unread" type="button" role="menuitem">
<span class="np__dot" aria-hidden="true"></span>
<span class="np__item-body"><strong>決済完了</strong><span>今月のサブスクが更新されました</span></span>
</button>
<button class="np__item" type="button" role="menuitem">
<span class="np__dot" aria-hidden="true"></span>
<span class="np__item-body"><strong>チーム招待</strong><span>チョン・ユナさんがチームに参加しました</span></span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// 드롭다운이 아래로 펼쳐지므로 무대 중앙 정렬 대신 위쪽에 앵커한다 (실측: 중앙 정렬이면 패널이 300px 아래로 넘친다)
.stage { align-items: flex-start; padding-top: $sp-8; }
.np { position: relative; box-sizing: border-box; width: min(280px, 88vw); display: flex; justify-content: flex-end; }
.np__bell {
position: relative;
width: $sp-8; height: $sp-8;
border-radius: 50%;
border: 1px solid rgba(255, 247, 230, .22);
background: rgba(255, 247, 230, .06);
cursor: pointer;
display: grid; place-items: center;
}
.np__bell-ic {
width: 14px; height: 14px;
border: 2px solid $subject-cream;
border-bottom: 0;
border-radius: $r-sm $r-sm 0 0;
position: relative;
transform-origin: top center; // 규칙8: 종 꼭지점 흔들림
}
.np__bell-ic::after {
content: "";
position: absolute; left: -3px; bottom: -4px;
width: 20px; height: 2px;
background: $subject-cream;
border-radius: $r-xs;
}
.np__badge {
position: absolute; top: 0; right: 0;
min-width: 15px; height: 15px; padding: 0 $sp-1;
border-radius: $r-pill;
background: $error;
color: $subject-cream;
font-size: 9px; font-weight: 800; line-height: 15px; text-align: center;
box-shadow: 0 0 0 2px $stage-ink;
}
.np__panel {
box-sizing: border-box;
position: absolute; top: calc(100% + #{$sp-2}); right: 0;
width: 232px;
border-radius: $r-card;
background: #201c26;
border: 1px solid rgba(255, 247, 230, .12);
box-shadow: $shadow-raised;
padding: $sp-2;
transform-origin: top right; // 규칙8: 종 버튼 모서리서 펼침
opacity: 0; transform: scale(.9) translateY(-4px);
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.np__panel.is-open { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; }
.np__panel-title { margin: $sp-1 $sp-2 $sp-2; font-size: 11px; font-weight: 800; color: rgba(255, 247, 230, .55); }
.np__item {
width: 100%;
display: flex; align-items: flex-start; gap: $sp-2;
border: 0; background: transparent; cursor: pointer;
padding: $sp-2;
border-radius: $r-sm;
text-align: left;
transition: background $dur-quick $easing;
}
.np__item:hover, .np__item:focus-visible { background: rgba(255, 247, 230, .06); }
.np__dot { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; margin-top: $sp-1; background: transparent; }
.np__item.is-unread .np__dot { background: $color; }
.np__item-body { display: flex; flex-direction: column; gap: 0; }
.np__item-body strong { font-size: 11px; color: $subject-cream; }
.np__item-body span { font-size: 10px; color: rgba(255, 247, 230, .5); }
// ── 자동 루프: 종이 살짝 흔들리고 패널이 열리는 모션을 반복한다 ──────────────
.np__bell.is-demo .np__bell-ic { animation: np-ring 2s ease-in-out infinite; }
.np__bell.is-demo + .np__panel { animation: np-pop 2s $easing infinite; }
@keyframes np-ring {
0%, 78%, 100% { transform: rotate(0deg); }
82% { transform: rotate(14deg); }
86% { transform: rotate(-12deg); }
90% { transform: rotate(8deg); }
94% { transform: rotate(0deg); }
}
@keyframes np-pop {
0% { opacity: 0; transform: scale(.9) translateY(-4px); }
16%, 100% { opacity: 1; transform: scale(1) translateY(0); }
}
@media (max-width: 340px) {
.np__panel { width: 200px; }
}
@media (max-height: 220px) {
.stage { padding-top: $sp-2; }
.np__panel { top: calc(100% + 4px); padding: $sp-1; }
.np__panel-title { display: none; }
.np__item { padding: 4px $sp-1; gap: $sp-1; }
.np__item-body strong { font-size: 10px; }
.np__item-body span { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.np__bell.is-demo .np__bell-ic { animation: none !important; }
.np__panel { animation: none !important; transition: none; }
}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);
}
.stage {
align-items: flex-start;
padding-top: 32px;
}
.np {
position: relative;
box-sizing: border-box;
width: min(280px, 88vw);
display: flex;
justify-content: flex-end;
}
.np__bell {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid rgba(255, 247, 230, 0.22);
background: rgba(255, 247, 230, 0.06);
cursor: pointer;
display: grid;
place-items: center;
}
.np__bell-ic {
width: 14px;
height: 14px;
border: 2px solid #fff7e6;
border-bottom: 0;
border-radius: 8px 8px 0 0;
position: relative;
transform-origin: top center;
}
.np__bell-ic::after {
content: "";
position: absolute;
left: -3px;
bottom: -4px;
width: 20px;
height: 2px;
background: #fff7e6;
border-radius: 4px;
}
.np__badge {
position: absolute;
top: 0;
right: 0;
min-width: 15px;
height: 15px;
padding: 0 4px;
border-radius: 999px;
background: #c2361a;
color: #fff7e6;
font-size: 9px;
font-weight: 800;
line-height: 15px;
text-align: center;
box-shadow: 0 0 0 2px #17141a;
}
.np__panel {
box-sizing: border-box;
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 232px;
border-radius: 16px;
background: #201c26;
border: 1px solid rgba(255, 247, 230, 0.12);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px;
transform-origin: top right;
opacity: 0;
transform: scale(0.9) translateY(-4px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.np__panel.is-open {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.np__panel-title {
margin: 4px 8px 8px;
font-size: 11px;
font-weight: 800;
color: rgba(255, 247, 230, 0.55);
}
.np__item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 8px;
border: 0;
background: transparent;
cursor: pointer;
padding: 8px;
border-radius: 8px;
text-align: left;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.np__item:hover, .np__item:focus-visible {
background: rgba(255, 247, 230, 0.06);
}
.np__dot {
flex: 0 0 auto;
width: 7px;
height: 7px;
border-radius: 50%;
margin-top: 4px;
background: transparent;
}
.np__item.is-unread .np__dot {
background: #2f6df6;
}
.np__item-body {
display: flex;
flex-direction: column;
gap: 0;
}
.np__item-body strong {
font-size: 11px;
color: #fff7e6;
}
.np__item-body span {
font-size: 10px;
color: rgba(255, 247, 230, 0.5);
}
.np__bell.is-demo .np__bell-ic {
animation: np-ring 2s ease-in-out infinite;
}
.np__bell.is-demo + .np__panel {
animation: np-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes np-ring {
0%, 78%, 100% {
transform: rotate(0deg);
}
82% {
transform: rotate(14deg);
}
86% {
transform: rotate(-12deg);
}
90% {
transform: rotate(8deg);
}
94% {
transform: rotate(0deg);
}
}
@keyframes np-pop {
0% {
opacity: 0;
transform: scale(0.9) translateY(-4px);
}
16%, 100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@media (max-width: 340px) {
.np__panel {
width: 200px;
}
}
@media (max-height: 220px) {
.stage {
padding-top: 8px;
}
.np__panel {
top: calc(100% + 4px);
padding: 4px;
}
.np__panel-title {
display: none;
}
.np__item {
padding: 4px 4px;
gap: 4px;
}
.np__item-body strong {
font-size: 10px;
}
.np__item-body span {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.np__bell.is-demo .np__bell-ic {
animation: none !important;
}
.np__panel {
animation: none !important;
transition: none;
}
}// 실제 동작: 종을 누르면 패널이 실제로 열리고, 알림을 누르면 그 항목만 읽음 처리되며 배지 숫자가 실제로 줄어든다.
var bell = document.querySelector('.np__bell');
var panel = document.querySelector('.np__panel');
var badge = document.querySelector('.np__badge');
function unreadCount() { return document.querySelectorAll('.np__item.is-unread').length; }
function syncBadge() {
var n = unreadCount();
badge.textContent = String(n);
badge.style.display = n > 0 ? '' : 'none';
}
bell.addEventListener('click', function () {
bell.classList.remove('is-demo');
var open = bell.getAttribute('aria-expanded') === 'true';
bell.setAttribute('aria-expanded', String(!open));
panel.classList.toggle('is-open', !open);
});
document.querySelectorAll('.np__item').forEach(function (it) {
it.addEventListener('click', function () {
it.classList.remove('is-unread');
syncBadge();
});
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.np')) { bell.setAttribute('aria-expanded', 'false'); panel.classList.remove('is-open'); }
});
syncBadge();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NotificationsPanel.module.scss";
export type NotificationsPanelProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Item = { id: string; title: string; desc: string; unread: boolean };
const INITIAL: Item[] = [
{ id: "a", title: "새 댓글", desc: "박하은님이 댓글을 남겼습니다", unread: true },
{ id: "b", title: "결제 완료", desc: "이번 달 구독이 갱신됐습니다", unread: true },
{ id: "c", title: "팀 초대", desc: "정유나님이 팀에 합류했습니다", unread: false },
];
export default function NotificationsPanel({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: NotificationsPanelProps) {
const [open, setOpen] = useState(true);
const [items, setItems] = useState(INITIAL);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const unread = items.filter((i) => i.unread).length;
function markRead(id: string) {
setItems((list) => list.map((i) => (i.id === id ? { ...i, unread: false } : i)));
}
return (
<div className={styles.np} style={vars}>
<button type="button" className={styles.bell} aria-label="알림" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
<span className={styles.bellIc} />
{unread > 0 && <span className={styles.badge}>{unread}</span>}
</button>
<div className={`${styles.panel} ${open ? styles.open : ""}`} role="menu" aria-label="알림 목록">
<p className={styles.panelTitle}>알림</p>
{items.map((it) => (
<button key={it.id} type="button" role="menuitem" className={styles.item} onClick={() => markRead(it.id)}>
<span className={`${styles.dot} ${it.unread ? styles.unread : ""}`} />
<span className={styles.itemBody}><strong>{it.title}</strong><span>{it.desc}</span></span>
</button>
))}
</div>
</div>
);
}08 · 通知ドロップダウンパネル — 計測 累積面積 5.662% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
通知を1件押すとその項目だけ既読になり、上のバッジの数字が残りの未読数だけ実際に減ります。
function syncBadge() {
var n = document.querySelectorAll('.np__item.is-unread').length;
badge.textContent = String(n);
badge.style.display = n > 0 ? '' : 'none';
}
it.addEventListener('click', function () { it.classList.remove('is-unread'); syncBadge(); });
09ユーザーアバタードロップダウン
何が動くか
アバターを押すと、メニューが右上を基点に実際に広がって開きます。

<main class="stage">
<div class="um">
<button class="um__avatar is-demo" type="button" aria-haspopup="menu" aria-expanded="true" aria-label="アカウントメニュー">
<span class="um__ring" aria-hidden="true"></span>
<span class="um__initial" aria-hidden="true">D</span>
</button>
<div class="um__menu is-open" role="menu">
<div class="um__who">
<strong>ドユン</strong>
<span>[email protected]</span>
</div>
<button class="um__item" type="button" role="menuitem"><span class="um__ic" aria-hidden="true"></span>プロフィール</button>
<button class="um__item" type="button" role="menuitem"><span class="um__ic" aria-hidden="true"></span>設定</button>
<button class="um__item um__item--danger" type="button" role="menuitem"><span class="um__ic" aria-hidden="true"></span>ログアウト</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-orange);
// 드롭다운이 아래로 펼쳐지므로 무대 중앙 정렬 대신 위쪽에 앵커한다 (실측: 중앙 정렬이면 메뉴가 300px 아래로 넘친다)
.stage { align-items: flex-start; padding-top: $sp-8; }
.um { position: relative; box-sizing: border-box; width: min(260px, 86vw); display: flex; justify-content: flex-end; }
.um__avatar {
position: relative;
width: $sp-8; height: $sp-8;
border-radius: 50%;
border: 0;
background: $stage-ink;
color: $subject-cream;
font-weight: 800; font-size: 14px;
cursor: pointer;
display: grid; place-items: center;
}
.um__ring {
position: absolute; inset: -3px;
border-radius: 50%;
border: 2px solid $subject-cream;
opacity: 0;
}
.um__initial { position: relative; z-index: 1; }
.um__menu {
box-sizing: border-box;
position: absolute; top: calc(100% + #{$sp-2}); right: 0;
width: 190px;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
padding: $sp-2;
transform-origin: top right; // 규칙8: 아바타 모서리서 펼침
opacity: 0; transform: scale(.92) translateY(-4px);
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.um__menu.is-open { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; }
.um__who { padding: $sp-2; border-bottom: 1px solid rgba(23, 20, 26, .1); margin-bottom: $sp-1; }
.um__who strong { display: block; font-size: 12px; color: $stage-ink; }
.um__who span { display: block; font-size: 10px; color: rgba(23, 20, 26, .72); }
.um__item {
width: 100%;
display: flex; align-items: center; gap: $sp-2;
border: 0; background: transparent; cursor: pointer;
padding: $sp-2; border-radius: $r-sm;
font: inherit; font-weight: 600; font-size: 12px; color: $stage-ink; text-align: left;
transition: background $dur-quick $easing;
}
.um__item:hover, .um__item:focus-visible { background: rgba(23, 20, 26, .06); }
.um__item--danger { color: $error; }
.um__ic { width: $sp-3; height: $sp-3; border-radius: $r-xs; background: currentColor; opacity: .55; }
// ── 자동 루프: 아바타 링이 은은하게 퍼지고 메뉴가 열리는 모션을 반복한다 ──────────────
.um__avatar.is-demo .um__ring { animation: um-pulse 2s ease-out infinite; }
.um__avatar.is-demo + .um__menu { animation: um-pop 2s $easing infinite; }
@keyframes um-pulse {
0% { opacity: .55; transform: scale(1); }
70%, 100% { opacity: 0; transform: scale(1.35); }
}
@keyframes um-pop {
0% { opacity: 0; transform: scale(.92) translateY(-4px); }
18%, 100% { opacity: 1; transform: scale(1) translateY(0); }
}
@media (max-width: 340px) {
.um__menu { width: 168px; }
}
@media (max-height: 220px) {
.stage { padding-top: $sp-2; }
.um__menu { top: calc(100% + 4px); padding: $sp-1; }
.um__who { padding: $sp-1; margin-bottom: $sp-1; }
.um__who span { display: none; }
.um__item { padding: 4px $sp-1; gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.um__avatar.is-demo .um__ring { animation: none !important; }
.um__menu { animation: none !important; transition: none; }
}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);
}
.stage {
align-items: flex-start;
padding-top: 32px;
}
.um {
position: relative;
box-sizing: border-box;
width: min(260px, 86vw);
display: flex;
justify-content: flex-end;
}
.um__avatar {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
border: 0;
background: #17141a;
color: #fff7e6;
font-weight: 800;
font-size: 14px;
cursor: pointer;
display: grid;
place-items: center;
}
.um__ring {
position: absolute;
inset: -3px;
border-radius: 50%;
border: 2px solid #fff7e6;
opacity: 0;
}
.um__initial {
position: relative;
z-index: 1;
}
.um__menu {
box-sizing: border-box;
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 190px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px;
transform-origin: top right;
opacity: 0;
transform: scale(0.92) translateY(-4px);
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.um__menu.is-open {
opacity: 1;
transform: scale(1) translateY(0);
pointer-events: auto;
}
.um__who {
padding: 8px;
border-bottom: 1px solid rgba(23, 20, 26, 0.1);
margin-bottom: 4px;
}
.um__who strong {
display: block;
font-size: 12px;
color: #17141a;
}
.um__who span {
display: block;
font-size: 10px;
color: rgba(23, 20, 26, 0.72);
}
.um__item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
cursor: pointer;
padding: 8px;
border-radius: 8px;
font: inherit;
font-weight: 600;
font-size: 12px;
color: #17141a;
text-align: left;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.um__item:hover, .um__item:focus-visible {
background: rgba(23, 20, 26, 0.06);
}
.um__item--danger {
color: #c2361a;
}
.um__ic {
width: 12px;
height: 12px;
border-radius: 4px;
background: currentColor;
opacity: 0.55;
}
.um__avatar.is-demo .um__ring {
animation: um-pulse 2s ease-out infinite;
}
.um__avatar.is-demo + .um__menu {
animation: um-pop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes um-pulse {
0% {
opacity: 0.55;
transform: scale(1);
}
70%, 100% {
opacity: 0;
transform: scale(1.35);
}
}
@keyframes um-pop {
0% {
opacity: 0;
transform: scale(0.92) translateY(-4px);
}
18%, 100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@media (max-width: 340px) {
.um__menu {
width: 168px;
}
}
@media (max-height: 220px) {
.stage {
padding-top: 8px;
}
.um__menu {
top: calc(100% + 4px);
padding: 4px;
}
.um__who {
padding: 4px;
margin-bottom: 4px;
}
.um__who span {
display: none;
}
.um__item {
padding: 4px 4px;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.um__avatar.is-demo .um__ring {
animation: none !important;
}
.um__menu {
animation: none !important;
transition: none;
}
}// 실제 동작: 아바타를 누르면 메뉴가 실제로 열리고, 바깥을 클릭하거나 Esc를 누르면 실제로 닫힌다.
var avatar = document.querySelector('.um__avatar');
var menu = document.querySelector('.um__menu');
function close() {
avatar.setAttribute('aria-expanded', 'false');
menu.classList.remove('is-open');
}
function open() {
avatar.setAttribute('aria-expanded', 'true');
menu.classList.add('is-open');
}
avatar.addEventListener('click', function () {
avatar.classList.remove('is-demo');
var expanded = avatar.getAttribute('aria-expanded') === 'true';
expanded ? close() : open();
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.um')) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UserMenu.module.scss";
export type UserMenuProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function UserMenu({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: UserMenuProps) {
const [open, setOpen] = useState(true);
const ref = useRef<HTMLDivElement | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("click", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, []);
return (
<div className={styles.um} style={vars} ref={ref}>
<button type="button" className={styles.avatar} aria-haspopup="menu" aria-expanded={open} aria-label="계정 메뉴" onClick={() => setOpen((v) => !v)}>
D
</button>
<div className={`${styles.menu} ${open ? styles.open : ""}`} role="menu">
<div className={styles.who}>
<strong>도윤</strong>
<span>[email protected]</span>
</div>
<button type="button" role="menuitem" className={styles.item}>프로필</button>
<button type="button" role="menuitem" className={styles.item}>설정</button>
<button type="button" role="menuitem" className={`${styles.item} ${styles.danger}`}>로그아웃</button>
</div>
</div>
);
}09 · ユーザーアバタードロップダウン — 計測 累積面積 28.833% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードで見る所
外側をクリックするか Esc キーを押すと実際に閉じ、メニュー自体をクリックしたときは閉じないよう closest('.um') で範囲を区別しています。
document.addEventListener('click', function (e) {
if (!e.target.closest('.um')) close();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
どこで壊れるか — 落とし穴
ダッシュボード デザイン、どこで壊れるか?
9個を作る間に実際にはまった落とし穴は、通知とユーザーメニュー2つのポップアップの縦位置でした。舞台のCSSがウィジェットを画面中央に揃えるため、ベルやアバターがキャンバス中央に来て、その下に開くパネルが300pxの舞台の底より下にはみ出し、スクロールバーが出てしまいました。中央揃えではなく上揃えに変えて、ようやくパネル全体が1画面に収まりました。幅320px・縦が短い画面ではさらに厳しく、パネルの見出しと説明文まで全部入れると縦200pxに収まらず、狭い画面では見出しと通知の説明文だけを隠し、太字の項目名だけ残す調整を別に行いました。ドーナツチャートは最初数字が上がる部品だけを集めたのが天気 ウィジェット 9選です。SVGの弧2本で描こうとしましたが、開始角度をそろえる計算がずれ続けたため、conic-gradientと@propertyに切り替えてから、値ひとつの変更だけで滑らかに切り替わるようになりました。並び替え表は最初、行ごとに背景色が順番に流れる作りにしましたが、実測すると隣り合うフレーム間の変化がほとんどなく「動いていない」と判定されました。表の上から下へ実際に移動するハイライト帯1本に変えたところ、この9個の圧縮ファイルのパスワードrzae7dqhで開いた最新のzipでは、表も他の8個と同じくはっきり動きます。
アクセシビリティ
押さえた点
9個すべて prefers-reduced-motion: reduce では装飾用の繰り返しアニメーション(サイドバーの自動開閉、スパークラインの自動描画、フィルターチップの自動押下、ベルとアバターの淡いパルス)を止めますが、実際の状態値はそのまま残します。通知パネル(08)とユーザーメニュー(09)は role="menu"・role="menuitem" を備え、外側クリックやEscで閉じます。設定フォーム(07)のトグルは role="switch" と aria-checked を持ち、Enterとスペースキーだけで操作できます。並び替え表(03)のボタンは aria-sort で今の並び順を伝え、空状態(06)で追加されたカードは aria-hidden を実際に切り替えて、見えないカードをスクリーンリーダーが読まないようにしています。この実装はMDNのARIAウィジェットロール文書を参考にしました。
次に見る所
ほかのテンプレート素材はテンプレートカテゴリーに、このサイト全体の内容は紹介ページにあります。
FAQ
完成した管理画面テンプレートではなく、この ウィジェット デザイン 1個だけ使ってもいいですか?
はい、それがこの9個を作った理由です。どのデモもページ全体ではなくウィジェット1個だけを画面に置いていて、zipの中にもその1個分のHTML・SCSS・JSだけが入っているので、自分のプロジェクトのサイドバーやヘッダーにそのまま移せます。初めて開いた人を画面に沿って案内する側はチュートリアル UI デザインで別に扱っています。
React版はCSS版と動きが違いますか?
動きは同じです。開閉や選択のような状態は useState で持ち、変数3つ(duration・easing・color)はpropsで受け取ってCSSカスタムプロパティに渡します。ドーナツのカウントアップは useEffect の中で requestAnimationFrame を使って同じ計算を繰り返します。
通知バッジの数字やサイドバーの折りたたみは実際に動いていますか、それとも装飾ですか?
実際に動いています。通知を押すとその項目の未読状態が実際に変わり、バッジは残りの件数を数え直して表示します。サイドバーのボタンも幅の値を実際に変えます。自動再生のGIFで見える繰り返しの動きは、人が触る前だけ回る装飾ループで、クリックやタップした瞬間に止まります。








