대시보드 컴포넌트 9종 — 화면 통째로 말고 이 부품만
대시보드 컴포넌트는 완성된 관리자 화면을 통째로 붙이지 않고 헤더·피드·캘린더처럼 화면 조각 하나만 그대로 옮겨 쓰는 UI 부품입니다. 아홉 개 중 하나는 Ctrl(⌘)+K로 여는 커맨드 팔레트인데, 방향키로 실제로 움직입니다.
01 page-header
02 activity-feed
03 calendar-widget
04 kanban-board
06 team-list
07 command-palette
08 tag-manager
09 file-upload
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 브레드크럼 + 페이지 헤더
- 02 액티비티 피드 타임라인
- 03 월간 캘린더 위젯
- 04 칸반 보드 미니 위젯
- 05 목표 진행률 스텝 카드
- 06 팀 멤버 리스트 (검색)
- 07 커맨드 팔레트 (⌘K)
- 08 태그 관리 입력 위젯
- 09 파일 업로드 드롭존
대시보드 디자인 추천 아홉 개는 화면에 들어오는 순서가 아니라 대시보드를 쓰는 사람이 실제로 거치는 순서대로 놓았습니다. 지금 어디에 있는지 알려주는 헤더에서 시작해 최근 활동을 확인하고, 일정(캘린더)과 작업(칸반)을 관리한 뒤 목표 진행률을 보고, 협업(팀·검색)과 정리(태그·파일)로 마무리합니다. 완성된 관리자 템플릿이 아니라 이 부품 하나만 떼어 내 프로젝트에 붙일 수 있도록, 데모마다 위젯 하나만 화면 가운데 두었습니다. 같은 부품을 사이드바와 스탯 카드 중심으로 다시 자른 편이 대시보드 사이드바·위젯 UI 9종이고, 숫자가 실제로 차오르는 원형 게이지만 모은 편이 원형 프로그레스바 스탯 링 위젯 9종입니다.
01브레드크럼 + 페이지 헤더
대시보드 디자인 예시에서 무엇이 보이나
케밥(⋮) 버튼을 누르면 오른쪽 위 모서리를 기준으로 메뉴가 실제로 커지며 열리고, 바깥을 클릭하거나 Esc를 누르면 실제로 닫힙니다.

<main class="stage">
<div class="ph is-demo">
<nav class="ph__crumbs" aria-label="이동 경로">
<a class="ph__crumb" href="#">워크스페이스</a>
<span class="ph__sep" aria-hidden="true"><svg viewBox="0 0 256 256" width="10" height="10"><g fill="currentColor"><path d="M180 128a4 4 0 0 1-1.17 2.83l-88 88a4 4 0 1 1-5.66-5.66L167.51 128L85.17 45.83a4 4 0 1 1 5.66-5.66l88 88A4 4 0 0 1 180 128" opacity=".2"/><path d="M181.66 133.66l-88 88a8 8 0 0 1-11.32-11.32L163.31 128L82.34 47.66a8 8 0 0 1 11.32-11.32l88 88a8 8 0 0 1 0 11.32"/></g></svg></span>
<a class="ph__crumb" href="#">프로젝트</a>
<span class="ph__sep" aria-hidden="true"><svg viewBox="0 0 256 256" width="10" height="10"><g fill="currentColor"><path d="M180 128a4 4 0 0 1-1.17 2.83l-88 88a4 4 0 1 1-5.66-5.66L167.51 128L85.17 45.83a4 4 0 1 1 5.66-5.66l88 88A4 4 0 0 1 180 128" opacity=".2"/><path d="M181.66 133.66l-88 88a8 8 0 0 1-11.32-11.32L163.31 128L82.34 47.66a8 8 0 0 1 11.32-11.32l88 88a8 8 0 0 1 0 11.32"/></g></svg></span>
<span class="ph__crumb ph__crumb--current" aria-current="page">설정</span>
</nav>
<div class="ph__row">
<h2 class="ph__title">설정</h2>
<div class="ph__actions">
<button class="ph__icon-btn" type="button" aria-label="공유">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M188 96a28 28 0 1 0-25.94-38.7l-58.66 34.36a28 28 0 1 0 0 44.68l58.66 34.36A28 28 0 1 0 176 148a27.7 27.7 0 0 0-1.63 9.4l-58.66-34.36a27.9 27.9 0 0 0 0-18.08l58.66-34.36A27.9 27.9 0 0 0 188 96" opacity=".2"/><path d="M188 152a35.7 35.7 0 0 0-23.6 8.9l-56.1-32.87a36.2 36.2 0 0 0 0-16l56.1-32.87A36 36 0 1 0 152 60a35.6 35.6 0 0 0 1.09 8.66l-56.1 32.87a36 36 0 1 0 0 52.94l56.1 32.87A36 36 0 1 0 188 152m0-128a20 20 0 1 1-20 20a20 20 0 0 1 20-20M60 148a20 20 0 1 1 20-20a20 20 0 0 1-20 20m128 76a20 20 0 1 1 20-20a20 20 0 0 1-20 20"/></g></svg>
</button>
<button class="ph__icon-btn" type="button" aria-label="내보내기">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg>
</button>
<div class="ph__menu-wrap">
<button class="ph__kebab" type="button" aria-haspopup="true" aria-expanded="false" aria-label="더 보기">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M176 32v192a16 16 0 0 1-16 16H96a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16" opacity=".2"/><path d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-12-56a12 12 0 1 0-12-12a12 12 0 0 0 12 12m0 112a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<div class="ph__menu" role="menu">
<button class="ph__menu-item" role="menuitem" type="button">이름 바꾸기</button>
<button class="ph__menu-item ph__menu-item--danger" role="menuitem" type="button">삭제</button>
</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.ph {
box-sizing: border-box;
width: min(280px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.ph__crumbs { display: flex; align-items: center; gap: $sp-1; margin: 0 0 $sp-2; }
.ph__crumb {
font-size: 11px; font-weight: 600; color: rgba(23, 20, 26, .72);
text-decoration: none;
}
.ph__crumb--current { color: $stage-ink; }
.ph__sep { display: inline-flex; color: rgba(23, 20, 26, .3); }
.ph__row { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.ph__title { margin: 0; font-size: 18px; font-weight: 800; }
.ph__actions { display: flex; align-items: center; gap: $sp-1; position: relative; }
.ph__icon-btn, .ph__kebab {
box-sizing: border-box;
width: 28px; height: 28px;
display: grid; place-items: center;
border: 1px solid rgba(23, 20, 26, .12);
background: #fff;
border-radius: $r-control;
color: rgba(23, 20, 26, .6);
cursor: pointer;
transition: background $duration $easing, color $duration $easing;
}
.ph__icon-btn:hover, .ph__kebab:hover { background: rgba(47, 109, 246, .08); color: $color; }
.ph__menu-wrap { position: relative; }
.ph__menu {
position: absolute;
top: 30px; right: 0;
box-sizing: border-box;
width: 116px;
background: #fff;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
transform-origin: top right; // 규칙8: 케밥 오른쪽 변에서 팝
transform: scale(.85);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ph__menu-wrap.is-open .ph__menu { transform: scale(1); opacity: 1; pointer-events: auto; }
.ph__menu-item {
display: block;
width: 100%;
box-sizing: border-box;
text-align: left;
border: 0; background: transparent; cursor: pointer;
font: inherit; font-size: 11px; font-weight: 600; color: $stage-ink;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
}
.ph__menu-item:hover { background: rgba(23, 20, 26, .06); }
.ph__menu-item--danger { color: $error; }
// ── 자동 루프: 메뉴가 열렸다 닫혔다를 반복한다 (실제 상태와 별개) ──────────────
.ph.is-demo .ph__menu {
animation: ph-menu-loop 2s $easing infinite;
}
@keyframes ph-menu-loop {
0%, 100% { transform: scale(.85); opacity: 0; }
40% { transform: scale(1); opacity: 1; }
85% { transform: scale(1); opacity: 1; }
}
@media (max-width: 340px) {
.ph__title { font-size: 16px; }
.ph__menu { width: 116px; }
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__menu { animation: none !important; }
.ph__menu, .ph__icon-btn, .ph__kebab { 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);
}
.ph {
box-sizing: border-box;
width: min(280px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ph__crumbs {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.ph__crumb {
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
text-decoration: none;
}
.ph__crumb--current {
color: #17141a;
}
.ph__sep {
display: inline-flex;
color: rgba(23, 20, 26, 0.3);
}
.ph__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ph__title {
margin: 0;
font-size: 18px;
font-weight: 800;
}
.ph__actions {
display: flex;
align-items: center;
gap: 4px;
position: relative;
}
.ph__icon-btn, .ph__kebab {
box-sizing: border-box;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 1px solid rgba(23, 20, 26, 0.12);
background: #fff;
border-radius: 12px;
color: rgba(23, 20, 26, 0.6);
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ph__icon-btn:hover, .ph__kebab:hover {
background: rgba(47, 109, 246, 0.08);
color: #2f6df6;
}
.ph__menu-wrap {
position: relative;
}
.ph__menu {
position: absolute;
top: 30px;
right: 0;
box-sizing: border-box;
width: 116px;
background: #fff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
transform-origin: top right;
transform: scale(0.85);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ph__menu-wrap.is-open .ph__menu {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.ph__menu-item {
display: block;
width: 100%;
box-sizing: border-box;
text-align: left;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 600;
color: #17141a;
padding: 4px 8px;
border-radius: 4px;
}
.ph__menu-item:hover {
background: rgba(23, 20, 26, 0.06);
}
.ph__menu-item--danger {
color: #c2361a;
}
.ph.is-demo .ph__menu {
animation: ph-menu-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ph-menu-loop {
0%, 100% {
transform: scale(0.85);
opacity: 0;
}
40% {
transform: scale(1);
opacity: 1;
}
85% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 340px) {
.ph__title {
font-size: 16px;
}
.ph__menu {
width: 116px;
}
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__menu {
animation: none !important;
}
.ph__menu, .ph__icon-btn, .ph__kebab {
transition: none;
}
}// 실제 동작: 케밥 버튼을 누르면 메뉴가 실제로 열리고, 바깥 클릭이나 Esc로 실제로 닫힌다.
var wrap = document.querySelector('.ph__menu-wrap');
var kebab = document.querySelector('.ph__kebab');
var menu = document.querySelector('.ph__menu');
function close() {
wrap.classList.remove('is-open');
kebab.setAttribute('aria-expanded', 'false');
}
kebab.addEventListener('click', function (e) {
document.querySelector('.ph').classList.remove('is-demo');
e.stopPropagation();
var open = wrap.classList.toggle('is-open');
kebab.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.ph__menu-wrap')) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { close(); kebab.focus({ preventScroll: true }); }
});
menu.querySelectorAll('.ph__menu-item').forEach(function (it) {
it.addEventListener('click', close);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PageHeader.module.scss";
export type PageHeaderProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PageHeader({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: PageHeaderProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const kebabRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); kebabRef.current?.focus({ preventScroll: true }); }
}
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("click", onDocClick); document.removeEventListener("keydown", onKey); };
}, []);
return (
<div className={styles.ph} style={vars}>
<nav className={styles.crumbs} aria-label="Breadcrumb">
<a className={styles.crumb} href="#">Workspace</a>
<span className={styles.sep} aria-hidden="true">›</span>
<a className={styles.crumb} href="#">Projects</a>
<span className={styles.sep} aria-hidden="true">›</span>
<span className={`${styles.crumb} ${styles.current}`} aria-current="page">Settings</span>
</nav>
<div className={styles.row}>
<h2 className={styles.title}>Settings</h2>
<div className={styles.actions} ref={wrapRef}>
<button type="button" className={styles.iconBtn} aria-label="Share">⇪</button>
<button type="button" className={styles.iconBtn} aria-label="Export">⇩</button>
<div className={styles.menuWrap}>
<button
ref={kebabRef}
type="button"
className={styles.kebab}
aria-haspopup="true"
aria-expanded={open}
aria-label="More actions"
onClick={() => setOpen((o) => !o)}
>
⋮
</button>
<div className={`${styles.menu} ${open ? styles.open : ""}`} role="menu">
<button className={styles.menuItem} role="menuitem" type="button" onClick={() => setOpen(false)}>Rename</button>
<button className={`${styles.menuItem} ${styles.danger}`} role="menuitem" type="button" onClick={() => setOpen(false)}>Delete</button>
</div>
</div>
</div>
</div>
</div>
);
}01 · 브레드크럼 + 페이지 헤더 — 측정 누적면적 4.097% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
완성된 관리자 화면 없이 경로 표시와 액션 메뉴만 필요할 때 이 조각 하나만 붙이면 됩니다.
function close() {
wrap.classList.remove('is-open');
kebab.setAttribute('aria-expanded', 'false');
}
kebab.addEventListener('click', function (e) {
document.querySelector('.ph').classList.remove('is-demo');
e.stopPropagation();
var open = wrap.classList.toggle('is-open');
kebab.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.ph__menu-wrap')) close();
});
02액티비티 피드 타임라인
화면에서 일어나는 일
'새 활동 보기'를 누르면 숨어 있던 항목이 실제로 위로 밀려 올라오며 나타나고, 종류별 아이콘은 완료·댓글·업로드를 각각 다른 색 원으로 구분합니다.

<main class="stage">
<div class="af">
<div class="af__head">
<strong class="af__title">최근 활동</strong>
<button class="af__more" type="button">새 활동 보기</button>
</div>
<ul class="af__list">
<li class="af__item">
<span class="af__ic af__ic--check"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span>
<div class="af__body"><p class="af__text">배포가 완료됐습니다</p><time class="af__time">3분 전</time></div>
</li>
<li class="af__item">
<span class="af__ic af__ic--chat"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24"/></g></svg></span>
<div class="af__body"><p class="af__text">민지 님이 댓글을 남겼습니다</p><time class="af__time">18분 전</time></div>
</li>
<li class="af__item af__item--new is-demo">
<span class="af__ic af__ic--upload"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg></span>
<div class="af__body"><p class="af__text">새 파일이 업로드됐습니다</p><time class="af__time">방금</time></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.af {
box-sizing: border-box;
width: min(260px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.af__head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 $sp-3; }
.af__title { font-size: 13px; }
.af__more {
border: 1px solid rgba(255, 247, 230, .35);
background: transparent;
color: $subject-cream;
font: inherit; font-weight: 700; font-size: 10px;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
cursor: pointer;
}
.af__more:disabled { opacity: .5; cursor: default; }
.af__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-3; }
.af__item { display: flex; align-items: flex-start; gap: $sp-2; }
.af__ic {
flex: 0 0 auto;
width: 20px; height: 20px;
border-radius: 50%;
display: grid; place-items: center;
color: $stage-ink;
}
.af__ic--check { background: $subject-mint; }
.af__ic--chat { background: $subject-sky; }
.af__ic--upload { background: $color; color: $subject-cream; }
.af__body { min-width: 0; }
.af__text { margin: 0; font-size: 12px; line-height: 1.4; }
.af__time { font-size: 10px; color: rgba(255, 247, 230, .5); }
.af__item--new {
opacity: 0;
transform: translateY(10px);
transition: opacity $duration $easing, transform $duration $easing;
}
.af__item--new.af__item--shown { opacity: 1; transform: translateY(0); }
// ── 자동 루프: 새 활동 항목이 계속 나타났다 사라진다 ──────────────
.af__item--new.is-demo { animation: af-in 2s $easing infinite; }
@keyframes af-in {
0% { opacity: 0; transform: translateY(10px); }
35% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(10px); }
}
@media (max-width: 340px) {
.af__text { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.af__item--new.is-demo { animation: none !important; opacity: 1; transform: translateY(0); }
.af__item--new { 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);
}
.af {
box-sizing: border-box;
width: min(260px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.af__head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 12px;
}
.af__title {
font-size: 13px;
}
.af__more {
border: 1px solid rgba(255, 247, 230, 0.35);
background: transparent;
color: #fff7e6;
font: inherit;
font-weight: 700;
font-size: 10px;
padding: 4px 8px;
border-radius: 999px;
cursor: pointer;
}
.af__more:disabled {
opacity: 0.5;
cursor: default;
}
.af__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.af__item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.af__ic {
flex: 0 0 auto;
width: 20px;
height: 20px;
border-radius: 50%;
display: grid;
place-items: center;
color: #17141a;
}
.af__ic--check {
background: #4cd4a6;
}
.af__ic--chat {
background: #6ec8ff;
}
.af__ic--upload {
background: #2f6df6;
color: #fff7e6;
}
.af__body {
min-width: 0;
}
.af__text {
margin: 0;
font-size: 12px;
line-height: 1.4;
}
.af__time {
font-size: 10px;
color: rgba(255, 247, 230, 0.5);
}
.af__item--new {
opacity: 0;
transform: translateY(10px);
transition: opacity 600ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.af__item--new.af__item--shown {
opacity: 1;
transform: translateY(0);
}
.af__item--new.is-demo {
animation: af-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes af-in {
0% {
opacity: 0;
transform: translateY(10px);
}
35% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(10px);
}
}
@media (max-width: 340px) {
.af__text {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.af__item--new.is-demo {
animation: none !important;
opacity: 1;
transform: translateY(0);
}
.af__item--new {
transition: none;
}
}// 실제 동작: 버튼을 누르면 숨어 있던 새 활동 항목이 실제로 나타나고, 버튼은 다 쓰면 사라진다.
var btn = document.querySelector('.af__more');
var last = document.querySelector('.af__item--new');
btn.addEventListener('click', function () {
last.classList.remove('is-demo');
last.classList.add('af__item--shown');
btn.disabled = true;
btn.textContent = '모두 확인함';
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ActivityFeed.module.scss";
export type ActivityFeedProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ActivityFeed({ duration = 600, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: ActivityFeedProps) {
const [shown, setShown] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.af} style={vars}>
<div className={styles.head}>
<strong className={styles.title}>Recent activity</strong>
<button type="button" className={styles.more} disabled={shown} onClick={() => setShown(true)}>
{shown ? "All caught up" : "Show new activity"}
</button>
</div>
<ul className={styles.list}>
<li className={styles.item}>
<span className={`${styles.ic} ${styles.check}`}>✓</span>
<div><p className={styles.text}>Deployment completed</p><time className={styles.time}>3m ago</time></div>
</li>
<li className={styles.item}>
<span className={`${styles.ic} ${styles.chat}`}>💬</span>
<div><p className={styles.text}>Minji left a comment</p><time className={styles.time}>18m ago</time></div>
</li>
<li className={`${styles.item} ${styles.newItem} ${shown ? styles.shown : ""}`}>
<span className={`${styles.ic} ${styles.upload}`}>↑</span>
<div><p className={styles.text}>A new file was uploaded</p><time className={styles.time}>Just now</time></div>
</li>
</ul>
</div>
);
}02 · 액티비티 피드 타임라인 — 측정 누적면적 1.846% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
.af__item--new {
opacity: 0;
transform: translateY(10px);
transition: opacity $duration $easing, transform $duration $easing;
}
.af__item--new.af__item--shown { opacity: 1; transform: translateY(0); }
.af__item--new.is-demo { animation: af-in 2s $easing infinite; }
@keyframes af-in {
0% { opacity: 0; transform: translateY(10px); }
35% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(10px); }
}
03월간 캘린더 위젯
어떤 움직임인가
화살표를 누르면 달이 실제로 넘어가며 그 달의 날짜 칸이 Date 객체로 다시 그려지고, 일정이 있는 날은 점으로, 오늘은 테두리로 표시됩니다.

<main class="stage">
<div class="cal">
<div class="cal__head">
<button class="cal__nav" type="button" data-dir="-1" aria-label="이전 달">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m165.66 202.34l-72-72a8 8 0 0 1 0-11.32l72-72a8 8 0 0 1 11.32 11.32L109.31 122H208a8 8 0 0 1 0 16h-98.69l67.67 63.66a8 8 0 0 1-11.32 11.32Z"/></g></svg>
</button>
<strong class="cal__month">2026년 9월</strong>
<button class="cal__nav" type="button" data-dir="1" aria-label="다음 달">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M181.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L165.31 138H56a8 8 0 0 1 0-16h109.31l-67-63.66a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 .03 11.32Z"/></g></svg>
</button>
</div>
<div class="cal__dows">
<span class="cal__dow">일</span><span class="cal__dow">월</span><span class="cal__dow">화</span><span class="cal__dow">수</span><span class="cal__dow">목</span><span class="cal__dow">금</span><span class="cal__dow">토</span>
</div>
<div class="cal__grid is-demo"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-pink;
@include stage($stage-paper);
.cal {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.cal__head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 $sp-2; }
.cal__month { font-size: 12px; font-weight: 800; }
.cal__nav {
width: 20px; height: 20px; padding: 0;
border: 0; background: transparent; color: rgba(23, 20, 26, .5);
display: grid; place-items: center;
border-radius: $r-xs; cursor: pointer;
}
.cal__nav:hover { background: rgba(23, 20, 26, .06); color: $stage-ink; }
.cal__dows, .cal__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1; }
.cal__dows { margin: 0 0 $sp-1; }
.cal__dow {
height: 14px; display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700; color: rgba(23, 20, 26, .72);
}
.cal__day {
position: relative;
height: 16px; padding: 0;
border: 0; background: transparent;
font: inherit; font-size: 10px; color: $stage-ink;
border-radius: $r-xs;
cursor: pointer;
}
.cal__day:empty { visibility: hidden; }
.cal__day:hover { background: rgba(23, 20, 26, .06); }
.cal__day[aria-pressed="true"] { background: $color; color: #fff; font-weight: 700; }
.cal__day--today { font-weight: 800; box-shadow: inset 0 0 0 1px $color; }
.cal__day--event::after {
content: "";
position: absolute; left: 50%; bottom: 1px;
width: 3px; height: 3px; border-radius: 50%;
background: $color;
transform: translateX(-50%);
}
.cal__day[aria-pressed="true"].cal__day--event::after { background: #fff; }
// ── 자동 루프: 오늘 칸에 옅은 고리가 계속 퍼졌다 사라진다 ──────────────
.cal__day--today::before {
content: "";
position: absolute; inset: 0;
border-radius: $r-xs;
border: 1px solid $color;
opacity: 0;
}
.cal__grid.is-demo .cal__day--today::before {
animation: cal-ping 2s $easing infinite;
}
@keyframes cal-ping {
0% { transform: scale(1); opacity: .7; }
70% { transform: scale(1.6); opacity: 0; }
100% { transform: scale(1.6); opacity: 0; }
}
@media (max-width: 340px) {
.cal { width: min(216px, 84vw); }
.cal__day, .cal__dow { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.cal__grid.is-demo .cal__day--today::before { animation: none !important; }
}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);
}
.cal {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.cal__head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px;
}
.cal__month {
font-size: 12px;
font-weight: 800;
}
.cal__nav {
width: 20px;
height: 20px;
padding: 0;
border: 0;
background: transparent;
color: rgba(23, 20, 26, 0.5);
display: grid;
place-items: center;
border-radius: 4px;
cursor: pointer;
}
.cal__nav:hover {
background: rgba(23, 20, 26, 0.06);
color: #17141a;
}
.cal__dows, .cal__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.cal__dows {
margin: 0 0 4px;
}
.cal__dow {
height: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.cal__day {
position: relative;
height: 16px;
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-size: 10px;
color: #17141a;
border-radius: 4px;
cursor: pointer;
}
.cal__day:empty {
visibility: hidden;
}
.cal__day:hover {
background: rgba(23, 20, 26, 0.06);
}
.cal__day[aria-pressed=true] {
background: #ff9ebb;
color: #fff;
font-weight: 700;
}
.cal__day--today {
font-weight: 800;
box-shadow: inset 0 0 0 1px #ff9ebb;
}
.cal__day--event::after {
content: "";
position: absolute;
left: 50%;
bottom: 1px;
width: 3px;
height: 3px;
border-radius: 50%;
background: #ff9ebb;
transform: translateX(-50%);
}
.cal__day[aria-pressed=true].cal__day--event::after {
background: #fff;
}
.cal__day--today::before {
content: "";
position: absolute;
inset: 0;
border-radius: 4px;
border: 1px solid #ff9ebb;
opacity: 0;
}
.cal__grid.is-demo .cal__day--today::before {
animation: cal-ping 2s ease-out infinite;
}
@keyframes cal-ping {
0% {
transform: scale(1);
opacity: 0.7;
}
70% {
transform: scale(1.6);
opacity: 0;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
@media (max-width: 340px) {
.cal {
width: min(216px, 84vw);
}
.cal__day, .cal__dow {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.cal__grid.is-demo .cal__day--today::before {
animation: none !important;
}
}// 실제 동작: 화살표를 누르면 달이 실제로 바뀌며 날짜 칸이 다시 그려지고, 날짜를 누르면 그 칸만 선택된다.
var EVENTS = [4, 11, 22];
var MONTH_LABEL = '{y}년 {m}월';
var grid = document.querySelector('.cal__grid');
var label = document.querySelector('.cal__month');
var cur = { y: 2026, m: 8 };
function build() {
grid.classList.remove('is-demo');
grid.innerHTML = '';
var first = new Date(cur.y, cur.m, 1).getDay();
var days = new Date(cur.y, cur.m + 1, 0).getDate();
for (var i = 0; i < first; i++) grid.appendChild(document.createElement('span'));
for (var d = 1; d <= days; d++) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'cal__day'; b.textContent = String(d);
b.setAttribute('aria-pressed', 'false');
if (cur.y === 2026 && cur.m === 8 && d === 11) b.classList.add('cal__day--today');
if (cur.y === 2026 && cur.m === 8 && EVENTS.indexOf(d) !== -1) b.classList.add('cal__day--event');
b.addEventListener('click', function () {
grid.querySelectorAll('[aria-pressed="true"]').forEach(function (o) { o.setAttribute('aria-pressed', 'false'); });
this.setAttribute('aria-pressed', 'true');
});
grid.appendChild(b);
}
label.textContent = MONTH_LABEL.replace('{y}', cur.y).replace('{m}', cur.m + 1);
}
document.querySelectorAll('.cal__nav').forEach(function (btn) {
btn.addEventListener('click', function () {
cur.m += Number(btn.dataset.dir);
if (cur.m < 0) { cur.m = 11; cur.y--; }
if (cur.m > 11) { cur.m = 0; cur.y++; }
build();
});
});
build();
grid.classList.add('is-demo');import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CalendarWidget.module.scss";
export type CalendarWidgetProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DOWS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const EVENTS = [4, 11, 22];
const TODAY = { y: 2026, m: 8, d: 11 };
export default function CalendarWidget({ duration = 220, easing = "ease-out", color = "#ff9ebb" }: CalendarWidgetProps) {
const [cur, setCur] = useState({ y: 2026, m: 8 });
const [selected, setSelected] = useState<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const days = useMemo(() => {
const first = new Date(cur.y, cur.m, 1).getDay();
const count = new Date(cur.y, cur.m + 1, 0).getDate();
return { blanks: first, count };
}, [cur]);
function nav(dir: number) {
setCur((c) => {
let m = c.m + dir, y = c.y;
if (m < 0) { m = 11; y--; }
if (m > 11) { m = 0; y++; }
return { y, m };
});
setSelected(null);
}
const monthLabel = new Date(cur.y, cur.m, 1).toLocaleString("en-US", { month: "long", year: "numeric" });
return (
<div className={styles.cal} style={vars}>
<div className={styles.head}>
<button type="button" className={styles.nav} aria-label="Previous month" onClick={() => nav(-1)}>‹</button>
<strong className={styles.month}>{monthLabel}</strong>
<button type="button" className={styles.nav} aria-label="Next month" onClick={() => nav(1)}>›</button>
</div>
<div className={styles.dows}>{DOWS.map((d) => <span key={d} className={styles.dow}>{d}</span>)}</div>
<div className={styles.grid}>
{Array.from({ length: days.blanks }).map((_, i) => <span key={`b${i}`} />)}
{Array.from({ length: days.count }).map((_, i) => {
const d = i + 1;
const isToday = cur.y === TODAY.y && cur.m === TODAY.m && d === TODAY.d;
const hasEvent = cur.y === TODAY.y && cur.m === TODAY.m && EVENTS.includes(d);
return (
<button
key={d}
type="button"
className={`${styles.day} ${isToday ? styles.today : ""} ${hasEvent ? styles.event : ""}`}
aria-pressed={selected === d}
onClick={() => setSelected(d)}
>
{d}
</button>
);
})}
</div>
</div>
);
}03 · 월간 캘린더 위젯 — 측정 누적면적 0.333% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
document.querySelectorAll('.cal__nav').forEach(function (btn) {
btn.addEventListener('click', function () {
cur.m += Number(btn.dataset.dir);
if (cur.m < 0) { cur.m = 11; cur.y--; }
if (cur.m > 11) { cur.m = 0; cur.y++; }
build();
});
});
04칸반 보드 미니 위젯
무엇이 보이나
카드의 화살표 버튼을 누르면 그 카드가 실제로 다음 칸(DOM)으로 옮겨가고, 마지막 칸에 닿으면 화살표 대신 체크 아이콘으로 바뀝니다.

<main class="stage">
<div class="kb">
<div class="kb__ghost is-demo" aria-hidden="true">디자인 검토</div>
<div class="kb__col" data-col="0">
<p class="kb__head">할 일 <span class="kb__count">1</span></p>
<div class="kb__cards">
<div class="kb__card">
<span class="kb__dot kb__dot--warn"></span>
<span class="kb__label">랜딩 카피 작성</span>
<button class="kb__move" type="button" aria-label="다음 칸으로">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M221.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L196.69 136H40a8 8 0 0 1 0-16h156.69l-58.35-58.34a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 0 11.32"/></g></svg>
</button>
</div>
</div>
</div>
<div class="kb__col" data-col="1">
<p class="kb__head">진행중 <span class="kb__count">1</span></p>
<div class="kb__cards">
<div class="kb__card">
<span class="kb__dot kb__dot--info"></span>
<span class="kb__label">아이콘 세트 정리</span>
<button class="kb__move" type="button" aria-label="다음 칸으로">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M221.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L196.69 136H40a8 8 0 0 1 0-16h156.69l-58.35-58.34a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 0 11.32"/></g></svg>
</button>
</div>
</div>
</div>
<div class="kb__col kb__col--done" data-col="2">
<p class="kb__head">완료 <span class="kb__count">1</span></p>
<div class="kb__cards">
<div class="kb__card kb__card--done">
<span class="kb__ic">
<svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>
</span>
<span class="kb__label">문구.json 정리</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-ink);
.kb {
position: relative;
box-sizing: border-box;
width: min(280px, 88vw);
display: flex;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.kb__col {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
}
.kb__head {
margin: 0 0 $sp-2;
font-size: 9px; font-weight: 700;
color: rgba(255, 247, 230, .55);
display: flex; align-items: center; gap: $sp-1;
}
.kb__count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 14px; height: 14px; padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(255, 247, 230, .12);
font-size: 9px; color: $color;
}
.kb__cards { display: flex; flex-direction: column; gap: $sp-2; }
.kb__card {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-1;
background: rgba(255, 247, 230, .06);
border: 1px solid rgba(255, 247, 230, .12);
border-radius: $r-sm;
padding: $sp-2;
transition: opacity $duration $easing, transform $duration $easing;
}
.kb__card--moving { opacity: 0; transform: scale(.9); }
.kb__dot { flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%; }
.kb__dot--warn { background: #d0bd9f; }
.kb__dot--info { background: $subject-sky; }
.kb__label { flex: 1 1 auto; min-width: 0; font-size: 10px; color: $color; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kb__move {
flex: 0 0 auto;
width: 18px; height: 18px; padding: 0;
border: 0; background: transparent; color: rgba(255, 247, 230, .55);
display: grid; place-items: center; cursor: pointer;
border-radius: $r-xs;
}
.kb__move:hover { background: rgba(255, 247, 230, .12); color: $color; }
.kb__ic { flex: 0 0 auto; color: $subject-mint; }
.kb__card--done { opacity: .85; }
// ── 자동 루프: 유령 카드가 세 칸을 계속 이동한다 (실제 카드와 별개) ──────────────
.kb__ghost {
position: absolute;
top: 20px; left: 0;
box-sizing: border-box;
width: 74px;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: rgba(255, 247, 230, .1);
border: 1px dashed rgba(255, 247, 230, .3);
font-size: 9px; color: rgba(255, 247, 230, .7);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
pointer-events: none;
opacity: 0;
}
.kb__ghost.is-demo { animation: kb-move 2s linear infinite; }
@keyframes kb-move {
0% { transform: translateX(0) translateY(0); opacity: .9; }
45% { transform: translateX(96px) translateY(-3px); opacity: .9; }
90% { transform: translateX(192px) translateY(0); opacity: .9; }
96% { transform: translateX(192px) translateY(0); opacity: 0; }
100% { transform: translateX(0) translateY(0); opacity: 0; }
}
@media (max-width: 340px) {
.kb__label { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.kb__ghost.is-demo { animation: none !important; opacity: 0; }
.kb__card { 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);
}
.kb {
position: relative;
box-sizing: border-box;
width: min(280px, 88vw);
display: flex;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.kb__col {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
}
.kb__head {
margin: 0 0 8px;
font-size: 9px;
font-weight: 700;
color: rgba(255, 247, 230, 0.55);
display: flex;
align-items: center;
gap: 4px;
}
.kb__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 14px;
height: 14px;
padding: 0 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
font-size: 9px;
color: #fff7e6;
}
.kb__cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.kb__card {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 247, 230, 0.06);
border: 1px solid rgba(255, 247, 230, 0.12);
border-radius: 8px;
padding: 8px;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.kb__card--moving {
opacity: 0;
transform: scale(0.9);
}
.kb__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
}
.kb__dot--warn {
background: #d0bd9f;
}
.kb__dot--info {
background: #6ec8ff;
}
.kb__label {
flex: 1 1 auto;
min-width: 0;
font-size: 10px;
color: #fff7e6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__move {
flex: 0 0 auto;
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 247, 230, 0.55);
display: grid;
place-items: center;
cursor: pointer;
border-radius: 4px;
}
.kb__move:hover {
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
}
.kb__ic {
flex: 0 0 auto;
color: #4cd4a6;
}
.kb__card--done {
opacity: 0.85;
}
.kb__ghost {
position: absolute;
top: 20px;
left: 0;
box-sizing: border-box;
width: 74px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
border: 1px dashed rgba(255, 247, 230, 0.3);
font-size: 9px;
color: rgba(255, 247, 230, 0.7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
opacity: 0;
}
.kb__ghost.is-demo {
animation: kb-move 2s linear infinite;
}
@keyframes kb-move {
0% {
transform: translateX(0) translateY(0);
opacity: 0.9;
}
45% {
transform: translateX(96px) translateY(-3px);
opacity: 0.9;
}
90% {
transform: translateX(192px) translateY(0);
opacity: 0.9;
}
96% {
transform: translateX(192px) translateY(0);
opacity: 0;
}
100% {
transform: translateX(0) translateY(0);
opacity: 0;
}
}
@media (max-width: 340px) {
.kb__label {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.kb__ghost.is-demo {
animation: none !important;
opacity: 0;
}
.kb__card {
transition: none;
}
}// 실제 동작: 화살표를 누르면 그 카드가 실제로 다음 칸(DOM)으로 옮겨간다. 마지막 칸에서는 화살표 대신 체크로 바뀐다.
var cols = document.querySelectorAll('.kb__col');
function refreshCounts() {
cols.forEach(function (c) {
c.querySelector('.kb__count').textContent = String(c.querySelectorAll('.kb__card').length);
});
}
function wire(card) {
var btn = card.querySelector('.kb__move');
if (!btn) return;
btn.addEventListener('click', function () {
document.querySelector('.kb__ghost').classList.remove('is-demo');
var col = card.closest('.kb__col');
var next = cols[Array.prototype.indexOf.call(cols, col) + 1];
if (!next) return;
card.classList.add('kb__card--moving');
setTimeout(function () {
card.classList.remove('kb__card--moving');
if (next.classList.contains('kb__col--done')) {
card.classList.add('kb__card--done');
btn.remove();
var ic = document.createElement('span');
ic.className = 'kb__ic';
ic.innerHTML = '<svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>';
card.prepend(ic);
}
next.querySelector('.kb__cards').appendChild(card);
refreshCounts();
}, 160);
});
}
document.querySelectorAll('.kb__card').forEach(wire);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./KanbanBoard.module.scss";
export type KanbanBoardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Card = { id: string; label: string; dot: "warn" | "info" | "none" };
const INITIAL: Card[][] = [
[{ id: "c1", label: "Write landing copy", dot: "warn" }],
[{ id: "c2", label: "Organize icon set", dot: "info" }],
[{ id: "c3", label: "Clean up copy.json", dot: "none" }],
];
const NAMES = ["To do", "In progress", "Done"];
export default function KanbanBoard({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6" }: KanbanBoardProps) {
const [cols, setCols] = useState<Card[][]>(INITIAL);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function move(colIndex: number, cardId: string) {
if (colIndex >= cols.length - 1) return;
setCols((prev) => {
const next = prev.map((c) => c.slice());
const idx = next[colIndex].findIndex((c) => c.id === cardId);
const [card] = next[colIndex].splice(idx, 1);
next[colIndex + 1].push(card);
return next;
});
}
return (
<div className={styles.kb} style={vars}>
{cols.map((col, i) => (
<div key={NAMES[i]} className={styles.col}>
<p className={styles.head}>{NAMES[i]} <span className={styles.count}>{col.length}</span></p>
<div className={styles.cards}>
{col.map((card) => (
<div key={card.id} className={styles.card}>
{i === cols.length - 1 ? (
<span className={styles.ic}>✓</span>
) : (
<span className={`${styles.dot} ${styles[card.dot]}`} />
)}
<span className={styles.label}>{card.label}</span>
{i < cols.length - 1 && (
<button type="button" className={styles.move} aria-label="Move to next column" onClick={() => move(i, card.id)}>→</button>
)}
</div>
))}
</div>
</div>
))}
</div>
);
}04 · 칸반 보드 미니 위젯 — 측정 누적면적 4.341% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
드래그 라이브러리 없이 진행 단계 세 칸만 나눠 보여주고 싶을 때 쓰는 조각입니다.
@keyframes kb-move {
0% { transform: translateX(0) translateY(0); opacity: .9; }
45% { transform: translateX(96px) translateY(-3px); opacity: .9; }
90% { transform: translateX(192px) translateY(0); opacity: .9; }
96% { transform: translateX(192px) translateY(0); opacity: 0; }
100% { transform: translateX(0) translateY(0); opacity: 0; }
}
05목표 진행률 스텝 카드
화면에서 일어나는 일
'다음 단계' 버튼을 누르면 막대가 실제로 그만큼 채워지고 방금 넘긴 단계에 체크 아이콘이 남으며, 마지막 단계에서는 버튼이 사라집니다.
<main class="stage">
<div class="gt is-demo">
<div class="gt__top">
<span class="gt__ic">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M176 128a48 48 0 1 1-48-48a48 48 0 0 1 48 48" opacity=".2"/><path d="M221.87 83.16A104.1 104.1 0 1 1 195.67 49l22.67-22.68a8 8 0 0 1 11.32 11.32l-96 96a8 8 0 0 1-11.32-11.32l27.72-27.72a40 40 0 1 0 17.87 31.09a8 8 0 1 1 16-.9a56 56 0 1 1-22.38-41.65l22.75-22.75a87.88 87.88 0 1 0 23.13 29.67a8 8 0 0 1 14.44-6.9"/></g></svg>
</span>
<strong class="gt__title">구독 목표</strong>
<span class="gt__pct">1 / 4</span>
</div>
<div class="gt__track"><div class="gt__fill"></div></div>
<ol class="gt__steps">
<li class="gt__step" data-p="25">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span></span>
<span class="gt__label">가입</span>
</li>
<li class="gt__step" data-p="50">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span></span>
<span class="gt__label">첫 결제</span>
</li>
<li class="gt__step" data-p="75">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span></span>
<span class="gt__label">연동</span>
</li>
<li class="gt__step" data-p="100">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span></span>
<span class="gt__label">완료</span>
</li>
</ol>
<button class="gt__next" type="button">다음 단계</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-paper);
.gt {
box-sizing: border-box;
width: min(240px, 86vw);
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
padding: $sp-4;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.gt__top { display: flex; align-items: center; gap: $sp-1; margin: 0 0 $sp-3; }
.gt__ic { flex: 0 0 auto; color: $color; }
.gt__title { flex: 1 1 auto; font-size: 12px; }
.gt__pct { font-size: 11px; font-weight: 800; color: $subject-ink; }
.gt__track {
height: 6px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
margin: 0 0 $sp-4;
}
.gt__fill {
width: 100%; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 진행바 왼쪽 채움
transform: scaleX(0);
transition: transform $duration $easing;
}
.gt__steps { list-style: none; display: flex; justify-content: space-between; margin: 0 0 $sp-4; padding: 0; }
.gt__step { display: flex; flex-direction: column; align-items: center; gap: $sp-1; flex: 1; }
.gt__dot {
position: relative;
width: 18px; height: 18px;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px rgba(23, 20, 26, .18);
display: grid; place-items: center;
}
.gt__check {
position: absolute; inset: 0;
border-radius: 50%;
background: $color;
color: #fff;
display: grid; place-items: center;
opacity: 0;
transform: scale(.6);
transition: opacity $duration $easing, transform $duration $easing;
}
.gt__step.is-active .gt__check { opacity: 1; transform: scale(1); }
.gt__label { font-size: 9px; color: rgba(23, 20, 26, .72); text-align: center; }
.gt__step.is-active .gt__label { color: $stage-ink; font-weight: 700; }
.gt__next {
width: 100%;
border: 0;
background: $color;
color: $subject-ink;
font: inherit; font-weight: 700; font-size: 12px;
padding: $sp-2 $sp-2;
border-radius: $r-control;
cursor: pointer;
}
// ── 자동 루프: 막대가 0→100 을 반복하고, 단계 체크가 순서대로 켜진다 ──────────────
.gt.is-demo .gt__fill { animation: gt-fill 3s $easing infinite; transform-origin: left center; }
@keyframes gt-fill {
0% { transform: scaleX(0); }
85% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
.gt.is-demo .gt__step .gt__check {
animation: gt-check 3s $easing infinite;
}
.gt.is-demo .gt__step:nth-child(1) .gt__check { animation-delay: 0s; }
.gt.is-demo .gt__step:nth-child(2) .gt__check { animation-delay: .55s; }
.gt.is-demo .gt__step:nth-child(3) .gt__check { animation-delay: 1.1s; }
.gt.is-demo .gt__step:nth-child(4) .gt__check { animation-delay: 1.65s; }
@keyframes gt-check {
0%, 15% { opacity: 0; transform: scale(.6); }
30% { opacity: 1; transform: scale(1); }
100% { opacity: 1; transform: scale(1); }
}
@media (max-width: 340px) {
.gt { padding: $sp-3; }
.gt__label { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__fill { animation: none !important; }
.gt.is-demo .gt__step .gt__check { animation: none !important; }
.gt__fill, .gt__check { 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);
}
.gt {
box-sizing: border-box;
width: min(240px, 86vw);
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.gt__top {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 12px;
}
.gt__ic {
flex: 0 0 auto;
color: #4cd4a6;
}
.gt__title {
flex: 1 1 auto;
font-size: 12px;
}
.gt__pct {
font-size: 11px;
font-weight: 800;
color: #17141a;
}
.gt__track {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
margin: 0 0 16px;
}
.gt__fill {
width: 100%;
height: 100%;
background: #4cd4a6;
transform-origin: left center;
transform: scaleX(0);
transition: transform 300ms ease-out;
}
.gt__steps {
list-style: none;
display: flex;
justify-content: space-between;
margin: 0 0 16px;
padding: 0;
}
.gt__step {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
}
.gt__dot {
position: relative;
width: 18px;
height: 18px;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px rgba(23, 20, 26, 0.18);
display: grid;
place-items: center;
}
.gt__check {
position: absolute;
inset: 0;
border-radius: 50%;
background: #4cd4a6;
color: #fff;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.gt__step.is-active .gt__check {
opacity: 1;
transform: scale(1);
}
.gt__label {
font-size: 9px;
color: rgba(23, 20, 26, 0.72);
text-align: center;
}
.gt__step.is-active .gt__label {
color: #17141a;
font-weight: 700;
}
.gt__next {
width: 100%;
border: 0;
background: #4cd4a6;
color: #17141a;
font: inherit;
font-weight: 700;
font-size: 12px;
padding: 8px 8px;
border-radius: 12px;
cursor: pointer;
}
.gt.is-demo .gt__fill {
animation: gt-fill 3s ease-out infinite;
transform-origin: left center;
}
@keyframes gt-fill {
0% {
transform: scaleX(0);
}
85% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.gt.is-demo .gt__step .gt__check {
animation: gt-check 3s ease-out infinite;
}
.gt.is-demo .gt__step:nth-child(1) .gt__check {
animation-delay: 0s;
}
.gt.is-demo .gt__step:nth-child(2) .gt__check {
animation-delay: 0.55s;
}
.gt.is-demo .gt__step:nth-child(3) .gt__check {
animation-delay: 1.1s;
}
.gt.is-demo .gt__step:nth-child(4) .gt__check {
animation-delay: 1.65s;
}
@keyframes gt-check {
0%, 15% {
opacity: 0;
transform: scale(0.6);
}
30% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.gt {
padding: 12px;
}
.gt__label {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__fill {
animation: none !important;
}
.gt.is-demo .gt__step .gt__check {
animation: none !important;
}
.gt__fill, .gt__check {
transition: none;
}
}// 실제 동작: '다음 단계'를 누르면 막대가 실제로 그 단계 값까지 채워지고 지나온 단계에 체크가 남는다.
var steps = document.querySelectorAll('.gt__step');
var fill = document.querySelector('.gt__fill');
var pct = document.querySelector('.gt__pct');
var btn = document.querySelector('.gt__next');
var idx = 0;
function apply() {
steps.forEach(function (s, i) { s.classList.toggle('is-active', i <= idx); });
fill.style.transform = 'scaleX(' + (Number(steps[idx].dataset.p) / 100) + ')';
pct.textContent = (idx + 1) + ' / ' + steps.length;
btn.hidden = idx === steps.length - 1;
}
btn.addEventListener('click', function () {
document.querySelector('.gt').classList.remove('is-demo');
idx = Math.min(idx + 1, steps.length - 1);
apply();
});
apply();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GoalTracker.module.scss";
export type GoalTrackerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STEPS = [
{ label: "Sign up", p: 25 },
{ label: "First payment", p: 50 },
{ label: "Integration", p: 75 },
{ label: "Done", p: 100 },
];
export default function GoalTracker({ duration = 300, easing = "ease-out", color = "#4cd4a6" }: GoalTrackerProps) {
const [idx, setIdx] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.gt} style={vars}>
<div className={styles.top}>
<span className={styles.ic}>◎</span>
<strong className={styles.title}>Subscription goal</strong>
<span className={styles.pct}>{idx + 1} / {STEPS.length}</span>
</div>
<div className={styles.track}>
<div className={styles.fill} style={{ transform: `scaleX(${STEPS[idx].p / 100})` }} />
</div>
<ol className={styles.steps}>
{STEPS.map((s, i) => (
<li key={s.label} className={styles.step}>
<span className={styles.dot}>
<span className={`${styles.check} ${i <= idx ? styles.active : ""}`}>✓</span>
</span>
<span className={`${styles.label} ${i <= idx ? styles.activeLabel : ""}`}>{s.label}</span>
</li>
))}
</ol>
{idx < STEPS.length - 1 && (
<button type="button" className={styles.next} onClick={() => setIdx((i) => Math.min(i + 1, STEPS.length - 1))}>
Next step
</button>
)}
</div>
);
}05 · 목표 진행률 스텝 카드 — 측정 누적면적 1.372% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
function apply() {
steps.forEach(function (s, i) { s.classList.toggle('is-active', i <= idx); });
fill.style.transform = 'scaleX(' + (Number(steps[idx].dataset.p) / 100) + ')';
pct.textContent = (idx + 1) + ' / ' + steps.length;
btn.hidden = idx === steps.length - 1;
}
btn.addEventListener('click', function () {
document.querySelector('.gt').classList.remove('is-demo');
idx = Math.min(idx + 1, steps.length - 1);
apply();
});
06팀 멤버 리스트 (검색)
어떤 움직임인가
이름을 입력하면 그 글자가 들어간 멤버만 실제로 남고 나머지는 실제로 숨겨지며, 온라인 상태 점은 계속 옅은 고리로 퍼졌다 사라집니다.

<main class="stage">
<div class="tl">
<div class="tl__head">
<input class="tl__search" type="text" placeholder="이름으로 검색" aria-label="멤버 검색">
<button class="tl__invite" type="button" aria-label="초대">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
<ul class="tl__list">
<li class="tl__row" data-name="김도윤">
<span class="tl__avatar">김<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">김도윤</span>
</li>
<li class="tl__row" data-name="이서준">
<span class="tl__avatar">이<span class="tl__status tl__status--away"></span></span>
<span class="tl__name">이서준</span>
</li>
<li class="tl__row" data-name="박하은">
<span class="tl__avatar">박<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">박하은</span>
</li>
<li class="tl__row tl__row--new is-demo" data-name="최유진">
<span class="tl__avatar">최<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">최유진</span>
</li>
</ul>
<p class="tl__empty" hidden>일치하는 멤버가 없습니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-sky;
@include stage($stage-yellow);
.tl {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.tl__head { display: flex; align-items: center; gap: $sp-2; margin: 0 0 $sp-3; }
.tl__search {
flex: 1 1 auto; min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(23, 20, 26, .18);
background: #fff;
border-radius: $r-control;
padding: $sp-1 $sp-2;
font: inherit; font-size: 11px; color: $stage-ink;
}
.tl__search::placeholder { color: rgba(23, 20, 26, .4); }
.tl__invite {
flex: 0 0 auto;
width: 26px; height: 26px; padding: 0;
border: 0; background: $stage-ink; color: $stage-yellow;
border-radius: 50%;
display: grid; place-items: center; cursor: pointer;
}
.tl__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.tl__row { display: flex; align-items: center; gap: $sp-2; }
.tl__avatar {
position: relative;
flex: 0 0 auto;
width: 24px; height: 24px;
border-radius: 50%;
background: #fff;
display: grid; place-items: center;
font-size: 10px; font-weight: 800;
}
.tl__status {
position: absolute; right: -1px; bottom: -1px;
width: 8px; height: 8px;
border-radius: 50%;
border: 2px solid $stage-yellow;
}
.tl__status--on { background: $subject-mint; }
.tl__status--away { background: #d0bd9f; }
.tl__status--on::after {
content: "";
position: absolute; inset: -2px;
border-radius: 50%;
border: 1px solid $subject-mint;
opacity: .8;
animation: tl-ping 2.4s $easing infinite;
}
.tl__name { font-size: 12px; font-weight: 600; }
.tl__empty { font-size: 11px; color: rgba(23, 20, 26, .5); margin: $sp-2 0 0; }
@keyframes tl-ping {
0% { transform: scale(1); opacity: .8; }
100% { transform: scale(1.9); opacity: 0; }
}
.tl__row--new {
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
// ── 자동 루프: 새 멤버 행이 계속 나타났다 사라진다 ──────────────
.tl__row--new.is-demo { animation: tl-join 2.6s $easing infinite; }
@keyframes tl-join {
0% { opacity: 0; transform: translateY(8px); }
30% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(8px); }
}
@media (max-width: 340px) {
.tl__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.tl__status--on::after { animation: none !important; opacity: 0; }
.tl__row--new.is-demo { animation: none !important; opacity: 1; transform: translateY(0); }
.tl__row--new { 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);
}
.tl {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.tl__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
}
.tl__search {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(23, 20, 26, 0.18);
background: #fff;
border-radius: 12px;
padding: 4px 8px;
font: inherit;
font-size: 11px;
color: #17141a;
}
.tl__search::placeholder {
color: rgba(23, 20, 26, 0.4);
}
.tl__invite {
flex: 0 0 auto;
width: 26px;
height: 26px;
padding: 0;
border: 0;
background: #17141a;
color: #ffd23f;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
}
.tl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.tl__row {
display: flex;
align-items: center;
gap: 8px;
}
.tl__avatar {
position: relative;
flex: 0 0 auto;
width: 24px;
height: 24px;
border-radius: 50%;
background: #fff;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 800;
}
.tl__status {
position: absolute;
right: -1px;
bottom: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid #ffd23f;
}
.tl__status--on {
background: #4cd4a6;
}
.tl__status--away {
background: #d0bd9f;
}
.tl__status--on::after {
content: "";
position: absolute;
inset: -2px;
border-radius: 50%;
border: 1px solid #4cd4a6;
opacity: 0.8;
animation: tl-ping 2.4s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.tl__name {
font-size: 12px;
font-weight: 600;
}
.tl__empty {
font-size: 11px;
color: rgba(23, 20, 26, 0.5);
margin: 8px 0 0;
}
@keyframes tl-ping {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(1.9);
opacity: 0;
}
}
.tl__row--new {
opacity: 0;
transform: translateY(8px);
transition: opacity 600ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tl__row--new.is-demo {
animation: tl-join 2.6s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes tl-join {
0% {
opacity: 0;
transform: translateY(8px);
}
30% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (max-width: 340px) {
.tl__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.tl__status--on::after {
animation: none !important;
opacity: 0;
}
.tl__row--new.is-demo {
animation: none !important;
opacity: 1;
transform: translateY(0);
}
.tl__row--new {
transition: none;
}
}// 실제 동작: 이름을 입력하면 일치하는 멤버만 실제로 남고 나머지는 실제로 숨겨진다.
var input = document.querySelector('.tl__search');
var rows = document.querySelectorAll('.tl__row');
var empty = document.querySelector('.tl__empty');
input.addEventListener('input', function () {
var q = input.value.trim().toLowerCase();
var shown = 0;
rows.forEach(function (r) {
var hit = r.dataset.name.toLowerCase().indexOf(q) !== -1;
r.hidden = !hit;
if (hit) shown++;
});
empty.hidden = shown !== 0;
});import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TeamList.module.scss";
export type TeamListProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const MEMBERS = [
{ name: "Kim Doyoon", initial: "K", status: "on" as const },
{ name: "Lee Seojun", initial: "L", status: "away" as const },
{ name: "Park Haeun", initial: "P", status: "on" as const },
];
export default function TeamList({ duration = 600, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#6ec8ff" }: TeamListProps) {
const [query, setQuery] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const filtered = useMemo(
() => MEMBERS.filter((m) => m.name.toLowerCase().includes(query.trim().toLowerCase())),
[query]
);
return (
<div className={styles.tl} style={vars}>
<div className={styles.head}>
<input
className={styles.search}
type="text"
placeholder="Search by name"
aria-label="Member search"
value={query}
onChange={(e: any) => setQuery(e.target.value)}
/>
<button type="button" className={styles.invite} aria-label="Invite">+</button>
</div>
<ul className={styles.list}>
{filtered.map((m) => (
<li key={m.name} className={styles.row}>
<span className={styles.avatar}>
{m.initial}
<span className={`${styles.status} ${styles[m.status]}`} />
</span>
<span className={styles.name}>{m.name}</span>
</li>
))}
</ul>
{filtered.length === 0 && <p className={styles.empty}>No matching members</p>}
</div>
);
}06 · 팀 멤버 리스트 (검색) — 측정 누적면적 1.09% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
input.addEventListener('input', function () {
var q = input.value.trim().toLowerCase();
var shown = 0;
rows.forEach(function (r) {
var hit = r.dataset.name.toLowerCase().indexOf(q) !== -1;
r.hidden = !hit;
if (hit) shown++;
});
empty.hidden = shown !== 0;
});
07커맨드 팔레트 (⌘K)
무엇이 보이나
버튼을 누르거나 Ctrl(⌘)+K를 누르면 패널이 실제로 화면 가운데서 커지며 열리고, 입력한 글자가 들어간 명령만 실제로 남으며 방향키·Enter로 고를 수 있습니다.

<main class="stage">
<div class="cp">
<button class="cp__trigger" type="button">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M116 40a76 76 0 1 0 76 76a76 76 0 0 0-76-76" opacity=".2"/><path d="m228.24 219.76l-51.38-51.38a92.12 92.12 0 1 0-8.48 8.48l51.38 51.38a6 6 0 0 0 8.48-8.48M36 116a80 80 0 1 1 80 80a80.09 80.09 0 0 1-80-80"/></g></svg>
빠른 이동 <kbd class="cp__kbd">Ctrl K</kbd>
</button>
<div class="cp__overlay is-demo">
<div class="cp__backdrop"></div>
<div class="cp__panel" role="dialog" aria-modal="true" aria-label="커맨드 팔레트">
<div class="cp__search-row">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M116 40a76 76 0 1 0 76 76a76 76 0 0 0-76-76" opacity=".2"/><path d="m228.24 219.76l-51.38-51.38a92.12 92.12 0 1 0-8.48 8.48l51.38 51.38a6 6 0 0 0 8.48-8.48M36 116a80 80 0 1 1 80 80a80.09 80.09 0 0 1-80-80"/></g></svg>
<input class="cp__input" type="text" placeholder="명령 검색…" aria-label="명령 검색">
</div>
<ul class="cp__list" role="listbox">
<li class="cp__item is-active" role="option" tabindex="-1" data-kw="이벤트 일정 캘린더 추가">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H48V96h160Z"/></g></svg></span>
새 이벤트 추가
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="태그 라벨 관리">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M243.31 136L144 36.69A15.86 15.86 0 0 0 132.69 32H40a8 8 0 0 0-8 8v92.69A15.86 15.86 0 0 0 36.69 144L136 243.31a16 16 0 0 0 22.63 0l84.68-84.68a16 16 0 0 0 0-22.63M96 84a12 12 0 1 1 12-12a12 12 0 0 1-12 12"/></g></svg></span>
태그 관리
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="프로필 계정 설정">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a87.7 87.7 0 0 1-56.61-20.68a72 72 0 0 1 113.22 0A87.7 87.7 0 0 1 128 216"/></g></svg></span>
프로필 설정
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="내보내기 다운로드 csv">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg></span>
파일 내보내기
</li>
</ul>
<p class="cp__empty" hidden>일치하는 명령이 없습니다</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-ink);
.cp { font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.cp__trigger {
display: inline-flex; align-items: center; gap: $sp-2;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, .25);
background: rgba(255, 247, 230, .06);
color: $color;
padding: $sp-2 $sp-3;
border-radius: $r-control;
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
}
.cp__kbd {
margin-left: $sp-1;
font: inherit; font-size: 9px; font-weight: 700;
line-height: 1.6;
padding: 0 $sp-1;
border-radius: $r-xs;
background: rgba(255, 247, 230, .12);
}
.cp__overlay {
position: fixed; inset: 0;
display: grid; place-items: center;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.cp__overlay.is-open { opacity: 1; pointer-events: auto; }
.cp__backdrop {
position: absolute; inset: 0;
background: rgba(0, 0, 0, .55);
}
.cp__panel {
position: relative;
box-sizing: border-box;
width: min(230px, 82vw);
background: #232028;
border-radius: $r-card;
box-shadow: $shadow-raised;
padding: $sp-2;
transform: scale(.92);
transform-origin: center;
transition: transform $duration $easing;
}
.cp__overlay.is-open .cp__panel { transform: scale(1); }
.cp__search-row {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-2;
border-bottom: 1px solid rgba(255, 247, 230, .12);
color: rgba(255, 247, 230, .5);
margin: 0 0 $sp-1;
}
.cp__input {
flex: 1 1 auto; min-width: 0;
border: 0; background: transparent; outline: none;
color: $color;
font: inherit; font-size: 12px;
padding: $sp-1 0;
}
.cp__input::placeholder { color: rgba(255, 247, 230, .4); }
.cp__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; max-height: 132px; overflow: hidden; }
.cp__item {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-2;
border-radius: $r-xs;
color: rgba(255, 247, 230, .75);
font-size: 12px;
cursor: pointer;
}
.cp__ic { flex: 0 0 auto; color: rgba(255, 247, 230, .5); }
.cp__item.is-active { background: rgba(255, 247, 230, .1); color: $color; }
.cp__item.is-active .cp__ic { color: $color; }
.cp__item[hidden] { display: none; }
.cp__empty { font-size: 11px; color: rgba(255, 247, 230, .5); padding: $sp-2; margin: 0; }
// ── 자동 루프: 팔레트가 열렸다 닫혔다를 반복한다 (실제 상태와 별개) ──────────────
.cp__overlay.is-demo { animation: cp-open 2s $easing infinite; }
.cp__overlay.is-demo .cp__panel { animation: cp-panel 2s $easing infinite; }
@keyframes cp-open {
0%, 100% { opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
}
@keyframes cp-panel {
0%, 100% { transform: scale(.92); }
20% { transform: scale(1); }
80% { transform: scale(1); }
}
@media (max-width: 340px) {
.cp__trigger { font-size: 11px; padding: $sp-1 $sp-2; }
.cp__panel { width: min(210px, 80vw); }
}
@media (prefers-reduced-motion: reduce) {
.cp__overlay.is-demo, .cp__overlay.is-demo .cp__panel { animation: none !important; }
.cp__overlay, .cp__panel { 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);
}
.cp {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__trigger {
display: inline-flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, 0.25);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
padding: 8px 12px;
border-radius: 12px;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cp__kbd {
margin-left: 4px;
font: inherit;
font-size: 9px;
font-weight: 700;
line-height: 1.6;
padding: 0 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.12);
}
.cp__overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cp__overlay.is-open {
opacity: 1;
pointer-events: auto;
}
.cp__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.cp__panel {
position: relative;
box-sizing: border-box;
width: min(230px, 82vw);
background: #232028;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px;
transform: scale(0.92);
transform-origin: center;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cp__overlay.is-open .cp__panel {
transform: scale(1);
}
.cp__search-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
color: rgba(255, 247, 230, 0.5);
margin: 0 0 4px;
}
.cp__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
outline: none;
color: #fff7e6;
font: inherit;
font-size: 12px;
padding: 4px 0;
}
.cp__input::placeholder {
color: rgba(255, 247, 230, 0.4);
}
.cp__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
max-height: 132px;
overflow: hidden;
}
.cp__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
color: rgba(255, 247, 230, 0.75);
font-size: 12px;
cursor: pointer;
}
.cp__ic {
flex: 0 0 auto;
color: rgba(255, 247, 230, 0.5);
}
.cp__item.is-active {
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
}
.cp__item.is-active .cp__ic {
color: #fff7e6;
}
.cp__item[hidden] {
display: none;
}
.cp__empty {
font-size: 11px;
color: rgba(255, 247, 230, 0.5);
padding: 8px;
margin: 0;
}
.cp__overlay.is-demo {
animation: cp-open 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.cp__overlay.is-demo .cp__panel {
animation: cp-panel 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes cp-open {
0%, 100% {
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
}
@keyframes cp-panel {
0%, 100% {
transform: scale(0.92);
}
20% {
transform: scale(1);
}
80% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.cp__trigger {
font-size: 11px;
padding: 4px 8px;
}
.cp__panel {
width: min(210px, 80vw);
}
}
@media (prefers-reduced-motion: reduce) {
.cp__overlay.is-demo, .cp__overlay.is-demo .cp__panel {
animation: none !important;
}
.cp__overlay, .cp__panel {
transition: none;
}
}// 실제 동작: 버튼이나 Ctrl(⌘)+K로 열리고, 입력하면 실제로 그 글자가 든 명령만 남으며, 방향키·Enter로 고른다.
var trigger = document.querySelector('.cp__trigger');
var overlay = document.querySelector('.cp__overlay');
var input = document.querySelector('.cp__input');
var items = document.querySelectorAll('.cp__item');
var empty = document.querySelector('.cp__empty');
var activeIndex = 0;
function visibleItems() { return Array.prototype.filter.call(items, function (it) { return !it.hidden; }); }
function setActive(i, vis) {
vis = vis || visibleItems();
items.forEach(function (it) { it.classList.remove('is-active'); });
if (vis[i]) vis[i].classList.add('is-active');
activeIndex = i;
}
function filter() {
var q = input.value.trim().toLowerCase();
items.forEach(function (it) { it.hidden = q !== '' && it.dataset.kw.toLowerCase().indexOf(q) === -1; });
var vis = visibleItems();
empty.hidden = vis.length !== 0;
setActive(0, vis);
}
function open() {
overlay.classList.remove('is-demo');
overlay.classList.add('is-open');
input.value = '';
filter();
input.focus({ preventScroll: true });
}
function close() {
overlay.classList.remove('is-open');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); open(); }
else if (e.key === 'Escape' && overlay.classList.contains('is-open')) close();
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay || e.target.classList.contains('cp__backdrop')) close();
});
input.addEventListener('input', filter);
input.addEventListener('keydown', function (e) {
var vis = visibleItems();
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(Math.min(activeIndex + 1, vis.length - 1), vis); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(Math.max(activeIndex - 1, 0), vis); }
else if (e.key === 'Enter') { var cur = vis[activeIndex]; if (cur) close(); }
});
items.forEach(function (it) { it.addEventListener('click', close); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CommandPalette.module.scss";
export type CommandPaletteProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const COMMANDS = [
{ id: "event", label: "Add new event", kw: "event calendar schedule" },
{ id: "tags", label: "Manage tags", kw: "tag label" },
{ id: "profile", label: "Profile settings", kw: "profile account settings" },
{ id: "export", label: "Export file", kw: "export download csv" },
];
export default function CommandPalette({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6" }: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const visible = COMMANDS.filter((c) => !query.trim() || c.kw.includes(query.trim().toLowerCase()));
function openPalette() {
setOpen(true); setQuery(""); setActive(0);
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}
function close() {
setOpen(false);
triggerRef.current?.focus({ preventScroll: true });
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openPalette(); }
else if (e.key === "Escape" && open) close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
});
return (
<div className={styles.cp} style={vars}>
<button ref={triggerRef} type="button" className={styles.trigger} onClick={openPalette}>
🔍 Quick jump <kbd className={styles.kbd}>Ctrl K</kbd>
</button>
{open && (
<div className={styles.overlay} onClick={(e: any) => { if (e.target === e.currentTarget) close(); }}>
<div className={styles.panel} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.searchRow}>
🔍
<input
ref={inputRef}
className={styles.input}
type="text"
placeholder="Search commands…"
aria-label="Command search"
value={query}
onChange={(e: any) => { setQuery(e.target.value); setActive(0); }}
onKeyDown={(e: any) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => Math.min(i + 1, visible.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter" && visible[active]) close();
}}
/>
</div>
<ul className={styles.list} role="listbox">
{visible.map((c, i) => (
<li key={c.id} className={`${styles.item} ${i === active ? styles.active : ""}`} role="option" onClick={close}>
{c.label}
</li>
))}
</ul>
{visible.length === 0 && <p className={styles.empty}>No matching commands</p>}
</div>
</div>
)}
</div>
);
}07 · 커맨드 팔레트 (⌘K) — 측정 누적면적 95.078% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
function filter() {
var q = input.value.trim().toLowerCase();
items.forEach(function (it) { it.hidden = q !== '' && it.dataset.kw.toLowerCase().indexOf(q) === -1; });
var vis = visibleItems();
empty.hidden = vis.length !== 0;
setActive(0, vis);
}
08태그 관리 입력 위젯
화면에서 일어나는 일
입력하고 추가를 누르면(또는 Enter) 새 태그 칩이 목록 끝에 바로 추가되고, 칩의 ×를 누르면 그 태그만 실제로 옅어지며 사라집니다.

<main class="stage">
<div class="tg">
<div class="tg__row">
<input class="tg__input" type="text" placeholder="태그 입력 후 Enter" aria-label="새 태그">
<button class="tg__add" type="button" aria-label="태그 추가">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
<ul class="tg__list">
<li class="tg__chip" data-tag="디자인">디자인<button class="tg__remove" type="button" aria-label="디자인 삭제">×</button></li>
<li class="tg__chip" data-tag="긴급">긴급<button class="tg__remove" type="button" aria-label="긴급 삭제">×</button></li>
<li class="tg__chip tg__chip--demo is-demo" aria-hidden="true">리뷰 필요<button class="tg__remove" type="button" tabindex="-1" aria-hidden="true">×</button></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.tg {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tg__row { display: flex; gap: $sp-2; margin: 0 0 $sp-3; }
.tg__input {
flex: 1 1 auto; min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, .35);
background: rgba(255, 247, 230, .1);
color: $subject-ink;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font: inherit; font-size: 12px;
}
.tg__input::placeholder { color: rgba(23, 20, 26, .72); }
.tg__add {
flex: 0 0 auto;
width: 32px; height: 32px; padding: 0;
border: 0; background: $subject-cream; color: $subject-ink;
border-radius: $r-control;
display: grid; place-items: center; cursor: pointer;
}
.tg__list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: $sp-2; }
.tg__chip {
box-sizing: border-box;
display: inline-flex; align-items: center; gap: $sp-1;
background: rgba(255, 247, 230, .16);
border: 1px solid rgba(255, 247, 230, .3);
color: $subject-ink;
padding: $sp-1 $sp-1 $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 11px; font-weight: 600;
opacity: 1; transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.tg__chip--out { opacity: 0; transform: scale(.7); }
.tg__remove {
width: 18px; height: 18px; padding: 0;
border: 0; background: transparent; color: inherit;
border-radius: 50%;
font-size: 13px; line-height: 1; cursor: pointer;
}
.tg__remove:hover { background: rgba(255, 247, 230, .2); }
.tg__chip--demo { pointer-events: none; opacity: 0; transform: translateX(10px) scale(.9); }
// ── 자동 루프: 새 태그 칩이 계속 밀려 들어왔다 사라진다 ──────────────
.tg__chip--demo.is-demo { animation: tg-in 2.4s $easing infinite; }
@keyframes tg-in {
0% { opacity: 0; transform: translateX(10px) scale(.9); }
35% { opacity: 1; transform: translateX(0) scale(1); }
80% { opacity: 1; transform: translateX(0) scale(1); }
100% { opacity: 0; transform: translateX(10px) scale(.9); }
}
@media (max-width: 340px) {
.tg__chip { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tg__chip--demo.is-demo { animation: none !important; opacity: 0; }
.tg__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);
}
.tg {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tg__row {
display: flex;
gap: 8px;
margin: 0 0 12px;
}
.tg__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, 0.35);
background: rgba(255, 247, 230, 0.1);
color: #17141a;
border-radius: 12px;
padding: 8px 12px;
font: inherit;
font-size: 12px;
}
.tg__input::placeholder {
color: rgba(23, 20, 26, 0.72);
}
.tg__add {
flex: 0 0 auto;
width: 32px;
height: 32px;
padding: 0;
border: 0;
background: #fff7e6;
color: #17141a;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
}
.tg__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tg__chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255, 247, 230, 0.16);
border: 1px solid rgba(255, 247, 230, 0.3);
color: #17141a;
padding: 4px 4px 4px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
opacity: 1;
transform: scale(1);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tg__chip--out {
opacity: 0;
transform: scale(0.7);
}
.tg__remove {
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
border-radius: 50%;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.tg__remove:hover {
background: rgba(255, 247, 230, 0.2);
}
.tg__chip--demo {
pointer-events: none;
opacity: 0;
transform: translateX(10px) scale(0.9);
}
.tg__chip--demo.is-demo {
animation: tg-in 2.4s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes tg-in {
0% {
opacity: 0;
transform: translateX(10px) scale(0.9);
}
35% {
opacity: 1;
transform: translateX(0) scale(1);
}
80% {
opacity: 1;
transform: translateX(0) scale(1);
}
100% {
opacity: 0;
transform: translateX(10px) scale(0.9);
}
}
@media (max-width: 340px) {
.tg__chip {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tg__chip--demo.is-demo {
animation: none !important;
opacity: 0;
}
.tg__chip {
transition: none;
}
}// 실제 동작: 입력하고 추가를 누르면(또는 Enter) 새 태그 칩이 실제로 생기고, ×를 누르면 그 태그만 실제로 사라진다.
var list = document.querySelector('.tg__list');
var input = document.querySelector('.tg__input');
var addBtn = document.querySelector('.tg__add');
function wireRemove(li) {
li.querySelector('.tg__remove').addEventListener('click', function () {
li.classList.add('tg__chip--out');
setTimeout(function () { li.remove(); }, 180);
});
}
function addTag() {
var v = input.value.trim();
if (!v) return;
var li = document.createElement('li');
li.className = 'tg__chip';
li.dataset.tag = v;
li.textContent = v;
var rm = document.createElement('button');
rm.type = 'button'; rm.className = 'tg__remove'; rm.textContent = '×';
rm.setAttribute('aria-label', v + ' 삭제');
li.appendChild(rm);
wireRemove(li);
list.appendChild(li);
input.value = '';
input.focus({ preventScroll: true });
}
addBtn.addEventListener('click', addTag);
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); addTag(); } });
document.querySelectorAll('.tg__chip:not(.tg__chip--demo)').forEach(wireRemove);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TagManager.module.scss";
export type TagManagerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TagManager({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6" }: TagManagerProps) {
const [tags, setTags] = useState(["Design", "Urgent"]);
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function addTag() {
const v = value.trim();
if (!v) return;
setTags((t) => [...t, v]);
setValue("");
}
function removeTag(tag: string) {
setTags((t) => t.filter((x) => x !== tag));
}
return (
<div className={styles.tg} style={vars}>
<div className={styles.row}>
<input
className={styles.input}
type="text"
placeholder="Type a tag, press Enter"
aria-label="New tag"
value={value}
onChange={(e: any) => setValue(e.target.value)}
onKeyDown={(e: any) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }}
/>
<button type="button" className={styles.add} aria-label="Add tag" onClick={addTag}>+</button>
</div>
<ul className={styles.list}>
{tags.map((tag) => (
<li key={tag} className={styles.chip}>
{tag}
<button type="button" className={styles.remove} aria-label={`Remove ${tag}`} onClick={() => removeTag(tag)}>×</button>
</li>
))}
</ul>
</div>
);
}08 · 태그 관리 입력 위젯 — 측정 누적면적 1.604% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
function addTag() {
var v = input.value.trim();
if (!v) return;
var li = document.createElement('li');
li.className = 'tg__chip';
li.dataset.tag = v;
li.textContent = v;
var rm = document.createElement('button');
rm.type = 'button'; rm.className = 'tg__remove'; rm.textContent = '×';
rm.setAttribute('aria-label', v + ' 삭제');
li.appendChild(rm);
wireRemove(li);
list.appendChild(li);
input.value = '';
input.focus({ preventScroll: true });
}
09파일 업로드 드롭존
어떤 움직임인가
영역에 파일을 끌어다 놓거나 클릭해 고르면 진행률 막대가 실제로 0에서 끝까지 차오르고, 다 차면 체크 아이콘으로 바뀝니다.

<main class="stage">
<div class="fu">
<label class="fu__zone" tabindex="0">
<span class="fu__ic">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg>
</span>
<span class="fu__hint">끌어다 놓거나 클릭해서 선택</span>
<input class="fu__input" type="file" hidden multiple>
</label>
<ul class="fu__files">
<li class="fu__file fu__file--demo is-demo" aria-hidden="true">
<span class="fu__file-name">report.pdf</span>
<span class="fu__track"><span class="fu__fill"></span></span>
<span class="fu__check">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>
</span>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-orange);
.fu {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.fu__zone {
box-sizing: border-box;
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
border: 1.5px dashed rgba(255, 247, 230, .4);
border-radius: $r-card;
padding: $sp-4 $sp-3;
cursor: pointer;
transition: border-color $duration $easing, background $duration $easing;
}
.fu__zone.is-over { border-color: $color; background: rgba(47, 109, 246, .12); }
.fu__ic { color: rgba(255, 247, 230, .7); }
.fu__hint { font-size: 11px; color: $subject-ink; text-align: center; }
.fu__files { list-style: none; margin: $sp-3 0 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.fu__file {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-2;
background: rgba(255, 247, 230, .08);
border-radius: $r-sm;
padding: $sp-1 $sp-2;
}
.fu__file-name { flex: 1 1 auto; min-width: 0; font-size: 10px; color: $subject-ink; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fu__track {
position: relative;
flex: 0 0 auto;
width: 46px; height: 4px;
border-radius: $r-pill;
background: rgba(255, 247, 230, .18);
overflow: hidden;
transition: opacity $duration $easing;
}
.fu__fill {
display: block; width: 100%; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 업로드 진행 왼쪽 채움
transform: scaleX(0);
transition: transform 900ms $easing;
}
.fu__check {
flex: 0 0 auto;
width: 16px; height: 16px;
border-radius: 50%;
background: $subject-mint;
color: $stage-ink;
display: grid; place-items: center;
opacity: 0;
transform: scale(.6);
transition: opacity $duration $easing, transform $duration $easing;
}
.fu__file--done .fu__track { opacity: 0; width: 0; }
.fu__file--done .fu__check { opacity: 1; transform: scale(1); }
.fu__remove {
flex: 0 0 auto;
width: 16px; height: 16px; padding: 0;
border: 0; background: transparent; color: rgba(255, 247, 230, .6);
font-size: 12px; line-height: 1; cursor: pointer;
}
// ── 자동 루프: 진행률이 0→100 을 반복하고 끝나면 체크로 바뀐다 ──────────────
.fu__file--demo.is-demo .fu__fill { animation: fu-fill 2.6s $easing infinite; }
.fu__file--demo.is-demo .fu__track { animation: fu-track 2.6s $easing infinite; }
.fu__file--demo.is-demo .fu__check { animation: fu-check 2.6s $easing infinite; }
@keyframes fu-fill {
0% { transform: scaleX(0); }
70% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@keyframes fu-track {
0%, 75% { opacity: 1; }
90%, 100% { opacity: 0; }
}
@keyframes fu-check {
0%, 75% { opacity: 0; transform: scale(.6); }
90%, 100% { opacity: 1; transform: scale(1); }
}
@media (max-width: 340px) {
.fu__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.fu__file--demo.is-demo .fu__fill,
.fu__file--demo.is-demo .fu__track,
.fu__file--demo.is-demo .fu__check { animation: none !important; }
.fu__zone, .fu__fill, .fu__check, .fu__track { 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);
}
.fu {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.fu__zone {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border: 1.5px dashed rgba(255, 247, 230, 0.4);
border-radius: 16px;
padding: 16px 12px;
cursor: pointer;
transition: border-color 600ms ease-out, background 600ms ease-out;
}
.fu__zone.is-over {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.12);
}
.fu__ic {
color: rgba(255, 247, 230, 0.7);
}
.fu__hint {
font-size: 11px;
color: #17141a;
text-align: center;
}
.fu__files {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.fu__file {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 247, 230, 0.08);
border-radius: 8px;
padding: 4px 8px;
}
.fu__file-name {
flex: 1 1 auto;
min-width: 0;
font-size: 10px;
color: #17141a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fu__track {
position: relative;
flex: 0 0 auto;
width: 46px;
height: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
overflow: hidden;
transition: opacity 600ms ease-out;
}
.fu__fill {
display: block;
width: 100%;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0);
transition: transform 900ms ease-out;
}
.fu__check {
flex: 0 0 auto;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4cd4a6;
color: #17141a;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 600ms ease-out, transform 600ms ease-out;
}
.fu__file--done .fu__track {
opacity: 0;
width: 0;
}
.fu__file--done .fu__check {
opacity: 1;
transform: scale(1);
}
.fu__remove {
flex: 0 0 auto;
width: 16px;
height: 16px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 247, 230, 0.6);
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.fu__file--demo.is-demo .fu__fill {
animation: fu-fill 2.6s ease-out infinite;
}
.fu__file--demo.is-demo .fu__track {
animation: fu-track 2.6s ease-out infinite;
}
.fu__file--demo.is-demo .fu__check {
animation: fu-check 2.6s ease-out infinite;
}
@keyframes fu-fill {
0% {
transform: scaleX(0);
}
70% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@keyframes fu-track {
0%, 75% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
@keyframes fu-check {
0%, 75% {
opacity: 0;
transform: scale(0.6);
}
90%, 100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.fu__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fu__file--demo.is-demo .fu__fill,
.fu__file--demo.is-demo .fu__track,
.fu__file--demo.is-demo .fu__check {
animation: none !important;
}
.fu__zone, .fu__fill, .fu__check, .fu__track {
transition: none;
}
}// 실제 동작: 끌어다 놓거나 클릭으로 고른 파일이 실제로 목록에 추가되고, 진행률이 실제로 0→100 채워진 뒤 체크로 바뀐다.
var zone = document.querySelector('.fu__zone');
var input = document.querySelector('.fu__input');
var list = document.querySelector('.fu__files');
function addFile(name) {
var li = document.createElement('li');
li.className = 'fu__file';
var nameEl = document.createElement('span'); nameEl.className = 'fu__file-name'; nameEl.textContent = name;
var track = document.createElement('span'); track.className = 'fu__track';
var fill = document.createElement('span'); fill.className = 'fu__fill';
track.appendChild(fill);
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'fu__remove';
rm.setAttribute('aria-label', name + ' 삭제'); rm.textContent = '×';
rm.addEventListener('click', function () { li.remove(); });
li.appendChild(nameEl); li.appendChild(track); li.appendChild(rm);
list.appendChild(li);
requestAnimationFrame(function () { fill.style.transform = 'scaleX(1)'; });
setTimeout(function () { li.classList.add('fu__file--done'); }, 900);
}
zone.addEventListener('click', function (e) { if (e.target !== input) input.click(); });
input.addEventListener('change', function () {
Array.prototype.forEach.call(input.files, function (f) { addFile(f.name); });
input.value = '';
});
['dragover', 'dragenter'].forEach(function (ev) {
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('is-over'); });
});
['dragleave', 'drop'].forEach(function (ev) {
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('is-over'); });
});
zone.addEventListener('drop', function (e) {
Array.prototype.forEach.call(e.dataTransfer.files, function (f) { addFile(f.name); });
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FileUpload.module.scss";
export type FileUploadProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type UploadFile = { id: string; name: string; done: boolean };
export default function FileUpload({ duration = 600, easing = "ease-out", color = "#2f6df6" }: FileUploadProps) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [over, setOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function addFiles(list: FileList | null) {
if (!list) return;
Array.from(list).forEach((f) => {
const id = `${f.name}-${Date.now()}-${Math.random()}`;
setFiles((prev) => [...prev, { id, name: f.name, done: false }]);
setTimeout(() => setFiles((prev) => prev.map((x) => (x.id === id ? { ...x, done: true } : x))), 900);
});
}
function onDrop(e: any) {
e.preventDefault(); setOver(false); addFiles(e.dataTransfer.files);
}
return (
<div className={styles.fu} style={vars}>
<label
className={`${styles.zone} ${over ? styles.over : ""}`}
tabIndex={0}
onDragOver={(e: any) => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={onDrop}
>
<span className={styles.ic}>⭱</span>
<span className={styles.hint}>Drag and drop, or click to choose</span>
<input ref={inputRef} type="file" multiple hidden onChange={(e: any) => { addFiles(e.target.files); e.target.value = ""; }} />
</label>
<ul className={styles.files}>
{files.map((f) => (
<li key={f.id} className={styles.file}>
<span className={styles.name}>{f.name}</span>
<span className={`${styles.track} ${f.done ? styles.trackDone : ""}`}>
<span className={styles.fill} style={{ transform: f.done ? "scaleX(1)" : "scaleX(0)" }} />
</span>
<span className={`${styles.check} ${f.done ? styles.checkDone : ""}`}>✓</span>
<button type="button" className={styles.remove} aria-label={`Remove ${f.name}`} onClick={() => setFiles((prev) => prev.filter((x) => x.id !== f.id))}>×</button>
</li>
))}
</ul>
</div>
);
}09 · 파일 업로드 드롭존 — 측정 누적면적 0.128% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
function addFile(name) {
var li = document.createElement('li');
li.className = 'fu__file';
var nameEl = document.createElement('span'); nameEl.className = 'fu__file-name'; nameEl.textContent = name;
var track = document.createElement('span'); track.className = 'fu__track';
var fill = document.createElement('span'); fill.className = 'fu__fill';
track.appendChild(fill);
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'fu__remove';
rm.setAttribute('aria-label', name + ' 삭제'); rm.textContent = '×';
rm.addEventListener('click', function () { li.remove(); });
li.appendChild(nameEl); li.appendChild(track); li.appendChild(rm);
list.appendChild(li);
requestAnimationFrame(function () { fill.style.transform = 'scaleX(1)'; });
setTimeout(function () { li.classList.add('fu__file--done'); }, 900);
}
어디서 깨지는가 — 함정
아홉 개를 만들면서 진짜 걸린 함정은 케밥 메뉴처럼 화면 밖으로 펼쳐지는 요소였습니다. 데스크톱 480×300에서는 멀쩡했는데 320px 폭·200px 높이로 다시 재보니 드롭다운이 화면 절반을 스크롤 영역으로 만들었습니다 — overflow: hidden으로 눈에는 안 보여도 scrollHeight는 실제 레이아웃 크기를 그대로 잰다는 뜻이었고, 메뉴 항목 3개를 2개로 줄이고 시작 위치도 위로 조금 당기고 나서야 폰 화면 검사를 통과했습니다. 칸반 보드의 유령 카드도 비슷한 함정이었습니다 — 세 칸을 순간 이동하듯 옮기면 애니메이션이 측정 구간 대부분에서 멈춰 있어 '움직이지 않는 것'으로 잡혔고, 한 칸씩 멈춰 서는 대신 2초 동안 계속 미끄러지게 바꾸고 나서야 아홉 개 전부 실제로 움직이는 것으로 측정됐습니다. 번역 사전에서도 작은 함정이 있었습니다 — 칸반(04)과 목표 카드(05)가 똑같이 '완료'라는 글자를 쓰는데, 이건 항목마다 따로 채울 필요 없이 사전에 '완료' 한 줄만 채우면 두 항목이 동시에 통과된다는 걸 뒤늦게 알았고, 덕분에 나머지 예순 개 넘는 낱말도 항목별이 아니라 글자 값 하나 기준으로 정리했습니다. 활동 기록만 따로 세로로 쌓아 올린 위젯은 알림 목록 UI 9가지에 모여 있습니다. 압축 비밀번호 usrg4zqs로 여는 zip 안의 아홉 개 소스에도 이 수정이 그대로 들어가 있습니다.
접근성
아홉 개 전부 prefers-reduced-motion: reduce에서 장식용 자동 루프(케밥 메뉴 자동 열림, 활동 항목 미끄러짐, 오늘 날짜 고리, 유령 카드 이동, 진행률 체크 순서, 온라인 점 고리, 팔레트 열림, 새 태그 슬라이드, 업로드 진행률)를 전부 끄되 실제 상태 값은 그대로 둡니다. 케밥 메뉴(01)와 커맨드 팔레트(07)는 각각 role="menu"·role="dialog"와 aria-expanded·aria-modal을 갖추고 바깥 클릭·Esc로 닫히며, 캘린더(03)의 날짜 버튼은 aria-pressed로 선택 상태를 알립니다. 팀 리스트(06)의 검색 입력과 태그 관리(08)의 삭제 버튼은 각각 aria-label로 스크린 리더에 무엇을 검색·삭제하는지 알리며, 이 계산 방식은 MDN의 Element.scrollHeight 문서에 정리돼 있습니다.
다른 대시보드 소재는 대시보드 카테고리에 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
완성된 관리자 대시보드가 아니라 대시보드 디자인 레퍼런스로 이 부품 하나만 써도 되나요?
네, 이 아홉 개를 나눠서 만든 이유가 그것입니다. 각 데모는 관리자 화면 전체가 아니라 부품 하나만 화면 가운데 두었고, zip 안에도 그 부품 하나의 HTML·SCSS·JS만 들어 있어 자기 프로젝트의 헤더나 사이드바 자리에 바로 옮기면 됩니다. 같은 화면 안에서 명령어로만 움직이는 검은 창은 터미널 UI 9종이 따로 다룹니다.
React 버전도 실제로 상태가 바뀌나요?
네. 커맨드 팔레트의 열림·닫힘, 캘린더의 선택 날짜, 태그 목록처럼 상태가 있는 항목은 전부 useState로 들고 있고, 변수 3개(duration·easing·color)는 props로 받아 CSS 사용자 지정 속성으로 넘깁니다.
파일 업로드나 진행률 막대는 실제로 작동하나요, 장식인가요?
실제로 작동합니다. 파일 업로드(09)는 고른 파일 이름을 목록에 실제로 추가하고 진행률 막대를 0에서 끝까지 채우며, 목표 진행률(05) 버튼은 누를 때마다 실제로 다음 단계 값까지 막대를 채웁니다. 자동 재생 GIF에서 보이는 반복 움직임은 사람이 손대지 않았을 때만 도는 장식 루프이며, 대부분은 실제 조작을 시작하는 순간 바로 꺼지고 나머지도 prefers-reduced-motion 설정에서는 항상 꺼집니다.








