대시보드 사이드바·위젯 UI 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 단독 사이드바 내비게이션
- 02 스탯 카드 로우 (스파크라인 호버)
- 03 정렬 가능한 미니 테이블
- 04 도넛 차트 카드 (가운데 카운터)
- 05 칩형 필터 바
- 06 빈 상태 일러스트 (추가 모프)
- 07 설정 폼 섹션 (저장 상태)
- 08 알림 드롭다운 패널
- 09 유저 아바타 드롭다운 메뉴
아홉 개는 인기순이 아니라 화면에 들어오는 순서대로 놓았습니다. 먼저 전체 구조를 잡는 자리(사이드바)를 놓고, 한눈에 보이는 요약(스탯 카드·표·차트)을 지나, 사용자가 직접 조작하는 자리(필터·빈 화면·설정)를 거쳐, 마지막으로 헤더 구석에 붙는 작은 팝업 둘(알림·유저 메뉴)로 마무리합니다. 완성된 대시보드 템플릿이 아니라 이 조각 하나만 떼어 내 프로젝트에 붙일 수 있도록, 각 데모는 페이지가 아니라 위젯 하나만 화면 가운데 놓았습니다. 같은 대시보드 부품을 통계 카드 중심으로 다시 모은 편이 대시보드 컴포넌트 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
지금 선택된 메뉴는 왼쪽의 얇은 세로 막대와 옅은 배경으로 표시됩니다.
.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; }
.sd__item.is-active .sd__link::before {
content: "";
position: absolute; left: 0; top: 18%; bottom: 18%;
width: 3px; background: $color;
}
02스탯 카드 로우 (스파크라인 드로잉)
무엇이 움직이나
기간 스위치를 누르면 방문자·전환율·매출 세 카드의 숫자가 실제로 바뀌고, 카드 아래 미니 그래프 선은 pathLength 속성으로 길이를 100으로 맞춰 두어 어떤 모양이든 같은 방식으로 그려집니다.

<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="₩842만" data-month="₩3,180만">₩842만</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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
이름 열은 문자열 비교로, 금액 열은 숫자 비교로 정렬하도록 나눠 두었습니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
링은 SVG가 아니라 conic-gradient와 등록된 사용자 지정 속성(@property)만으로 그렸습니다.
@property --pct-a { syntax: "<percentage>"; inherits: true; initial-value: 0%; }
@property --pct-b { 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
하나라도 선택되면 '전체 해제' 글자 버튼이 나타납니다.
c.addEventListener('click', function () {
c.classList.remove('is-demo');
var on = c.getAttribute('aria-pressed') === 'true';
c.setAttribute('aria-pressed', String(!on));
refresh();
});
06빈 상태 일러스트 (추가 모프)
무엇이 움직이나
도형만으로 그린 빈 상자 일러스트가 계속 살짝 떠 있다가, '첫 항목 추가' 버튼을 누르면 그 자리가 실제로 리스트 카드 한 장으로 바뀝니다. 다시 누르면 원래 빈 상태로 되돌아갑니다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
.es__empty, .es__item { position: absolute; inset: 0; transition: opacity $duration $easing, transform $duration $easing; }
.es__item { 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); }
07설정 폼 섹션 (저장 상태)
무엇이 움직이나
토글을 켜고 저장 버튼을 누르면 버튼이 링 스피너로 바뀌었다가, 700ms 뒤 실제로 초록색 '저장됨' 상태로 바뀌고 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
완성된 설정 페이지가 아니라 이 섹션 하나만 옮겨 붙일 수 있게 폼 태그 하나로 닫아 두었습니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
알림 한 줄을 누르면 그 항목만 읽음 처리되고, 위 배지 숫자가 남은 안 읽은 개수만큼 실제로 줄어듭니다.
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
메뉴 바깥을 클릭하거나 Esc 키를 누르면 실제로 닫히고, 메뉴 자체를 클릭했을 땐 닫히지 않도록 closest('.um')으로 안쪽 클릭인지 구분했습니다.
document.addEventListener('click', function (e) {
if (!e.target.closest('.um')) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});
어디서 깨지는가 — 함정
대시보드 위젯, 어디서 깨지나요?
아홉 개를 만드는 동안 진짜 걸린 함정은 드롭다운 두 개(알림·유저 메뉴)의 세로 위치였습니다. 무대 CSS가 위젯을 화면 가운데 정렬하다 보니, 종 아이콘이나 아바타가 캔버스 중앙에 놓이고 그 아래로 펼쳐지는 패널이 300px 무대 바닥 밑으로 넘어가 스크롤바가 떴습니다. 정렬을 가운데 대신 위쪽으로 바꾸고 나서야 패널 전체가 한 화면에 들어왔습니다. 320px 폭·짧은 세로 화면에서는 더 심했습니다 — 패널 제목과 설명 줄까지 다 넣으면 세로 200px 안에 못 들어가, 좁은 화면에서는 패널 제목과 알림 설명 문장을 숨기고 진한 글자만 남기도록 따로 손봤습니다. 정렬 가능한 표는 처음엔 행마다 배경색이 순서대로 훑고 지나가게 만들었는데, 실제로 측정해 보니 프레임 사이 변화가 너무 작아 "움직이지 않는 것"으로 잡혔습니다. 숫자가 오르는 조각만 따로 떼어 모은 편은 날씨 위젯 9종입니다. 하이라이트 띠 하나가 표 위에서 아래로 실제로 이동하는 방식으로 바꾸자, 압축 비밀번호 rzae7dqh로 연 최신 zip에서는 표도 다른 여덟 개처럼 또렷하게 움직입니다. 도넛 차트는 SVG로 먼저 그리려다 두 구간의 시작 각도를 맞추는 계산이 계속 어긋나, conic-gradient와 @property로 바꾸고 나서야 값 하나만 바꿔도 부드럽게 전환됐습니다.
접근성
챙긴 것
아홉 개 전부 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
완성된 관리자 템플릿 대신 위젯형 대시보드처럼 이 조각 하나만 써도 되나요?
네, 그게 이 아홉 개를 만든 이유입니다. 각 데모는 페이지 전체가 아니라 위젯 하나만 화면에 두었고, zip 안에도 그 위젯 하나의 HTML·SCSS·JS만 들어 있어 자기 프로젝트의 사이드바나 헤더에 그대로 옮기면 됩니다. 처음 보는 사람을 화면 따라 안내하는 쪽은 웹 온보딩 투어 위젯에서 따로 다뤘습니다.
React 버전은 CSS 버전과 다르게 동작하나요?
동작은 같습니다. 열림·닫힘·선택 같은 상태는 useState로 들고, 변수 3개(duration·easing·color)는 props로 받아 CSS 사용자 지정 속성으로 넘깁니다. 도넛 차트의 숫자 카운트업은 useEffect 안에서 requestAnimationFrame으로 같은 계산을 반복합니다.
알림 배지 숫자나 사이드바 접힘은 실제로 작동하나요, 장식인가요?
실제로 작동합니다. 알림을 누르면 그 항목의 안 읽음 상태가 실제로 바뀌고 배지 숫자는 남은 개수를 다시 세어 표시하며, 사이드바 접기 버튼은 실제로 폭 값을 바꿉니다. 자동 재생 GIF에서 보이는 반복 움직임은 사람이 손대지 않았을 때만 도는 장식 루프이고, 첫 클릭·탭 순간 바로 꺼집니다.








