9 Dashboard Components, Not the Whole Page
Dashboard components are UI pieces — a header, a feed, a calendar — you drop in one at a time instead of wiring a whole admin screen.
01 page-header
02 activity-feed
03 calendar-widget
04 kanban-board
06 team-list
07 command-palette
08 tag-manager
09 file-upload
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Breadcrumb + page header
- 02 Activity feed timeline
- 03 Monthly calendar widget
- 04 Mini kanban board widget
- 05 Goal progress step card
- 06 Team member list with search
- 07 Command palette (⌘K)
- 08 Tag manager input widget
- 09 File upload dropzone
The order here isn't popularity — it's the order a person actually moves through a dashboard. It starts with the header that tells you where you are, then recent activity, then planning (calendar, kanban), then a goal you're tracking, then people and search, and it ends with cleanup (tags, files). Each demo keeps a single widget centered on its own screen so you can lift out one piece, in plain SCSS or as dashboard components react files, instead of an entire admin template. The sidebar-and-stat-card recut of these same pieces is Dashboard Sidebar Design, and and the circular gauges whose numbers genuinely climb are 9 SVG Progress Ring Widgets.
01Breadcrumb + page header
What you see in these dashboard design examples
Clicking the kebab button actually scales a menu open from the top-right corner, and clicking outside or pressing Escape actually closes it.

<main class="stage">
<div class="ph is-demo">
<nav class="ph__crumbs" aria-label="Breadcrumb">
<a class="ph__crumb" href="#">Workspace</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="#">Projects</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">Settings</span>
</nav>
<div class="ph__row">
<h2 class="ph__title">Settings</h2>
<div class="ph__actions">
<button class="ph__icon-btn" type="button" aria-label="Share">
<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="Export">
<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="More actions">
<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">Rename</button>
<button class="ph__menu-item ph__menu-item--danger" role="menuitem" type="button">Delete</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 · Breadcrumb + page header — Measured area 4.097% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Good for a page that just needs a path indicator and an action menu, not a full admin shell.
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();
});
02Activity feed timeline
What happens on screen
Clicking "show new activity" actually slides a hidden entry up into view, and each type gets its own colored icon for completed, comment, or upload.

<main class="stage">
<div class="af">
<div class="af__head">
<strong class="af__title">Recent activity</strong>
<button class="af__more" type="button">Show new activity</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">Deployment completed</p><time class="af__time">3m ago</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">Minji left a comment</p><time class="af__time">18m ago</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">A new file was uploaded</p><time class="af__time">Just now</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 = 'All caught up';
});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 · Activity feed timeline — Measured area 1.846% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
.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); }
}
03Monthly calendar widget
What moves
Clicking an arrow actually advances the month and rebuilds that month's grid with a native Date object, marking event days with a dot and today with a ring.

<main class="stage">
<div class="cal">
<div class="cal__head">
<button class="cal__nav" type="button" data-dir="-1" aria-label="Previous month">
<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">September 2026</strong>
<button class="cal__nav" type="button" data-dir="1" aria-label="Next month">
<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">Su</span><span class="cal__dow">Mo</span><span class="cal__dow">Tu</span><span class="cal__dow">We</span><span class="cal__dow">Th</span><span class="cal__dow">Fr</span><span class="cal__dow">Sa</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 = '{m}/{y}';
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 · Monthly calendar widget — Measured area 0.333% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
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();
});
});
04Mini kanban board widget
What you see
Clicking a card's arrow button actually moves that card into the next column's DOM node, and once it lands in the last column the arrow becomes a check mark instead.

<main class="stage">
<div class="kb">
<div class="kb__ghost is-demo" aria-hidden="true">Design review</div>
<div class="kb__col" data-col="0">
<p class="kb__head">To do <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">Write landing copy</span>
<button class="kb__move" type="button" aria-label="Move to next column">
<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">In progress <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">Organize icon set</span>
<button class="kb__move" type="button" aria-label="Move to next column">
<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">Done <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">Clean up copy.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 · Mini kanban board widget — Measured area 4.341% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
No drag library, just three progress columns.
@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; }
}
05Goal progress step card
What happens on screen
Clicking "next step" actually fills the bar to that step's value and stamps a check on the step just passed, and the button disappears once the last step is reached.
<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">Subscription goal</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">Sign up</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">First payment</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">Integration</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">Done</span>
</li>
</ol>
<button class="gt__next" type="button">Next step</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;
}
}// 실제 동작: 'Next step'를 누르면 막대가 실제로 그 단계 값까지 채워지고 지나온 단계에 체크가 남는다.
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 · Goal progress step card — Measured area 1.372% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
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();
});
06Team member list with search
What moves
Typing a name actually keeps only matching rows visible and hides the rest, while the online status dot keeps emitting a faint expanding ring.

<main class="stage">
<div class="tl">
<div class="tl__head">
<input class="tl__search" type="text" placeholder="Search by name" aria-label="Member search">
<button class="tl__invite" type="button" aria-label="Invite">
<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="Kim Doyoon">
<span class="tl__avatar">K<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">Kim Doyoon</span>
</li>
<li class="tl__row" data-name="Lee Seojun">
<span class="tl__avatar">L<span class="tl__status tl__status--away"></span></span>
<span class="tl__name">Lee Seojun</span>
</li>
<li class="tl__row" data-name="Park Haeun">
<span class="tl__avatar">P<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">Park Haeun</span>
</li>
<li class="tl__row tl__row--new is-demo" data-name="Choi Yujin">
<span class="tl__avatar">C<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">Choi Yujin</span>
</li>
</ul>
<p class="tl__empty" hidden>No matching members</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 · Team member list with search — Measured area 1.09% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
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;
});
07Command palette (Ctrl+K)
What you see
Clicking the trigger or pressing Ctrl (or Cmd) + K actually scales the panel open at the center of the screen, typing actually filters the command list, and the arrow keys plus Enter pick one.

<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>
Quick jump <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="Command palette">
<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="Search commands…" aria-label="Command search">
</div>
<ul class="cp__list" role="listbox">
<li class="cp__item is-active" role="option" tabindex="-1" data-kw="event schedule calendar add">
<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>
Add new event
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="tag label manage">
<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>
Manage tags
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="profile account settings">
<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>
Profile settings
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="export download 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>
Export file
</li>
</ul>
<p class="cp__empty" hidden>No matching commands</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 · Command palette (⌘K) — Measured area 95.078% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
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);
}
08Tag manager input widget
What happens on screen
Typing a tag and clicking add (or pressing Enter) actually pushes a new chip onto the end of the list, and clicking a chip's × actually removes just that one tag.

<main class="stage">
<div class="tg">
<div class="tg__row">
<input class="tg__input" type="text" placeholder="Type a tag, press Enter" aria-label="New tag">
<button class="tg__add" type="button" aria-label="Add tag">
<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="Design">Design<button class="tg__remove" type="button" aria-label="Remove Design">×</button></li>
<li class="tg__chip" data-tag="Urgent">Urgent<button class="tg__remove" type="button" aria-label="Remove Urgent">×</button></li>
<li class="tg__chip tg__chip--demo is-demo" aria-hidden="true">Needs review<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) New tag 칩이 실제로 생기고, ×를 누르면 그 태그만 실제로 사라진다.
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 + ' Delete');
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 · Tag manager input widget — Measured area 1.604% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
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 + ' remove');
li.appendChild(rm);
wireRemove(li);
list.appendChild(li);
input.value = '';
input.focus({ preventScroll: true });
}
09File upload dropzone
What moves
Dropping a file or picking one from the file browser actually fills a progress bar from zero to full, then swaps it for a check icon once it's done.

<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">Drag and drop, or click to choose</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 + ' Delete'); 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 · File upload dropzone — Measured area 0.128% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
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 + ' remove'); 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);
}
Where it breaks — the trap
The real trap while building these nine was an element that expands past its own box, like the kebab menu. It sat fine at the 480×300 desktop size, but re-measuring at a 320×200 phone viewport showed the dropdown turning half the screen into scrollable space — overflow: hidden hides the overflow visually, but scrollHeight still measures the full laid-out size underneath it, so the fix was trimming the menu's item count and repositioning it until the phone check actually passed. The kanban board's ghost card hit a related trap: teleporting it between three columns left the animation frozen for most of the capture window, which the measurement script read as "not moving" — switching to one continuous two-second slide instead of three discrete jumps is what made all nine register as actually moving. There was a smaller trap in the translation dictionary too — the kanban board (04) and the goal card (05) both use the word "Done," and it turns out the dictionary only needs that one string filled in once, not once per item, for both to pass; once that clicked, the rest of the roughly seventy strings across all nine demos got organized the same way, by exact text rather than by which item happened to use it. Stacking activity records into one vertical column of their own is 9 Activity Feed UI Widgets. The zip sitting behind the download password usrg4zqs ships every one of these fixes.
Accessibility (reduced-motion)
All nine turn off their decorative auto-play loops under prefers-reduced-motion: reduce (the kebab menu auto-opening, the feed item sliding in, today's pulsing ring, the ghost card, the step checks lighting up in sequence, the presence ping, the palette opening, the new tag chip, the upload progress) while leaving the real state untouched. The kebab menu (01) and the command palette (07) use role="menu"/role="dialog" with aria-expanded/aria-modal and close on an outside click or Escape, and the calendar's (03) day buttons report selection with aria-pressed. The search field in the team list (06) and the remove buttons in the tag manager (08) each carry an aria-label describing what they search or delete, and the layout math behind the trap above is documented on MDN's Element.scrollHeight page.
More dashboard pieces live in the dashboard category, and what this site covers overall is on the about page.
FAQ
Can I use just one dashboard components ui piece instead of a full admin dashboard?
Yes, that's the reason they're split up this way. Each demo keeps a single widget centered on its own page instead of a full admin layout, and the zip only ships that one piece's HTML, SCSS, and JS, so you can drop it straight into your own header or sidebar slot. The black window on the same screen that moves only on typed commands belongs to Terminal UI Design.
Does the React version actually hold state too?
Yes. Anything stateful — the command palette opening and closing, the calendar's selected day, the tag list — is held in useState, and the three variables (duration, easing, color) come in as props and get passed through as CSS custom properties.
Is the file upload or the progress bar just for show?
No, both actually run. The file upload (09) really adds the picked file's name to the list and fills a progress bar from zero to full, and the goal tracker (05) button really fills the bar to the next step's value every time you click it. The looping motion you see in the autoplay GIF is a decorative loop that only runs while nobody has interacted yet, and it stops the instant you click.








