ダッシュボード コンポーネント9選 — 画面ごとじゃなくこの部品だけ
ダッシュボード コンポーネントとは、完成した管理画面をまるごと組み込む代わりに、ヘッダーやフィード、カレンダーのように画面の一部品だけをそのまま自分のプロジェクトに移せるUI部品です。画面ごとではなくこの部品だけで、九つのうち一つは Ctrl(⌘)+K で開くコマンドパレットまで矢印キーで実際に動きます。
01 page-header
02 activity-feed
03 calendar-widget
04 kanban-board
06 team-list
07 command-palette
08 tag-manager
09 file-upload
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 パンくず + ページヘッダー
- 02 アクティビティフィード タイムライン
- 03 月間カレンダーウィジェット
- 04 ミニ カンバンボード ウィジェット
- 05 目標進捗ステップカード
- 06 チームメンバーリスト(検索)
- 07 コマンドパレット(⌘K)
- 08 タグ管理入力ウィジェット
- 09 ファイルアップロード ドロップゾーン
ダッシュボード デザイン サンプル九つは、よく使われる順ではなく、実際にダッシュボードを使う人がたどる順番で並べました。今どこにいるかを示すヘッダーから始まり、最近のアクティビティを確認し、予定(カレンダー)とタスク(カンバン)を管理してから目標の進捗を見て、協業(チーム・検索)と整理(タグ・ファイル)で締めくくります。完成した管理画面テンプレートではなくこの部品一つだけを取り出せるように、各デモは画面中央にウィジェット一つだけを置いています。同じ部品をサイドバーと統計カード中心に切り直したのがダッシュボード ウィジェット単体UI9選で、数値が実際に上がっていく円形ゲージだけを集めたのが円形プログレスバー css スタットリング9選です。
01パンくず + ページヘッダー
ダッシュボード デザイン 参考として何が見えるか
ケバブ(⋮)ボタンを押すと右上を起点に実際にメニューが拡大しながら開き、外側をクリックするか Esc を押すと実際に閉じます。

<main class="stage">
<div class="ph is-demo">
<nav class="ph__crumbs" aria-label="パンくずリスト">
<a class="ph__crumb" href="#">ワークスペース</a>
<span class="ph__sep" aria-hidden="true"><svg viewBox="0 0 256 256" width="10" height="10"><g fill="currentColor"><path d="M180 128a4 4 0 0 1-1.17 2.83l-88 88a4 4 0 1 1-5.66-5.66L167.51 128L85.17 45.83a4 4 0 1 1 5.66-5.66l88 88A4 4 0 0 1 180 128" opacity=".2"/><path d="M181.66 133.66l-88 88a8 8 0 0 1-11.32-11.32L163.31 128L82.34 47.66a8 8 0 0 1 11.32-11.32l88 88a8 8 0 0 1 0 11.32"/></g></svg></span>
<a class="ph__crumb" href="#">プロジェクト</a>
<span class="ph__sep" aria-hidden="true"><svg viewBox="0 0 256 256" width="10" height="10"><g fill="currentColor"><path d="M180 128a4 4 0 0 1-1.17 2.83l-88 88a4 4 0 1 1-5.66-5.66L167.51 128L85.17 45.83a4 4 0 1 1 5.66-5.66l88 88A4 4 0 0 1 180 128" opacity=".2"/><path d="M181.66 133.66l-88 88a8 8 0 0 1-11.32-11.32L163.31 128L82.34 47.66a8 8 0 0 1 11.32-11.32l88 88a8 8 0 0 1 0 11.32"/></g></svg></span>
<span class="ph__crumb ph__crumb--current" aria-current="page">設定</span>
</nav>
<div class="ph__row">
<h2 class="ph__title">設定</h2>
<div class="ph__actions">
<button class="ph__icon-btn" type="button" aria-label="共有">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M188 96a28 28 0 1 0-25.94-38.7l-58.66 34.36a28 28 0 1 0 0 44.68l58.66 34.36A28 28 0 1 0 176 148a27.7 27.7 0 0 0-1.63 9.4l-58.66-34.36a27.9 27.9 0 0 0 0-18.08l58.66-34.36A27.9 27.9 0 0 0 188 96" opacity=".2"/><path d="M188 152a35.7 35.7 0 0 0-23.6 8.9l-56.1-32.87a36.2 36.2 0 0 0 0-16l56.1-32.87A36 36 0 1 0 152 60a35.6 35.6 0 0 0 1.09 8.66l-56.1 32.87a36 36 0 1 0 0 52.94l56.1 32.87A36 36 0 1 0 188 152m0-128a20 20 0 1 1-20 20a20 20 0 0 1 20-20M60 148a20 20 0 1 1 20-20a20 20 0 0 1-20 20m128 76a20 20 0 1 1 20-20a20 20 0 0 1-20 20"/></g></svg>
</button>
<button class="ph__icon-btn" type="button" aria-label="エクスポート">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg>
</button>
<div class="ph__menu-wrap">
<button class="ph__kebab" type="button" aria-haspopup="true" aria-expanded="false" aria-label="その他の操作">
<svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M176 32v192a16 16 0 0 1-16 16H96a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16" opacity=".2"/><path d="M140 128a12 12 0 1 1-12-12a12 12 0 0 1 12 12m-12-56a12 12 0 1 0-12-12a12 12 0 0 0 12 12m0 112a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg>
</button>
<div class="ph__menu" role="menu">
<button class="ph__menu-item" role="menuitem" type="button">名前を変更</button>
<button class="ph__menu-item ph__menu-item--danger" role="menuitem" type="button">削除</button>
</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.ph {
box-sizing: border-box;
width: min(280px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.ph__crumbs { display: flex; align-items: center; gap: $sp-1; margin: 0 0 $sp-2; }
.ph__crumb {
font-size: 11px; font-weight: 600; color: rgba(23, 20, 26, .72);
text-decoration: none;
}
.ph__crumb--current { color: $stage-ink; }
.ph__sep { display: inline-flex; color: rgba(23, 20, 26, .3); }
.ph__row { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.ph__title { margin: 0; font-size: 18px; font-weight: 800; }
.ph__actions { display: flex; align-items: center; gap: $sp-1; position: relative; }
.ph__icon-btn, .ph__kebab {
box-sizing: border-box;
width: 28px; height: 28px;
display: grid; place-items: center;
border: 1px solid rgba(23, 20, 26, .12);
background: #fff;
border-radius: $r-control;
color: rgba(23, 20, 26, .6);
cursor: pointer;
transition: background $duration $easing, color $duration $easing;
}
.ph__icon-btn:hover, .ph__kebab:hover { background: rgba(47, 109, 246, .08); color: $color; }
.ph__menu-wrap { position: relative; }
.ph__menu {
position: absolute;
top: 30px; right: 0;
box-sizing: border-box;
width: 116px;
background: #fff;
border-radius: $r-sm;
box-shadow: $shadow-raised;
padding: $sp-1;
transform-origin: top right; // 규칙8: 케밥 오른쪽 변에서 팝
transform: scale(.85);
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.ph__menu-wrap.is-open .ph__menu { transform: scale(1); opacity: 1; pointer-events: auto; }
.ph__menu-item {
display: block;
width: 100%;
box-sizing: border-box;
text-align: left;
border: 0; background: transparent; cursor: pointer;
font: inherit; font-size: 11px; font-weight: 600; color: $stage-ink;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
}
.ph__menu-item:hover { background: rgba(23, 20, 26, .06); }
.ph__menu-item--danger { color: $error; }
// ── 자동 루프: 메뉴가 열렸다 닫혔다를 반복한다 (실제 상태와 별개) ──────────────
.ph.is-demo .ph__menu {
animation: ph-menu-loop 2s $easing infinite;
}
@keyframes ph-menu-loop {
0%, 100% { transform: scale(.85); opacity: 0; }
40% { transform: scale(1); opacity: 1; }
85% { transform: scale(1); opacity: 1; }
}
@media (max-width: 340px) {
.ph__title { font-size: 16px; }
.ph__menu { width: 116px; }
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__menu { animation: none !important; }
.ph__menu, .ph__icon-btn, .ph__kebab { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ph {
box-sizing: border-box;
width: min(280px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ph__crumbs {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.ph__crumb {
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
text-decoration: none;
}
.ph__crumb--current {
color: #17141a;
}
.ph__sep {
display: inline-flex;
color: rgba(23, 20, 26, 0.3);
}
.ph__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ph__title {
margin: 0;
font-size: 18px;
font-weight: 800;
}
.ph__actions {
display: flex;
align-items: center;
gap: 4px;
position: relative;
}
.ph__icon-btn, .ph__kebab {
box-sizing: border-box;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border: 1px solid rgba(23, 20, 26, 0.12);
background: #fff;
border-radius: 12px;
color: rgba(23, 20, 26, 0.6);
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), color 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ph__icon-btn:hover, .ph__kebab:hover {
background: rgba(47, 109, 246, 0.08);
color: #2f6df6;
}
.ph__menu-wrap {
position: relative;
}
.ph__menu {
position: absolute;
top: 30px;
right: 0;
box-sizing: border-box;
width: 116px;
background: #fff;
border-radius: 8px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 4px;
transform-origin: top right;
transform: scale(0.85);
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ph__menu-wrap.is-open .ph__menu {
transform: scale(1);
opacity: 1;
pointer-events: auto;
}
.ph__menu-item {
display: block;
width: 100%;
box-sizing: border-box;
text-align: left;
border: 0;
background: transparent;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 600;
color: #17141a;
padding: 4px 8px;
border-radius: 4px;
}
.ph__menu-item:hover {
background: rgba(23, 20, 26, 0.06);
}
.ph__menu-item--danger {
color: #c2361a;
}
.ph.is-demo .ph__menu {
animation: ph-menu-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes ph-menu-loop {
0%, 100% {
transform: scale(0.85);
opacity: 0;
}
40% {
transform: scale(1);
opacity: 1;
}
85% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 340px) {
.ph__title {
font-size: 16px;
}
.ph__menu {
width: 116px;
}
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__menu {
animation: none !important;
}
.ph__menu, .ph__icon-btn, .ph__kebab {
transition: none;
}
}// 실제 동작: 케밥 버튼을 누르면 메뉴가 실제로 열리고, 바깥 클릭이나 Esc로 실제로 닫힌다.
var wrap = document.querySelector('.ph__menu-wrap');
var kebab = document.querySelector('.ph__kebab');
var menu = document.querySelector('.ph__menu');
function close() {
wrap.classList.remove('is-open');
kebab.setAttribute('aria-expanded', 'false');
}
kebab.addEventListener('click', function (e) {
document.querySelector('.ph').classList.remove('is-demo');
e.stopPropagation();
var open = wrap.classList.toggle('is-open');
kebab.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.ph__menu-wrap')) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { close(); kebab.focus({ preventScroll: true }); }
});
menu.querySelectorAll('.ph__menu-item').forEach(function (it) {
it.addEventListener('click', close);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PageHeader.module.scss";
export type PageHeaderProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PageHeader({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: PageHeaderProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const kebabRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); kebabRef.current?.focus({ preventScroll: true }); }
}
document.addEventListener("click", onDocClick);
document.addEventListener("keydown", onKey);
return () => { document.removeEventListener("click", onDocClick); document.removeEventListener("keydown", onKey); };
}, []);
return (
<div className={styles.ph} style={vars}>
<nav className={styles.crumbs} aria-label="Breadcrumb">
<a className={styles.crumb} href="#">Workspace</a>
<span className={styles.sep} aria-hidden="true">›</span>
<a className={styles.crumb} href="#">Projects</a>
<span className={styles.sep} aria-hidden="true">›</span>
<span className={`${styles.crumb} ${styles.current}`} aria-current="page">Settings</span>
</nav>
<div className={styles.row}>
<h2 className={styles.title}>Settings</h2>
<div className={styles.actions} ref={wrapRef}>
<button type="button" className={styles.iconBtn} aria-label="Share">⇪</button>
<button type="button" className={styles.iconBtn} aria-label="Export">⇩</button>
<div className={styles.menuWrap}>
<button
ref={kebabRef}
type="button"
className={styles.kebab}
aria-haspopup="true"
aria-expanded={open}
aria-label="More actions"
onClick={() => setOpen((o) => !o)}
>
⋮
</button>
<div className={`${styles.menu} ${open ? styles.open : ""}`} role="menu">
<button className={styles.menuItem} role="menuitem" type="button" onClick={() => setOpen(false)}>Rename</button>
<button className={`${styles.menuItem} ${styles.danger}`} role="menuitem" type="button" onClick={() => setOpen(false)}>Delete</button>
</div>
</div>
</div>
</div>
</div>
);
}01 · パンくず + ページヘッダー — 計測 累積面積 4.097% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
完成した管理画面なしにパンくずとアクションメニューだけが必要なページに向いています。
function close() {
wrap.classList.remove('is-open');
kebab.setAttribute('aria-expanded', 'false');
}
kebab.addEventListener('click', function (e) {
document.querySelector('.ph').classList.remove('is-demo');
e.stopPropagation();
var open = wrap.classList.toggle('is-open');
kebab.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.ph__menu-wrap')) close();
});
02アクティビティフィード タイムライン
画面で起きること
「新しい活動を見る」を押すと隠れていた項目が実際に上へスライドして現れ、完了・コメント・アップロードの種類ごとに色の違う丸アイコンで区別します。

<main class="stage">
<div class="af">
<div class="af__head">
<strong class="af__title">最近のアクティビティ</strong>
<button class="af__more" type="button">新しい活動を見る</button>
</div>
<ul class="af__list">
<li class="af__item">
<span class="af__ic af__ic--check"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span>
<div class="af__body"><p class="af__text">デプロイが完了しました</p><time class="af__time">3分前</time></div>
</li>
<li class="af__item">
<span class="af__ic af__ic--chat"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M128 24a104 104 0 0 0-91.82 152.88l-11.35 34.05a16 16 0 0 0 20.24 20.24l34.05-11.35A104 104 0 1 0 128 24"/></g></svg></span>
<div class="af__body"><p class="af__text">ミンジさんがコメントしました</p><time class="af__time">18分前</time></div>
</li>
<li class="af__item af__item--new is-demo">
<span class="af__ic af__ic--upload"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg></span>
<div class="af__body"><p class="af__text">新しいファイルがアップロードされました</p><time class="af__time">たった今</time></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.af {
box-sizing: border-box;
width: min(260px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.af__head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 $sp-3; }
.af__title { font-size: 13px; }
.af__more {
border: 1px solid rgba(255, 247, 230, .35);
background: transparent;
color: $subject-cream;
font: inherit; font-weight: 700; font-size: 10px;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
cursor: pointer;
}
.af__more:disabled { opacity: .5; cursor: default; }
.af__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-3; }
.af__item { display: flex; align-items: flex-start; gap: $sp-2; }
.af__ic {
flex: 0 0 auto;
width: 20px; height: 20px;
border-radius: 50%;
display: grid; place-items: center;
color: $stage-ink;
}
.af__ic--check { background: $subject-mint; }
.af__ic--chat { background: $subject-sky; }
.af__ic--upload { background: $color; color: $subject-cream; }
.af__body { min-width: 0; }
.af__text { margin: 0; font-size: 12px; line-height: 1.4; }
.af__time { font-size: 10px; color: rgba(255, 247, 230, .5); }
.af__item--new {
opacity: 0;
transform: translateY(10px);
transition: opacity $duration $easing, transform $duration $easing;
}
.af__item--new.af__item--shown { opacity: 1; transform: translateY(0); }
// ── 자동 루프: 새 활동 항목이 계속 나타났다 사라진다 ──────────────
.af__item--new.is-demo { animation: af-in 2s $easing infinite; }
@keyframes af-in {
0% { opacity: 0; transform: translateY(10px); }
35% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(10px); }
}
@media (max-width: 340px) {
.af__text { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.af__item--new.is-demo { animation: none !important; opacity: 1; transform: translateY(0); }
.af__item--new { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.af {
box-sizing: border-box;
width: min(260px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.af__head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 12px;
}
.af__title {
font-size: 13px;
}
.af__more {
border: 1px solid rgba(255, 247, 230, 0.35);
background: transparent;
color: #fff7e6;
font: inherit;
font-weight: 700;
font-size: 10px;
padding: 4px 8px;
border-radius: 999px;
cursor: pointer;
}
.af__more:disabled {
opacity: 0.5;
cursor: default;
}
.af__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.af__item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.af__ic {
flex: 0 0 auto;
width: 20px;
height: 20px;
border-radius: 50%;
display: grid;
place-items: center;
color: #17141a;
}
.af__ic--check {
background: #4cd4a6;
}
.af__ic--chat {
background: #6ec8ff;
}
.af__ic--upload {
background: #2f6df6;
color: #fff7e6;
}
.af__body {
min-width: 0;
}
.af__text {
margin: 0;
font-size: 12px;
line-height: 1.4;
}
.af__time {
font-size: 10px;
color: rgba(255, 247, 230, 0.5);
}
.af__item--new {
opacity: 0;
transform: translateY(10px);
transition: opacity 600ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.af__item--new.af__item--shown {
opacity: 1;
transform: translateY(0);
}
.af__item--new.is-demo {
animation: af-in 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes af-in {
0% {
opacity: 0;
transform: translateY(10px);
}
35% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(10px);
}
}
@media (max-width: 340px) {
.af__text {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.af__item--new.is-demo {
animation: none !important;
opacity: 1;
transform: translateY(0);
}
.af__item--new {
transition: none;
}
}// 실제 동작: 버튼을 누르면 숨어 있던 새 활동 항목이 실제로 나타나고, 버튼은 다 쓰면 사라진다.
var btn = document.querySelector('.af__more');
var last = document.querySelector('.af__item--new');
btn.addEventListener('click', function () {
last.classList.remove('is-demo');
last.classList.add('af__item--shown');
btn.disabled = true;
btn.textContent = 'すべて確認済み';
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ActivityFeed.module.scss";
export type ActivityFeedProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ActivityFeed({ duration = 600, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#2f6df6" }: ActivityFeedProps) {
const [shown, setShown] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.af} style={vars}>
<div className={styles.head}>
<strong className={styles.title}>Recent activity</strong>
<button type="button" className={styles.more} disabled={shown} onClick={() => setShown(true)}>
{shown ? "All caught up" : "Show new activity"}
</button>
</div>
<ul className={styles.list}>
<li className={styles.item}>
<span className={`${styles.ic} ${styles.check}`}>✓</span>
<div><p className={styles.text}>Deployment completed</p><time className={styles.time}>3m ago</time></div>
</li>
<li className={styles.item}>
<span className={`${styles.ic} ${styles.chat}`}>💬</span>
<div><p className={styles.text}>Minji left a comment</p><time className={styles.time}>18m ago</time></div>
</li>
<li className={`${styles.item} ${styles.newItem} ${shown ? styles.shown : ""}`}>
<span className={`${styles.ic} ${styles.upload}`}>↑</span>
<div><p className={styles.text}>A new file was uploaded</p><time className={styles.time}>Just now</time></div>
</li>
</ul>
</div>
);
}02 · アクティビティフィード タイムライン — 計測 累積面積 1.846% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
.af__item--new {
opacity: 0;
transform: translateY(10px);
transition: opacity $duration $easing, transform $duration $easing;
}
.af__item--new.af__item--shown { opacity: 1; transform: translateY(0); }
.af__item--new.is-demo { animation: af-in 2s $easing infinite; }
@keyframes af-in {
0% { opacity: 0; transform: translateY(10px); }
35% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(10px); }
}
03月間カレンダーウィジェット
どんな動きか
矢印を押すと実際に月が切り替わり、Date オブジェクトでその月のマスを再描画します。

<main class="stage">
<div class="cal">
<div class="cal__head">
<button class="cal__nav" type="button" data-dir="-1" aria-label="前の月">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m165.66 202.34l-72-72a8 8 0 0 1 0-11.32l72-72a8 8 0 0 1 11.32 11.32L109.31 122H208a8 8 0 0 1 0 16h-98.69l67.67 63.66a8 8 0 0 1-11.32 11.32Z"/></g></svg>
</button>
<strong class="cal__month">2026年9月</strong>
<button class="cal__nav" type="button" data-dir="1" aria-label="次の月">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M181.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L165.31 138H56a8 8 0 0 1 0-16h109.31l-67-63.66a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 .03 11.32Z"/></g></svg>
</button>
</div>
<div class="cal__dows">
<span class="cal__dow">日</span><span class="cal__dow">月</span><span class="cal__dow">火</span><span class="cal__dow">水</span><span class="cal__dow">木</span><span class="cal__dow">金</span><span class="cal__dow">土</span>
</div>
<div class="cal__grid is-demo"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-pink;
@include stage($stage-paper);
.cal {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.cal__head { display: flex; align-items: center; justify-content: space-between; margin: 0 0 $sp-2; }
.cal__month { font-size: 12px; font-weight: 800; }
.cal__nav {
width: 20px; height: 20px; padding: 0;
border: 0; background: transparent; color: rgba(23, 20, 26, .5);
display: grid; place-items: center;
border-radius: $r-xs; cursor: pointer;
}
.cal__nav:hover { background: rgba(23, 20, 26, .06); color: $stage-ink; }
.cal__dows, .cal__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: $sp-1; }
.cal__dows { margin: 0 0 $sp-1; }
.cal__dow {
height: 14px; display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700; color: rgba(23, 20, 26, .72);
}
.cal__day {
position: relative;
height: 16px; padding: 0;
border: 0; background: transparent;
font: inherit; font-size: 10px; color: $stage-ink;
border-radius: $r-xs;
cursor: pointer;
}
.cal__day:empty { visibility: hidden; }
.cal__day:hover { background: rgba(23, 20, 26, .06); }
.cal__day[aria-pressed="true"] { background: $color; color: #fff; font-weight: 700; }
.cal__day--today { font-weight: 800; box-shadow: inset 0 0 0 1px $color; }
.cal__day--event::after {
content: "";
position: absolute; left: 50%; bottom: 1px;
width: 3px; height: 3px; border-radius: 50%;
background: $color;
transform: translateX(-50%);
}
.cal__day[aria-pressed="true"].cal__day--event::after { background: #fff; }
// ── 자동 루프: 오늘 칸에 옅은 고리가 계속 퍼졌다 사라진다 ──────────────
.cal__day--today::before {
content: "";
position: absolute; inset: 0;
border-radius: $r-xs;
border: 1px solid $color;
opacity: 0;
}
.cal__grid.is-demo .cal__day--today::before {
animation: cal-ping 2s $easing infinite;
}
@keyframes cal-ping {
0% { transform: scale(1); opacity: .7; }
70% { transform: scale(1.6); opacity: 0; }
100% { transform: scale(1.6); opacity: 0; }
}
@media (max-width: 340px) {
.cal { width: min(216px, 84vw); }
.cal__day, .cal__dow { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.cal__grid.is-demo .cal__day--today::before { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cal {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.cal__head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px;
}
.cal__month {
font-size: 12px;
font-weight: 800;
}
.cal__nav {
width: 20px;
height: 20px;
padding: 0;
border: 0;
background: transparent;
color: rgba(23, 20, 26, 0.5);
display: grid;
place-items: center;
border-radius: 4px;
cursor: pointer;
}
.cal__nav:hover {
background: rgba(23, 20, 26, 0.06);
color: #17141a;
}
.cal__dows, .cal__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.cal__dows {
margin: 0 0 4px;
}
.cal__dow {
height: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
}
.cal__day {
position: relative;
height: 16px;
padding: 0;
border: 0;
background: transparent;
font: inherit;
font-size: 10px;
color: #17141a;
border-radius: 4px;
cursor: pointer;
}
.cal__day:empty {
visibility: hidden;
}
.cal__day:hover {
background: rgba(23, 20, 26, 0.06);
}
.cal__day[aria-pressed=true] {
background: #ff9ebb;
color: #fff;
font-weight: 700;
}
.cal__day--today {
font-weight: 800;
box-shadow: inset 0 0 0 1px #ff9ebb;
}
.cal__day--event::after {
content: "";
position: absolute;
left: 50%;
bottom: 1px;
width: 3px;
height: 3px;
border-radius: 50%;
background: #ff9ebb;
transform: translateX(-50%);
}
.cal__day[aria-pressed=true].cal__day--event::after {
background: #fff;
}
.cal__day--today::before {
content: "";
position: absolute;
inset: 0;
border-radius: 4px;
border: 1px solid #ff9ebb;
opacity: 0;
}
.cal__grid.is-demo .cal__day--today::before {
animation: cal-ping 2s ease-out infinite;
}
@keyframes cal-ping {
0% {
transform: scale(1);
opacity: 0.7;
}
70% {
transform: scale(1.6);
opacity: 0;
}
100% {
transform: scale(1.6);
opacity: 0;
}
}
@media (max-width: 340px) {
.cal {
width: min(216px, 84vw);
}
.cal__day, .cal__dow {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.cal__grid.is-demo .cal__day--today::before {
animation: none !important;
}
}// 실제 동작: 화살표를 누르면 달이 실제로 바뀌며 날짜 칸이 다시 그려지고, 날짜를 누르면 그 칸만 선택된다.
var EVENTS = [4, 11, 22];
var MONTH_LABEL = '{y}年{m}月';
var grid = document.querySelector('.cal__grid');
var label = document.querySelector('.cal__month');
var cur = { y: 2026, m: 8 };
function build() {
grid.classList.remove('is-demo');
grid.innerHTML = '';
var first = new Date(cur.y, cur.m, 1).getDay();
var days = new Date(cur.y, cur.m + 1, 0).getDate();
for (var i = 0; i < first; i++) grid.appendChild(document.createElement('span'));
for (var d = 1; d <= days; d++) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'cal__day'; b.textContent = String(d);
b.setAttribute('aria-pressed', 'false');
if (cur.y === 2026 && cur.m === 8 && d === 11) b.classList.add('cal__day--today');
if (cur.y === 2026 && cur.m === 8 && EVENTS.indexOf(d) !== -1) b.classList.add('cal__day--event');
b.addEventListener('click', function () {
grid.querySelectorAll('[aria-pressed="true"]').forEach(function (o) { o.setAttribute('aria-pressed', 'false'); });
this.setAttribute('aria-pressed', 'true');
});
grid.appendChild(b);
}
label.textContent = MONTH_LABEL.replace('{y}', cur.y).replace('{m}', cur.m + 1);
}
document.querySelectorAll('.cal__nav').forEach(function (btn) {
btn.addEventListener('click', function () {
cur.m += Number(btn.dataset.dir);
if (cur.m < 0) { cur.m = 11; cur.y--; }
if (cur.m > 11) { cur.m = 0; cur.y++; }
build();
});
});
build();
grid.classList.add('is-demo');import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CalendarWidget.module.scss";
export type CalendarWidgetProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const DOWS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
const EVENTS = [4, 11, 22];
const TODAY = { y: 2026, m: 8, d: 11 };
export default function CalendarWidget({ duration = 220, easing = "ease-out", color = "#ff9ebb" }: CalendarWidgetProps) {
const [cur, setCur] = useState({ y: 2026, m: 8 });
const [selected, setSelected] = useState<number | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const days = useMemo(() => {
const first = new Date(cur.y, cur.m, 1).getDay();
const count = new Date(cur.y, cur.m + 1, 0).getDate();
return { blanks: first, count };
}, [cur]);
function nav(dir: number) {
setCur((c) => {
let m = c.m + dir, y = c.y;
if (m < 0) { m = 11; y--; }
if (m > 11) { m = 0; y++; }
return { y, m };
});
setSelected(null);
}
const monthLabel = new Date(cur.y, cur.m, 1).toLocaleString("en-US", { month: "long", year: "numeric" });
return (
<div className={styles.cal} style={vars}>
<div className={styles.head}>
<button type="button" className={styles.nav} aria-label="Previous month" onClick={() => nav(-1)}>‹</button>
<strong className={styles.month}>{monthLabel}</strong>
<button type="button" className={styles.nav} aria-label="Next month" onClick={() => nav(1)}>›</button>
</div>
<div className={styles.dows}>{DOWS.map((d) => <span key={d} className={styles.dow}>{d}</span>)}</div>
<div className={styles.grid}>
{Array.from({ length: days.blanks }).map((_, i) => <span key={`b${i}`} />)}
{Array.from({ length: days.count }).map((_, i) => {
const d = i + 1;
const isToday = cur.y === TODAY.y && cur.m === TODAY.m && d === TODAY.d;
const hasEvent = cur.y === TODAY.y && cur.m === TODAY.m && EVENTS.includes(d);
return (
<button
key={d}
type="button"
className={`${styles.day} ${isToday ? styles.today : ""} ${hasEvent ? styles.event : ""}`}
aria-pressed={selected === d}
onClick={() => setSelected(d)}
>
{d}
</button>
);
})}
</div>
</div>
);
}03 · 月間カレンダーウィジェット — 計測 累積面積 0.333% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
予定がある日は点で、今日は枠線で示されます。
document.querySelectorAll('.cal__nav').forEach(function (btn) {
btn.addEventListener('click', function () {
cur.m += Number(btn.dataset.dir);
if (cur.m < 0) { cur.m = 11; cur.y--; }
if (cur.m > 11) { cur.m = 0; cur.y++; }
build();
});
});
04ミニ カンバンボード ウィジェット
何が見えるか
カードの矢印ボタンを押すとそのカードが実際に次の列のDOMへ移動し、最後の列に着くと矢印の代わりにチェックアイコンに変わります。

<main class="stage">
<div class="kb">
<div class="kb__ghost is-demo" aria-hidden="true">デザインレビュー</div>
<div class="kb__col" data-col="0">
<p class="kb__head">未着手 <span class="kb__count">1</span></p>
<div class="kb__cards">
<div class="kb__card">
<span class="kb__dot kb__dot--warn"></span>
<span class="kb__label">LPコピー作成</span>
<button class="kb__move" type="button" aria-label="次の列へ移動">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M221.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L196.69 136H40a8 8 0 0 1 0-16h156.69l-58.35-58.34a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 0 11.32"/></g></svg>
</button>
</div>
</div>
</div>
<div class="kb__col" data-col="1">
<p class="kb__head">進行中 <span class="kb__count">1</span></p>
<div class="kb__cards">
<div class="kb__card">
<span class="kb__dot kb__dot--info"></span>
<span class="kb__label">アイコンセット整理</span>
<button class="kb__move" type="button" aria-label="次の列へ移動">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M221.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L196.69 136H40a8 8 0 0 1 0-16h156.69l-58.35-58.34a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 0 11.32"/></g></svg>
</button>
</div>
</div>
</div>
<div class="kb__col kb__col--done" data-col="2">
<p class="kb__head">完了 <span class="kb__count">1</span></p>
<div class="kb__cards">
<div class="kb__card kb__card--done">
<span class="kb__ic">
<svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>
</span>
<span class="kb__label">文言.json整理</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-ink);
.kb {
position: relative;
box-sizing: border-box;
width: min(280px, 88vw);
display: flex;
gap: $sp-2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.kb__col {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
}
.kb__head {
margin: 0 0 $sp-2;
font-size: 9px; font-weight: 700;
color: rgba(255, 247, 230, .55);
display: flex; align-items: center; gap: $sp-1;
}
.kb__count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 14px; height: 14px; padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(255, 247, 230, .12);
font-size: 9px; color: $color;
}
.kb__cards { display: flex; flex-direction: column; gap: $sp-2; }
.kb__card {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-1;
background: rgba(255, 247, 230, .06);
border: 1px solid rgba(255, 247, 230, .12);
border-radius: $r-sm;
padding: $sp-2;
transition: opacity $duration $easing, transform $duration $easing;
}
.kb__card--moving { opacity: 0; transform: scale(.9); }
.kb__dot { flex: 0 0 auto; width: 6px; height: 6px; border-radius: 50%; }
.kb__dot--warn { background: #d0bd9f; }
.kb__dot--info { background: $subject-sky; }
.kb__label { flex: 1 1 auto; min-width: 0; font-size: 10px; color: $color; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kb__move {
flex: 0 0 auto;
width: 18px; height: 18px; padding: 0;
border: 0; background: transparent; color: rgba(255, 247, 230, .55);
display: grid; place-items: center; cursor: pointer;
border-radius: $r-xs;
}
.kb__move:hover { background: rgba(255, 247, 230, .12); color: $color; }
.kb__ic { flex: 0 0 auto; color: $subject-mint; }
.kb__card--done { opacity: .85; }
// ── 자동 루프: 유령 카드가 세 칸을 계속 이동한다 (실제 카드와 별개) ──────────────
.kb__ghost {
position: absolute;
top: 20px; left: 0;
box-sizing: border-box;
width: 74px;
padding: $sp-1 $sp-2;
border-radius: $r-sm;
background: rgba(255, 247, 230, .1);
border: 1px dashed rgba(255, 247, 230, .3);
font-size: 9px; color: rgba(255, 247, 230, .7);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
pointer-events: none;
opacity: 0;
}
.kb__ghost.is-demo { animation: kb-move 2s linear infinite; }
@keyframes kb-move {
0% { transform: translateX(0) translateY(0); opacity: .9; }
45% { transform: translateX(96px) translateY(-3px); opacity: .9; }
90% { transform: translateX(192px) translateY(0); opacity: .9; }
96% { transform: translateX(192px) translateY(0); opacity: 0; }
100% { transform: translateX(0) translateY(0); opacity: 0; }
}
@media (max-width: 340px) {
.kb__label { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.kb__ghost.is-demo { animation: none !important; opacity: 0; }
.kb__card { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.kb {
position: relative;
box-sizing: border-box;
width: min(280px, 88vw);
display: flex;
gap: 8px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.kb__col {
box-sizing: border-box;
flex: 1 1 0;
min-width: 0;
}
.kb__head {
margin: 0 0 8px;
font-size: 9px;
font-weight: 700;
color: rgba(255, 247, 230, 0.55);
display: flex;
align-items: center;
gap: 4px;
}
.kb__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 14px;
height: 14px;
padding: 0 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
font-size: 9px;
color: #fff7e6;
}
.kb__cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.kb__card {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 247, 230, 0.06);
border: 1px solid rgba(255, 247, 230, 0.12);
border-radius: 8px;
padding: 8px;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.kb__card--moving {
opacity: 0;
transform: scale(0.9);
}
.kb__dot {
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 50%;
}
.kb__dot--warn {
background: #d0bd9f;
}
.kb__dot--info {
background: #6ec8ff;
}
.kb__label {
flex: 1 1 auto;
min-width: 0;
font-size: 10px;
color: #fff7e6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kb__move {
flex: 0 0 auto;
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 247, 230, 0.55);
display: grid;
place-items: center;
cursor: pointer;
border-radius: 4px;
}
.kb__move:hover {
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
}
.kb__ic {
flex: 0 0 auto;
color: #4cd4a6;
}
.kb__card--done {
opacity: 0.85;
}
.kb__ghost {
position: absolute;
top: 20px;
left: 0;
box-sizing: border-box;
width: 74px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
border: 1px dashed rgba(255, 247, 230, 0.3);
font-size: 9px;
color: rgba(255, 247, 230, 0.7);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
opacity: 0;
}
.kb__ghost.is-demo {
animation: kb-move 2s linear infinite;
}
@keyframes kb-move {
0% {
transform: translateX(0) translateY(0);
opacity: 0.9;
}
45% {
transform: translateX(96px) translateY(-3px);
opacity: 0.9;
}
90% {
transform: translateX(192px) translateY(0);
opacity: 0.9;
}
96% {
transform: translateX(192px) translateY(0);
opacity: 0;
}
100% {
transform: translateX(0) translateY(0);
opacity: 0;
}
}
@media (max-width: 340px) {
.kb__label {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.kb__ghost.is-demo {
animation: none !important;
opacity: 0;
}
.kb__card {
transition: none;
}
}// 실제 동작: 화살표를 누르면 그 카드가 실제로 다음 칸(DOM)으로 옮겨간다. 마지막 칸에서는 화살표 대신 체크로 바뀐다.
var cols = document.querySelectorAll('.kb__col');
function refreshCounts() {
cols.forEach(function (c) {
c.querySelector('.kb__count').textContent = String(c.querySelectorAll('.kb__card').length);
});
}
function wire(card) {
var btn = card.querySelector('.kb__move');
if (!btn) return;
btn.addEventListener('click', function () {
document.querySelector('.kb__ghost').classList.remove('is-demo');
var col = card.closest('.kb__col');
var next = cols[Array.prototype.indexOf.call(cols, col) + 1];
if (!next) return;
card.classList.add('kb__card--moving');
setTimeout(function () {
card.classList.remove('kb__card--moving');
if (next.classList.contains('kb__col--done')) {
card.classList.add('kb__card--done');
btn.remove();
var ic = document.createElement('span');
ic.className = 'kb__ic';
ic.innerHTML = '<svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>';
card.prepend(ic);
}
next.querySelector('.kb__cards').appendChild(card);
refreshCounts();
}, 160);
});
}
document.querySelectorAll('.kb__card').forEach(wire);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./KanbanBoard.module.scss";
export type KanbanBoardProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Card = { id: string; label: string; dot: "warn" | "info" | "none" };
const INITIAL: Card[][] = [
[{ id: "c1", label: "Write landing copy", dot: "warn" }],
[{ id: "c2", label: "Organize icon set", dot: "info" }],
[{ id: "c3", label: "Clean up copy.json", dot: "none" }],
];
const NAMES = ["To do", "In progress", "Done"];
export default function KanbanBoard({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6" }: KanbanBoardProps) {
const [cols, setCols] = useState<Card[][]>(INITIAL);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function move(colIndex: number, cardId: string) {
if (colIndex >= cols.length - 1) return;
setCols((prev) => {
const next = prev.map((c) => c.slice());
const idx = next[colIndex].findIndex((c) => c.id === cardId);
const [card] = next[colIndex].splice(idx, 1);
next[colIndex + 1].push(card);
return next;
});
}
return (
<div className={styles.kb} style={vars}>
{cols.map((col, i) => (
<div key={NAMES[i]} className={styles.col}>
<p className={styles.head}>{NAMES[i]} <span className={styles.count}>{col.length}</span></p>
<div className={styles.cards}>
{col.map((card) => (
<div key={card.id} className={styles.card}>
{i === cols.length - 1 ? (
<span className={styles.ic}>✓</span>
) : (
<span className={`${styles.dot} ${styles[card.dot]}`} />
)}
<span className={styles.label}>{card.label}</span>
{i < cols.length - 1 && (
<button type="button" className={styles.move} aria-label="Move to next column" onClick={() => move(i, card.id)}>→</button>
)}
</div>
))}
</div>
</div>
))}
</div>
);
}04 · ミニ カンバンボード ウィジェット — 計測 累積面積 4.341% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
ドラッグライブラリなしで進捗3列だけを見せたいときの部品です。
@keyframes kb-move {
0% { transform: translateX(0) translateY(0); opacity: .9; }
45% { transform: translateX(96px) translateY(-3px); opacity: .9; }
90% { transform: translateX(192px) translateY(0); opacity: .9; }
96% { transform: translateX(192px) translateY(0); opacity: 0; }
100% { transform: translateX(0) translateY(0); opacity: 0; }
}
05目標進捗ステップカード
画面で起きること
「次のステップ」ボタンを押すとバーが実際にその分だけ埋まり、通過したステップにチェックアイコンが付きます。
<main class="stage">
<div class="gt is-demo">
<div class="gt__top">
<span class="gt__ic">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M176 128a48 48 0 1 1-48-48a48 48 0 0 1 48 48" opacity=".2"/><path d="M221.87 83.16A104.1 104.1 0 1 1 195.67 49l22.67-22.68a8 8 0 0 1 11.32 11.32l-96 96a8 8 0 0 1-11.32-11.32l27.72-27.72a40 40 0 1 0 17.87 31.09a8 8 0 1 1 16-.9a56 56 0 1 1-22.38-41.65l22.75-22.75a87.88 87.88 0 1 0 23.13 29.67a8 8 0 0 1 14.44-6.9"/></g></svg>
</span>
<strong class="gt__title">購読目標</strong>
<span class="gt__pct">1 / 4</span>
</div>
<div class="gt__track"><div class="gt__fill"></div></div>
<ol class="gt__steps">
<li class="gt__step" data-p="25">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span></span>
<span class="gt__label">登録</span>
</li>
<li class="gt__step" data-p="50">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span></span>
<span class="gt__label">初回決済</span>
</li>
<li class="gt__step" data-p="75">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span></span>
<span class="gt__label">連携</span>
</li>
<li class="gt__step" data-p="100">
<span class="gt__dot"><span class="gt__check"><svg viewBox="0 0 256 256" width="9" height="9"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg></span></span>
<span class="gt__label">完了</span>
</li>
</ol>
<button class="gt__next" type="button">次のステップ</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-mint;
@include stage($stage-paper);
.gt {
box-sizing: border-box;
width: min(240px, 86vw);
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
padding: $sp-4;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.gt__top { display: flex; align-items: center; gap: $sp-1; margin: 0 0 $sp-3; }
.gt__ic { flex: 0 0 auto; color: $color; }
.gt__title { flex: 1 1 auto; font-size: 12px; }
.gt__pct { font-size: 11px; font-weight: 800; color: $subject-ink; }
.gt__track {
height: 6px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
margin: 0 0 $sp-4;
}
.gt__fill {
width: 100%; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 진행바 왼쪽 채움
transform: scaleX(0);
transition: transform $duration $easing;
}
.gt__steps { list-style: none; display: flex; justify-content: space-between; margin: 0 0 $sp-4; padding: 0; }
.gt__step { display: flex; flex-direction: column; align-items: center; gap: $sp-1; flex: 1; }
.gt__dot {
position: relative;
width: 18px; height: 18px;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px rgba(23, 20, 26, .18);
display: grid; place-items: center;
}
.gt__check {
position: absolute; inset: 0;
border-radius: 50%;
background: $color;
color: #fff;
display: grid; place-items: center;
opacity: 0;
transform: scale(.6);
transition: opacity $duration $easing, transform $duration $easing;
}
.gt__step.is-active .gt__check { opacity: 1; transform: scale(1); }
.gt__label { font-size: 9px; color: rgba(23, 20, 26, .72); text-align: center; }
.gt__step.is-active .gt__label { color: $stage-ink; font-weight: 700; }
.gt__next {
width: 100%;
border: 0;
background: $color;
color: $subject-ink;
font: inherit; font-weight: 700; font-size: 12px;
padding: $sp-2 $sp-2;
border-radius: $r-control;
cursor: pointer;
}
// ── 자동 루프: 막대가 0→100 을 반복하고, 단계 체크가 순서대로 켜진다 ──────────────
.gt.is-demo .gt__fill { animation: gt-fill 3s $easing infinite; transform-origin: left center; }
@keyframes gt-fill {
0% { transform: scaleX(0); }
85% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
.gt.is-demo .gt__step .gt__check {
animation: gt-check 3s $easing infinite;
}
.gt.is-demo .gt__step:nth-child(1) .gt__check { animation-delay: 0s; }
.gt.is-demo .gt__step:nth-child(2) .gt__check { animation-delay: .55s; }
.gt.is-demo .gt__step:nth-child(3) .gt__check { animation-delay: 1.1s; }
.gt.is-demo .gt__step:nth-child(4) .gt__check { animation-delay: 1.65s; }
@keyframes gt-check {
0%, 15% { opacity: 0; transform: scale(.6); }
30% { opacity: 1; transform: scale(1); }
100% { opacity: 1; transform: scale(1); }
}
@media (max-width: 340px) {
.gt { padding: $sp-3; }
.gt__label { font-size: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__fill { animation: none !important; }
.gt.is-demo .gt__step .gt__check { animation: none !important; }
.gt__fill, .gt__check { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.gt {
box-sizing: border-box;
width: min(240px, 86vw);
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 16px;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.gt__top {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 12px;
}
.gt__ic {
flex: 0 0 auto;
color: #4cd4a6;
}
.gt__title {
flex: 1 1 auto;
font-size: 12px;
}
.gt__pct {
font-size: 11px;
font-weight: 800;
color: #17141a;
}
.gt__track {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
margin: 0 0 16px;
}
.gt__fill {
width: 100%;
height: 100%;
background: #4cd4a6;
transform-origin: left center;
transform: scaleX(0);
transition: transform 300ms ease-out;
}
.gt__steps {
list-style: none;
display: flex;
justify-content: space-between;
margin: 0 0 16px;
padding: 0;
}
.gt__step {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 1;
}
.gt__dot {
position: relative;
width: 18px;
height: 18px;
border-radius: 50%;
box-shadow: inset 0 0 0 1.5px rgba(23, 20, 26, 0.18);
display: grid;
place-items: center;
}
.gt__check {
position: absolute;
inset: 0;
border-radius: 50%;
background: #4cd4a6;
color: #fff;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.gt__step.is-active .gt__check {
opacity: 1;
transform: scale(1);
}
.gt__label {
font-size: 9px;
color: rgba(23, 20, 26, 0.72);
text-align: center;
}
.gt__step.is-active .gt__label {
color: #17141a;
font-weight: 700;
}
.gt__next {
width: 100%;
border: 0;
background: #4cd4a6;
color: #17141a;
font: inherit;
font-weight: 700;
font-size: 12px;
padding: 8px 8px;
border-radius: 12px;
cursor: pointer;
}
.gt.is-demo .gt__fill {
animation: gt-fill 3s ease-out infinite;
transform-origin: left center;
}
@keyframes gt-fill {
0% {
transform: scaleX(0);
}
85% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
.gt.is-demo .gt__step .gt__check {
animation: gt-check 3s ease-out infinite;
}
.gt.is-demo .gt__step:nth-child(1) .gt__check {
animation-delay: 0s;
}
.gt.is-demo .gt__step:nth-child(2) .gt__check {
animation-delay: 0.55s;
}
.gt.is-demo .gt__step:nth-child(3) .gt__check {
animation-delay: 1.1s;
}
.gt.is-demo .gt__step:nth-child(4) .gt__check {
animation-delay: 1.65s;
}
@keyframes gt-check {
0%, 15% {
opacity: 0;
transform: scale(0.6);
}
30% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.gt {
padding: 12px;
}
.gt__label {
font-size: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.gt.is-demo .gt__fill {
animation: none !important;
}
.gt.is-demo .gt__step .gt__check {
animation: none !important;
}
.gt__fill, .gt__check {
transition: none;
}
}// 실제 동작: '次のステップ'를 누르면 막대가 실제로 그 단계 값까지 채워지고 지나온 단계에 체크가 남는다.
var steps = document.querySelectorAll('.gt__step');
var fill = document.querySelector('.gt__fill');
var pct = document.querySelector('.gt__pct');
var btn = document.querySelector('.gt__next');
var idx = 0;
function apply() {
steps.forEach(function (s, i) { s.classList.toggle('is-active', i <= idx); });
fill.style.transform = 'scaleX(' + (Number(steps[idx].dataset.p) / 100) + ')';
pct.textContent = (idx + 1) + ' / ' + steps.length;
btn.hidden = idx === steps.length - 1;
}
btn.addEventListener('click', function () {
document.querySelector('.gt').classList.remove('is-demo');
idx = Math.min(idx + 1, steps.length - 1);
apply();
});
apply();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GoalTracker.module.scss";
export type GoalTrackerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const STEPS = [
{ label: "Sign up", p: 25 },
{ label: "First payment", p: 50 },
{ label: "Integration", p: 75 },
{ label: "Done", p: 100 },
];
export default function GoalTracker({ duration = 300, easing = "ease-out", color = "#4cd4a6" }: GoalTrackerProps) {
const [idx, setIdx] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.gt} style={vars}>
<div className={styles.top}>
<span className={styles.ic}>◎</span>
<strong className={styles.title}>Subscription goal</strong>
<span className={styles.pct}>{idx + 1} / {STEPS.length}</span>
</div>
<div className={styles.track}>
<div className={styles.fill} style={{ transform: `scaleX(${STEPS[idx].p / 100})` }} />
</div>
<ol className={styles.steps}>
{STEPS.map((s, i) => (
<li key={s.label} className={styles.step}>
<span className={styles.dot}>
<span className={`${styles.check} ${i <= idx ? styles.active : ""}`}>✓</span>
</span>
<span className={`${styles.label} ${i <= idx ? styles.activeLabel : ""}`}>{s.label}</span>
</li>
))}
</ol>
{idx < STEPS.length - 1 && (
<button type="button" className={styles.next} onClick={() => setIdx((i) => Math.min(i + 1, STEPS.length - 1))}>
Next step
</button>
)}
</div>
);
}05 · 目標進捗ステップカード — 計測 累積面積 1.372% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
最後のステップに達するとボタンは消えます。
function apply() {
steps.forEach(function (s, i) { s.classList.toggle('is-active', i <= idx); });
fill.style.transform = 'scaleX(' + (Number(steps[idx].dataset.p) / 100) + ')';
pct.textContent = (idx + 1) + ' / ' + steps.length;
btn.hidden = idx === steps.length - 1;
}
btn.addEventListener('click', function () {
document.querySelector('.gt').classList.remove('is-demo');
idx = Math.min(idx + 1, steps.length - 1);
apply();
});
06チームメンバーリスト(検索)
どんな動きか
名前を入力するとその文字を含む行だけが実際に残り、残りは実際に隠れます。

<main class="stage">
<div class="tl">
<div class="tl__head">
<input class="tl__search" type="text" placeholder="名前で検索" aria-label="メンバー検索">
<button class="tl__invite" type="button" aria-label="招待">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
<ul class="tl__list">
<li class="tl__row" data-name="キム・ドユン">
<span class="tl__avatar">キ<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">キム・ドユン</span>
</li>
<li class="tl__row" data-name="イ・ソジュン">
<span class="tl__avatar">イ<span class="tl__status tl__status--away"></span></span>
<span class="tl__name">イ・ソジュン</span>
</li>
<li class="tl__row" data-name="パク・ハウン">
<span class="tl__avatar">パ<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">パク・ハウン</span>
</li>
<li class="tl__row tl__row--new is-demo" data-name="チェ・ユジン">
<span class="tl__avatar">チ<span class="tl__status tl__status--on"></span></span>
<span class="tl__name">チェ・ユジン</span>
</li>
</ul>
<p class="tl__empty" hidden>一致するメンバーがいません</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-pop;
$color: $subject-sky;
@include stage($stage-yellow);
.tl {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.tl__head { display: flex; align-items: center; gap: $sp-2; margin: 0 0 $sp-3; }
.tl__search {
flex: 1 1 auto; min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(23, 20, 26, .18);
background: #fff;
border-radius: $r-control;
padding: $sp-1 $sp-2;
font: inherit; font-size: 11px; color: $stage-ink;
}
.tl__search::placeholder { color: rgba(23, 20, 26, .4); }
.tl__invite {
flex: 0 0 auto;
width: 26px; height: 26px; padding: 0;
border: 0; background: $stage-ink; color: $stage-yellow;
border-radius: 50%;
display: grid; place-items: center; cursor: pointer;
}
.tl__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.tl__row { display: flex; align-items: center; gap: $sp-2; }
.tl__avatar {
position: relative;
flex: 0 0 auto;
width: 24px; height: 24px;
border-radius: 50%;
background: #fff;
display: grid; place-items: center;
font-size: 10px; font-weight: 800;
}
.tl__status {
position: absolute; right: -1px; bottom: -1px;
width: 8px; height: 8px;
border-radius: 50%;
border: 2px solid $stage-yellow;
}
.tl__status--on { background: $subject-mint; }
.tl__status--away { background: #d0bd9f; }
.tl__status--on::after {
content: "";
position: absolute; inset: -2px;
border-radius: 50%;
border: 1px solid $subject-mint;
opacity: .8;
animation: tl-ping 2.4s $easing infinite;
}
.tl__name { font-size: 12px; font-weight: 600; }
.tl__empty { font-size: 11px; color: rgba(23, 20, 26, .5); margin: $sp-2 0 0; }
@keyframes tl-ping {
0% { transform: scale(1); opacity: .8; }
100% { transform: scale(1.9); opacity: 0; }
}
.tl__row--new {
opacity: 0;
transform: translateY(8px);
transition: opacity $duration $easing, transform $duration $easing;
}
// ── 자동 루프: 새 멤버 행이 계속 나타났다 사라진다 ──────────────
.tl__row--new.is-demo { animation: tl-join 2.6s $easing infinite; }
@keyframes tl-join {
0% { opacity: 0; transform: translateY(8px); }
30% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; transform: translateY(0); }
100% { opacity: 0; transform: translateY(8px); }
}
@media (max-width: 340px) {
.tl__name { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.tl__status--on::after { animation: none !important; opacity: 0; }
.tl__row--new.is-demo { animation: none !important; opacity: 1; transform: translateY(0); }
.tl__row--new { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tl {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.tl__head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 12px;
}
.tl__search {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(23, 20, 26, 0.18);
background: #fff;
border-radius: 12px;
padding: 4px 8px;
font: inherit;
font-size: 11px;
color: #17141a;
}
.tl__search::placeholder {
color: rgba(23, 20, 26, 0.4);
}
.tl__invite {
flex: 0 0 auto;
width: 26px;
height: 26px;
padding: 0;
border: 0;
background: #17141a;
color: #ffd23f;
border-radius: 50%;
display: grid;
place-items: center;
cursor: pointer;
}
.tl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.tl__row {
display: flex;
align-items: center;
gap: 8px;
}
.tl__avatar {
position: relative;
flex: 0 0 auto;
width: 24px;
height: 24px;
border-radius: 50%;
background: #fff;
display: grid;
place-items: center;
font-size: 10px;
font-weight: 800;
}
.tl__status {
position: absolute;
right: -1px;
bottom: -1px;
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid #ffd23f;
}
.tl__status--on {
background: #4cd4a6;
}
.tl__status--away {
background: #d0bd9f;
}
.tl__status--on::after {
content: "";
position: absolute;
inset: -2px;
border-radius: 50%;
border: 1px solid #4cd4a6;
opacity: 0.8;
animation: tl-ping 2.4s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.tl__name {
font-size: 12px;
font-weight: 600;
}
.tl__empty {
font-size: 11px;
color: rgba(23, 20, 26, 0.5);
margin: 8px 0 0;
}
@keyframes tl-ping {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(1.9);
opacity: 0;
}
}
.tl__row--new {
opacity: 0;
transform: translateY(8px);
transition: opacity 600ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 600ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tl__row--new.is-demo {
animation: tl-join 2.6s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes tl-join {
0% {
opacity: 0;
transform: translateY(8px);
}
30% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(8px);
}
}
@media (max-width: 340px) {
.tl__name {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.tl__status--on::after {
animation: none !important;
opacity: 0;
}
.tl__row--new.is-demo {
animation: none !important;
opacity: 1;
transform: translateY(0);
}
.tl__row--new {
transition: none;
}
}// 실제 동작: 이름을 입력하면 일치하는 멤버만 실제로 남고 나머지는 실제로 숨겨진다.
var input = document.querySelector('.tl__search');
var rows = document.querySelectorAll('.tl__row');
var empty = document.querySelector('.tl__empty');
input.addEventListener('input', function () {
var q = input.value.trim().toLowerCase();
var shown = 0;
rows.forEach(function (r) {
var hit = r.dataset.name.toLowerCase().indexOf(q) !== -1;
r.hidden = !hit;
if (hit) shown++;
});
empty.hidden = shown !== 0;
});import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TeamList.module.scss";
export type TeamListProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const MEMBERS = [
{ name: "Kim Doyoon", initial: "K", status: "on" as const },
{ name: "Lee Seojun", initial: "L", status: "away" as const },
{ name: "Park Haeun", initial: "P", status: "on" as const },
];
export default function TeamList({ duration = 600, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#6ec8ff" }: TeamListProps) {
const [query, setQuery] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const filtered = useMemo(
() => MEMBERS.filter((m) => m.name.toLowerCase().includes(query.trim().toLowerCase())),
[query]
);
return (
<div className={styles.tl} style={vars}>
<div className={styles.head}>
<input
className={styles.search}
type="text"
placeholder="Search by name"
aria-label="Member search"
value={query}
onChange={(e: any) => setQuery(e.target.value)}
/>
<button type="button" className={styles.invite} aria-label="Invite">+</button>
</div>
<ul className={styles.list}>
{filtered.map((m) => (
<li key={m.name} className={styles.row}>
<span className={styles.avatar}>
{m.initial}
<span className={`${styles.status} ${styles[m.status]}`} />
</span>
<span className={styles.name}>{m.name}</span>
</li>
))}
</ul>
{filtered.length === 0 && <p className={styles.empty}>No matching members</p>}
</div>
);
}06 · チームメンバーリスト(検索) — 計測 累積面積 1.09% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
オンライン状態の点はずっと薄い輪となって広がっては消え続けます。
input.addEventListener('input', function () {
var q = input.value.trim().toLowerCase();
var shown = 0;
rows.forEach(function (r) {
var hit = r.dataset.name.toLowerCase().indexOf(q) !== -1;
r.hidden = !hit;
if (hit) shown++;
});
empty.hidden = shown !== 0;
});
07コマンドパレット(⌘K)
何が見えるか
ボタンを押すか Ctrl(⌘)+K を押すとパネルが実際に画面中央で拡大しながら開き、入力した文字を含むコマンドだけが実際に残ります。

<main class="stage">
<div class="cp">
<button class="cp__trigger" type="button">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M116 40a76 76 0 1 0 76 76a76 76 0 0 0-76-76" opacity=".2"/><path d="m228.24 219.76l-51.38-51.38a92.12 92.12 0 1 0-8.48 8.48l51.38 51.38a6 6 0 0 0 8.48-8.48M36 116a80 80 0 1 1 80 80a80.09 80.09 0 0 1-80-80"/></g></svg>
クイックジャンプ <kbd class="cp__kbd">Ctrl K</kbd>
</button>
<div class="cp__overlay is-demo">
<div class="cp__backdrop"></div>
<div class="cp__panel" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<div class="cp__search-row">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M116 40a76 76 0 1 0 76 76a76 76 0 0 0-76-76" opacity=".2"/><path d="m228.24 219.76l-51.38-51.38a92.12 92.12 0 1 0-8.48 8.48l51.38 51.38a6 6 0 0 0 8.48-8.48M36 116a80 80 0 1 1 80 80a80.09 80.09 0 0 1-80-80"/></g></svg>
<input class="cp__input" type="text" placeholder="コマンドを検索…" aria-label="コマンド検索">
</div>
<ul class="cp__list" role="listbox">
<li class="cp__item is-active" role="option" tabindex="-1" data-kw="イベント 予定 カレンダー 追加">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H48V96h160Z"/></g></svg></span>
新しいイベントを追加
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="タグ ラベル 管理">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M243.31 136L144 36.69A15.86 15.86 0 0 0 132.69 32H40a8 8 0 0 0-8 8v92.69A15.86 15.86 0 0 0 36.69 144L136 243.31a16 16 0 0 0 22.63 0l84.68-84.68a16 16 0 0 0 0-22.63M96 84a12 12 0 1 1 12-12a12 12 0 0 1-12 12"/></g></svg></span>
タグ管理
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="プロフィール アカウント 設定">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a87.7 87.7 0 0 1-56.61-20.68a72 72 0 0 1 113.22 0A87.7 87.7 0 0 1 128 216"/></g></svg></span>
プロフィール設定
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="エクスポート ダウンロード csv">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg></span>
ファイルをエクスポート
</li>
</ul>
<p class="cp__empty" hidden>一致するコマンドがありません</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-ink);
.cp { font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.cp__trigger {
display: inline-flex; align-items: center; gap: $sp-2;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, .25);
background: rgba(255, 247, 230, .06);
color: $color;
padding: $sp-2 $sp-3;
border-radius: $r-control;
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
}
.cp__kbd {
margin-left: $sp-1;
font: inherit; font-size: 9px; font-weight: 700;
line-height: 1.6;
padding: 0 $sp-1;
border-radius: $r-xs;
background: rgba(255, 247, 230, .12);
}
.cp__overlay {
position: fixed; inset: 0;
display: grid; place-items: center;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.cp__overlay.is-open { opacity: 1; pointer-events: auto; }
.cp__backdrop {
position: absolute; inset: 0;
background: rgba(0, 0, 0, .55);
}
.cp__panel {
position: relative;
box-sizing: border-box;
width: min(230px, 82vw);
background: #232028;
border-radius: $r-card;
box-shadow: $shadow-raised;
padding: $sp-2;
transform: scale(.92);
transform-origin: center;
transition: transform $duration $easing;
}
.cp__overlay.is-open .cp__panel { transform: scale(1); }
.cp__search-row {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-2;
border-bottom: 1px solid rgba(255, 247, 230, .12);
color: rgba(255, 247, 230, .5);
margin: 0 0 $sp-1;
}
.cp__input {
flex: 1 1 auto; min-width: 0;
border: 0; background: transparent; outline: none;
color: $color;
font: inherit; font-size: 12px;
padding: $sp-1 0;
}
.cp__input::placeholder { color: rgba(255, 247, 230, .4); }
.cp__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; max-height: 132px; overflow: hidden; }
.cp__item {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-2;
border-radius: $r-xs;
color: rgba(255, 247, 230, .75);
font-size: 12px;
cursor: pointer;
}
.cp__ic { flex: 0 0 auto; color: rgba(255, 247, 230, .5); }
.cp__item.is-active { background: rgba(255, 247, 230, .1); color: $color; }
.cp__item.is-active .cp__ic { color: $color; }
.cp__item[hidden] { display: none; }
.cp__empty { font-size: 11px; color: rgba(255, 247, 230, .5); padding: $sp-2; margin: 0; }
// ── 자동 루프: 팔레트가 열렸다 닫혔다를 반복한다 (실제 상태와 별개) ──────────────
.cp__overlay.is-demo { animation: cp-open 2s $easing infinite; }
.cp__overlay.is-demo .cp__panel { animation: cp-panel 2s $easing infinite; }
@keyframes cp-open {
0%, 100% { opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
}
@keyframes cp-panel {
0%, 100% { transform: scale(.92); }
20% { transform: scale(1); }
80% { transform: scale(1); }
}
@media (max-width: 340px) {
.cp__trigger { font-size: 11px; padding: $sp-1 $sp-2; }
.cp__panel { width: min(210px, 80vw); }
}
@media (prefers-reduced-motion: reduce) {
.cp__overlay.is-demo, .cp__overlay.is-demo .cp__panel { animation: none !important; }
.cp__overlay, .cp__panel { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cp {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__trigger {
display: inline-flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, 0.25);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
padding: 8px 12px;
border-radius: 12px;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cp__kbd {
margin-left: 4px;
font: inherit;
font-size: 9px;
font-weight: 700;
line-height: 1.6;
padding: 0 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.12);
}
.cp__overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cp__overlay.is-open {
opacity: 1;
pointer-events: auto;
}
.cp__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.cp__panel {
position: relative;
box-sizing: border-box;
width: min(230px, 82vw);
background: #232028;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px;
transform: scale(0.92);
transform-origin: center;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cp__overlay.is-open .cp__panel {
transform: scale(1);
}
.cp__search-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
color: rgba(255, 247, 230, 0.5);
margin: 0 0 4px;
}
.cp__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
outline: none;
color: #fff7e6;
font: inherit;
font-size: 12px;
padding: 4px 0;
}
.cp__input::placeholder {
color: rgba(255, 247, 230, 0.4);
}
.cp__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
max-height: 132px;
overflow: hidden;
}
.cp__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
color: rgba(255, 247, 230, 0.75);
font-size: 12px;
cursor: pointer;
}
.cp__ic {
flex: 0 0 auto;
color: rgba(255, 247, 230, 0.5);
}
.cp__item.is-active {
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
}
.cp__item.is-active .cp__ic {
color: #fff7e6;
}
.cp__item[hidden] {
display: none;
}
.cp__empty {
font-size: 11px;
color: rgba(255, 247, 230, 0.5);
padding: 8px;
margin: 0;
}
.cp__overlay.is-demo {
animation: cp-open 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.cp__overlay.is-demo .cp__panel {
animation: cp-panel 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes cp-open {
0%, 100% {
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
}
@keyframes cp-panel {
0%, 100% {
transform: scale(0.92);
}
20% {
transform: scale(1);
}
80% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.cp__trigger {
font-size: 11px;
padding: 4px 8px;
}
.cp__panel {
width: min(210px, 80vw);
}
}
@media (prefers-reduced-motion: reduce) {
.cp__overlay.is-demo, .cp__overlay.is-demo .cp__panel {
animation: none !important;
}
.cp__overlay, .cp__panel {
transition: none;
}
}// 실제 동작: 버튼이나 Ctrl(⌘)+K로 열리고, 입력하면 실제로 그 글자가 든 명령만 남으며, 방향키·Enter로 고른다.
var trigger = document.querySelector('.cp__trigger');
var overlay = document.querySelector('.cp__overlay');
var input = document.querySelector('.cp__input');
var items = document.querySelectorAll('.cp__item');
var empty = document.querySelector('.cp__empty');
var activeIndex = 0;
function visibleItems() { return Array.prototype.filter.call(items, function (it) { return !it.hidden; }); }
function setActive(i, vis) {
vis = vis || visibleItems();
items.forEach(function (it) { it.classList.remove('is-active'); });
if (vis[i]) vis[i].classList.add('is-active');
activeIndex = i;
}
function filter() {
var q = input.value.trim().toLowerCase();
items.forEach(function (it) { it.hidden = q !== '' && it.dataset.kw.toLowerCase().indexOf(q) === -1; });
var vis = visibleItems();
empty.hidden = vis.length !== 0;
setActive(0, vis);
}
function open() {
overlay.classList.remove('is-demo');
overlay.classList.add('is-open');
input.value = '';
filter();
input.focus({ preventScroll: true });
}
function close() {
overlay.classList.remove('is-open');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); open(); }
else if (e.key === 'Escape' && overlay.classList.contains('is-open')) close();
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay || e.target.classList.contains('cp__backdrop')) close();
});
input.addEventListener('input', filter);
input.addEventListener('keydown', function (e) {
var vis = visibleItems();
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(Math.min(activeIndex + 1, vis.length - 1), vis); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(Math.max(activeIndex - 1, 0), vis); }
else if (e.key === 'Enter') { var cur = vis[activeIndex]; if (cur) close(); }
});
items.forEach(function (it) { it.addEventListener('click', close); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CommandPalette.module.scss";
export type CommandPaletteProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const COMMANDS = [
{ id: "event", label: "Add new event", kw: "event calendar schedule" },
{ id: "tags", label: "Manage tags", kw: "tag label" },
{ id: "profile", label: "Profile settings", kw: "profile account settings" },
{ id: "export", label: "Export file", kw: "export download csv" },
];
export default function CommandPalette({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6" }: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const visible = COMMANDS.filter((c) => !query.trim() || c.kw.includes(query.trim().toLowerCase()));
function openPalette() {
setOpen(true); setQuery(""); setActive(0);
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}
function close() {
setOpen(false);
triggerRef.current?.focus({ preventScroll: true });
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openPalette(); }
else if (e.key === "Escape" && open) close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
});
return (
<div className={styles.cp} style={vars}>
<button ref={triggerRef} type="button" className={styles.trigger} onClick={openPalette}>
🔍 Quick jump <kbd className={styles.kbd}>Ctrl K</kbd>
</button>
{open && (
<div className={styles.overlay} onClick={(e: any) => { if (e.target === e.currentTarget) close(); }}>
<div className={styles.panel} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.searchRow}>
🔍
<input
ref={inputRef}
className={styles.input}
type="text"
placeholder="Search commands…"
aria-label="Command search"
value={query}
onChange={(e: any) => { setQuery(e.target.value); setActive(0); }}
onKeyDown={(e: any) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => Math.min(i + 1, visible.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter" && visible[active]) close();
}}
/>
</div>
<ul className={styles.list} role="listbox">
{visible.map((c, i) => (
<li key={c.id} className={`${styles.item} ${i === active ? styles.active : ""}`} role="option" onClick={close}>
{c.label}
</li>
))}
</ul>
{visible.length === 0 && <p className={styles.empty}>No matching commands</p>}
</div>
</div>
)}
</div>
);
}07 · コマンドパレット(⌘K) — 計測 累積面積 95.078% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
方向キーと Enter で選べます。
function filter() {
var q = input.value.trim().toLowerCase();
items.forEach(function (it) { it.hidden = q !== '' && it.dataset.kw.toLowerCase().indexOf(q) === -1; });
var vis = visibleItems();
empty.hidden = vis.length !== 0;
setActive(0, vis);
}
08タグ管理入力ウィジェット
画面で起きること
入力して追加を押すと(または Enter で)新しいタグチップが実際にリストの末尾に滑り込み、チップの×を押すとそのタグだけが実際に消えます。

<main class="stage">
<div class="tg">
<div class="tg__row">
<input class="tg__input" type="text" placeholder="タグを入力してEnter" aria-label="新しいタグ">
<button class="tg__add" type="button" aria-label="タグを追加">
<svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8"/></g></svg>
</button>
</div>
<ul class="tg__list">
<li class="tg__chip" data-tag="デザイン">デザイン<button class="tg__remove" type="button" aria-label="デザインを削除">×</button></li>
<li class="tg__chip" data-tag="緊急">緊急<button class="tg__remove" type="button" aria-label="緊急を削除">×</button></li>
<li class="tg__chip tg__chip--demo is-demo" aria-hidden="true">レビュー必要<button class="tg__remove" type="button" tabindex="-1" aria-hidden="true">×</button></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
.tg {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tg__row { display: flex; gap: $sp-2; margin: 0 0 $sp-3; }
.tg__input {
flex: 1 1 auto; min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, .35);
background: rgba(255, 247, 230, .1);
color: $subject-ink;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font: inherit; font-size: 12px;
}
.tg__input::placeholder { color: rgba(23, 20, 26, .72); }
.tg__add {
flex: 0 0 auto;
width: 32px; height: 32px; padding: 0;
border: 0; background: $subject-cream; color: $subject-ink;
border-radius: $r-control;
display: grid; place-items: center; cursor: pointer;
}
.tg__list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: $sp-2; }
.tg__chip {
box-sizing: border-box;
display: inline-flex; align-items: center; gap: $sp-1;
background: rgba(255, 247, 230, .16);
border: 1px solid rgba(255, 247, 230, .3);
color: $subject-ink;
padding: $sp-1 $sp-1 $sp-1 $sp-3;
border-radius: $r-pill;
font-size: 11px; font-weight: 600;
opacity: 1; transform: scale(1);
transition: opacity $duration $easing, transform $duration $easing;
}
.tg__chip--out { opacity: 0; transform: scale(.7); }
.tg__remove {
width: 18px; height: 18px; padding: 0;
border: 0; background: transparent; color: inherit;
border-radius: 50%;
font-size: 13px; line-height: 1; cursor: pointer;
}
.tg__remove:hover { background: rgba(255, 247, 230, .2); }
.tg__chip--demo { pointer-events: none; opacity: 0; transform: translateX(10px) scale(.9); }
// ── 자동 루프: 새 태그 칩이 계속 밀려 들어왔다 사라진다 ──────────────
.tg__chip--demo.is-demo { animation: tg-in 2.4s $easing infinite; }
@keyframes tg-in {
0% { opacity: 0; transform: translateX(10px) scale(.9); }
35% { opacity: 1; transform: translateX(0) scale(1); }
80% { opacity: 1; transform: translateX(0) scale(1); }
100% { opacity: 0; transform: translateX(10px) scale(.9); }
}
@media (max-width: 340px) {
.tg__chip { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.tg__chip--demo.is-demo { animation: none !important; opacity: 0; }
.tg__chip { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tg {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.tg__row {
display: flex;
gap: 8px;
margin: 0 0 12px;
}
.tg__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, 0.35);
background: rgba(255, 247, 230, 0.1);
color: #17141a;
border-radius: 12px;
padding: 8px 12px;
font: inherit;
font-size: 12px;
}
.tg__input::placeholder {
color: rgba(23, 20, 26, 0.72);
}
.tg__add {
flex: 0 0 auto;
width: 32px;
height: 32px;
padding: 0;
border: 0;
background: #fff7e6;
color: #17141a;
border-radius: 12px;
display: grid;
place-items: center;
cursor: pointer;
}
.tg__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tg__chip {
box-sizing: border-box;
display: inline-flex;
align-items: center;
gap: 4px;
background: rgba(255, 247, 230, 0.16);
border: 1px solid rgba(255, 247, 230, 0.3);
color: #17141a;
padding: 4px 4px 4px 12px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
opacity: 1;
transform: scale(1);
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tg__chip--out {
opacity: 0;
transform: scale(0.7);
}
.tg__remove {
width: 18px;
height: 18px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
border-radius: 50%;
font-size: 13px;
line-height: 1;
cursor: pointer;
}
.tg__remove:hover {
background: rgba(255, 247, 230, 0.2);
}
.tg__chip--demo {
pointer-events: none;
opacity: 0;
transform: translateX(10px) scale(0.9);
}
.tg__chip--demo.is-demo {
animation: tg-in 2.4s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes tg-in {
0% {
opacity: 0;
transform: translateX(10px) scale(0.9);
}
35% {
opacity: 1;
transform: translateX(0) scale(1);
}
80% {
opacity: 1;
transform: translateX(0) scale(1);
}
100% {
opacity: 0;
transform: translateX(10px) scale(0.9);
}
}
@media (max-width: 340px) {
.tg__chip {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.tg__chip--demo.is-demo {
animation: none !important;
opacity: 0;
}
.tg__chip {
transition: none;
}
}// 실제 동작: 입력하고 추가를 누르면(또는 Enter) 新しいタグ 칩이 실제로 생기고, ×를 누르면 그 태그만 실제로 사라진다.
var list = document.querySelector('.tg__list');
var input = document.querySelector('.tg__input');
var addBtn = document.querySelector('.tg__add');
function wireRemove(li) {
li.querySelector('.tg__remove').addEventListener('click', function () {
li.classList.add('tg__chip--out');
setTimeout(function () { li.remove(); }, 180);
});
}
function addTag() {
var v = input.value.trim();
if (!v) return;
var li = document.createElement('li');
li.className = 'tg__chip';
li.dataset.tag = v;
li.textContent = v;
var rm = document.createElement('button');
rm.type = 'button'; rm.className = 'tg__remove'; rm.textContent = '×';
rm.setAttribute('aria-label', v + ' 削除');
li.appendChild(rm);
wireRemove(li);
list.appendChild(li);
input.value = '';
input.focus({ preventScroll: true });
}
addBtn.addEventListener('click', addTag);
input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); addTag(); } });
document.querySelectorAll('.tg__chip:not(.tg__chip--demo)').forEach(wireRemove);import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TagManager.module.scss";
export type TagManagerProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function TagManager({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6" }: TagManagerProps) {
const [tags, setTags] = useState(["Design", "Urgent"]);
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function addTag() {
const v = value.trim();
if (!v) return;
setTags((t) => [...t, v]);
setValue("");
}
function removeTag(tag: string) {
setTags((t) => t.filter((x) => x !== tag));
}
return (
<div className={styles.tg} style={vars}>
<div className={styles.row}>
<input
className={styles.input}
type="text"
placeholder="Type a tag, press Enter"
aria-label="New tag"
value={value}
onChange={(e: any) => setValue(e.target.value)}
onKeyDown={(e: any) => { if (e.key === "Enter") { e.preventDefault(); addTag(); } }}
/>
<button type="button" className={styles.add} aria-label="Add tag" onClick={addTag}>+</button>
</div>
<ul className={styles.list}>
{tags.map((tag) => (
<li key={tag} className={styles.chip}>
{tag}
<button type="button" className={styles.remove} aria-label={`Remove ${tag}`} onClick={() => removeTag(tag)}>×</button>
</li>
))}
</ul>
</div>
);
}08 · タグ管理入力ウィジェット — 計測 累積面積 1.604% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
function addTag() {
var v = input.value.trim();
if (!v) return;
var li = document.createElement('li');
li.className = 'tg__chip';
li.dataset.tag = v;
li.textContent = v;
var rm = document.createElement('button');
rm.type = 'button'; rm.className = 'tg__remove'; rm.textContent = '×';
rm.setAttribute('aria-label', v + ' を削除');
li.appendChild(rm);
wireRemove(li);
list.appendChild(li);
input.value = '';
input.focus({ preventScroll: true });
}
09ファイルアップロード ドロップゾーン
どんな動きか
領域にファイルをドラッグするかクリックして選ぶと進捗バーが実際に0から満タンまで伸び、完了するとチェックアイコンに変わります。

<main class="stage">
<div class="fu">
<label class="fu__zone" tabindex="0">
<span class="fu__ic">
<svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M216 48v160H40V48a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg>
</span>
<span class="fu__hint">ドラッグ&ドロップ、またはクリックして選択</span>
<input class="fu__input" type="file" hidden multiple>
</label>
<ul class="fu__files">
<li class="fu__file fu__file--demo is-demo" aria-hidden="true">
<span class="fu__file-name">report.pdf</span>
<span class="fu__track"><span class="fu__fill"></span></span>
<span class="fu__check">
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0"/></g></svg>
</span>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-orange);
.fu {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-cream;
}
.fu__zone {
box-sizing: border-box;
display: flex; flex-direction: column; align-items: center; gap: $sp-2;
border: 1.5px dashed rgba(255, 247, 230, .4);
border-radius: $r-card;
padding: $sp-4 $sp-3;
cursor: pointer;
transition: border-color $duration $easing, background $duration $easing;
}
.fu__zone.is-over { border-color: $color; background: rgba(47, 109, 246, .12); }
.fu__ic { color: rgba(255, 247, 230, .7); }
.fu__hint { font-size: 11px; color: $subject-ink; text-align: center; }
.fu__files { list-style: none; margin: $sp-3 0 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.fu__file {
box-sizing: border-box;
display: flex; align-items: center; gap: $sp-2;
background: rgba(255, 247, 230, .08);
border-radius: $r-sm;
padding: $sp-1 $sp-2;
}
.fu__file-name { flex: 1 1 auto; min-width: 0; font-size: 10px; color: $subject-ink; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fu__track {
position: relative;
flex: 0 0 auto;
width: 46px; height: 4px;
border-radius: $r-pill;
background: rgba(255, 247, 230, .18);
overflow: hidden;
transition: opacity $duration $easing;
}
.fu__fill {
display: block; width: 100%; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 업로드 진행 왼쪽 채움
transform: scaleX(0);
transition: transform 900ms $easing;
}
.fu__check {
flex: 0 0 auto;
width: 16px; height: 16px;
border-radius: 50%;
background: $subject-mint;
color: $stage-ink;
display: grid; place-items: center;
opacity: 0;
transform: scale(.6);
transition: opacity $duration $easing, transform $duration $easing;
}
.fu__file--done .fu__track { opacity: 0; width: 0; }
.fu__file--done .fu__check { opacity: 1; transform: scale(1); }
.fu__remove {
flex: 0 0 auto;
width: 16px; height: 16px; padding: 0;
border: 0; background: transparent; color: rgba(255, 247, 230, .6);
font-size: 12px; line-height: 1; cursor: pointer;
}
// ── 자동 루프: 진행률이 0→100 을 반복하고 끝나면 체크로 바뀐다 ──────────────
.fu__file--demo.is-demo .fu__fill { animation: fu-fill 2.6s $easing infinite; }
.fu__file--demo.is-demo .fu__track { animation: fu-track 2.6s $easing infinite; }
.fu__file--demo.is-demo .fu__check { animation: fu-check 2.6s $easing infinite; }
@keyframes fu-fill {
0% { transform: scaleX(0); }
70% { transform: scaleX(1); }
100% { transform: scaleX(1); }
}
@keyframes fu-track {
0%, 75% { opacity: 1; }
90%, 100% { opacity: 0; }
}
@keyframes fu-check {
0%, 75% { opacity: 0; transform: scale(.6); }
90%, 100% { opacity: 1; transform: scale(1); }
}
@media (max-width: 340px) {
.fu__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.fu__file--demo.is-demo .fu__fill,
.fu__file--demo.is-demo .fu__track,
.fu__file--demo.is-demo .fu__check { animation: none !important; }
.fu__zone, .fu__fill, .fu__check, .fu__track { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fu {
box-sizing: border-box;
width: min(240px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.fu__zone {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border: 1.5px dashed rgba(255, 247, 230, 0.4);
border-radius: 16px;
padding: 16px 12px;
cursor: pointer;
transition: border-color 600ms ease-out, background 600ms ease-out;
}
.fu__zone.is-over {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.12);
}
.fu__ic {
color: rgba(255, 247, 230, 0.7);
}
.fu__hint {
font-size: 11px;
color: #17141a;
text-align: center;
}
.fu__files {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.fu__file {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 247, 230, 0.08);
border-radius: 8px;
padding: 4px 8px;
}
.fu__file-name {
flex: 1 1 auto;
min-width: 0;
font-size: 10px;
color: #17141a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fu__track {
position: relative;
flex: 0 0 auto;
width: 46px;
height: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.18);
overflow: hidden;
transition: opacity 600ms ease-out;
}
.fu__fill {
display: block;
width: 100%;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0);
transition: transform 900ms ease-out;
}
.fu__check {
flex: 0 0 auto;
width: 16px;
height: 16px;
border-radius: 50%;
background: #4cd4a6;
color: #17141a;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.6);
transition: opacity 600ms ease-out, transform 600ms ease-out;
}
.fu__file--done .fu__track {
opacity: 0;
width: 0;
}
.fu__file--done .fu__check {
opacity: 1;
transform: scale(1);
}
.fu__remove {
flex: 0 0 auto;
width: 16px;
height: 16px;
padding: 0;
border: 0;
background: transparent;
color: rgba(255, 247, 230, 0.6);
font-size: 12px;
line-height: 1;
cursor: pointer;
}
.fu__file--demo.is-demo .fu__fill {
animation: fu-fill 2.6s ease-out infinite;
}
.fu__file--demo.is-demo .fu__track {
animation: fu-track 2.6s ease-out infinite;
}
.fu__file--demo.is-demo .fu__check {
animation: fu-check 2.6s ease-out infinite;
}
@keyframes fu-fill {
0% {
transform: scaleX(0);
}
70% {
transform: scaleX(1);
}
100% {
transform: scaleX(1);
}
}
@keyframes fu-track {
0%, 75% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
@keyframes fu-check {
0%, 75% {
opacity: 0;
transform: scale(0.6);
}
90%, 100% {
opacity: 1;
transform: scale(1);
}
}
@media (max-width: 340px) {
.fu__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.fu__file--demo.is-demo .fu__fill,
.fu__file--demo.is-demo .fu__track,
.fu__file--demo.is-demo .fu__check {
animation: none !important;
}
.fu__zone, .fu__fill, .fu__check, .fu__track {
transition: none;
}
}// 실제 동작: 끌어다 놓거나 클릭으로 고른 파일이 실제로 목록에 추가되고, 진행률이 실제로 0→100 채워진 뒤 체크로 바뀐다.
var zone = document.querySelector('.fu__zone');
var input = document.querySelector('.fu__input');
var list = document.querySelector('.fu__files');
function addFile(name) {
var li = document.createElement('li');
li.className = 'fu__file';
var nameEl = document.createElement('span'); nameEl.className = 'fu__file-name'; nameEl.textContent = name;
var track = document.createElement('span'); track.className = 'fu__track';
var fill = document.createElement('span'); fill.className = 'fu__fill';
track.appendChild(fill);
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'fu__remove';
rm.setAttribute('aria-label', name + ' 削除'); rm.textContent = '×';
rm.addEventListener('click', function () { li.remove(); });
li.appendChild(nameEl); li.appendChild(track); li.appendChild(rm);
list.appendChild(li);
requestAnimationFrame(function () { fill.style.transform = 'scaleX(1)'; });
setTimeout(function () { li.classList.add('fu__file--done'); }, 900);
}
zone.addEventListener('click', function (e) { if (e.target !== input) input.click(); });
input.addEventListener('change', function () {
Array.prototype.forEach.call(input.files, function (f) { addFile(f.name); });
input.value = '';
});
['dragover', 'dragenter'].forEach(function (ev) {
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('is-over'); });
});
['dragleave', 'drop'].forEach(function (ev) {
zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('is-over'); });
});
zone.addEventListener('drop', function (e) {
Array.prototype.forEach.call(e.dataTransfer.files, function (f) { addFile(f.name); });
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FileUpload.module.scss";
export type FileUploadProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type UploadFile = { id: string; name: string; done: boolean };
export default function FileUpload({ duration = 600, easing = "ease-out", color = "#2f6df6" }: FileUploadProps) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [over, setOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function addFiles(list: FileList | null) {
if (!list) return;
Array.from(list).forEach((f) => {
const id = `${f.name}-${Date.now()}-${Math.random()}`;
setFiles((prev) => [...prev, { id, name: f.name, done: false }]);
setTimeout(() => setFiles((prev) => prev.map((x) => (x.id === id ? { ...x, done: true } : x))), 900);
});
}
function onDrop(e: any) {
e.preventDefault(); setOver(false); addFiles(e.dataTransfer.files);
}
return (
<div className={styles.fu} style={vars}>
<label
className={`${styles.zone} ${over ? styles.over : ""}`}
tabIndex={0}
onDragOver={(e: any) => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={onDrop}
>
<span className={styles.ic}>⭱</span>
<span className={styles.hint}>Drag and drop, or click to choose</span>
<input ref={inputRef} type="file" multiple hidden onChange={(e: any) => { addFiles(e.target.files); e.target.value = ""; }} />
</label>
<ul className={styles.files}>
{files.map((f) => (
<li key={f.id} className={styles.file}>
<span className={styles.name}>{f.name}</span>
<span className={`${styles.track} ${f.done ? styles.trackDone : ""}`}>
<span className={styles.fill} style={{ transform: f.done ? "scaleX(1)" : "scaleX(0)" }} />
</span>
<span className={`${styles.check} ${f.done ? styles.checkDone : ""}`}>✓</span>
<button type="button" className={styles.remove} aria-label={`Remove ${f.name}`} onClick={() => setFiles((prev) => prev.filter((x) => x.id !== f.id))}>×</button>
</li>
))}
</ul>
</div>
);
}09 · ファイルアップロード ドロップゾーン — 計測 累積面積 0.128% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
function addFile(name) {
var li = document.createElement('li');
li.className = 'fu__file';
var nameEl = document.createElement('span'); nameEl.className = 'fu__file-name'; nameEl.textContent = name;
var track = document.createElement('span'); track.className = 'fu__track';
var fill = document.createElement('span'); fill.className = 'fu__fill';
track.appendChild(fill);
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'fu__remove';
rm.setAttribute('aria-label', name + ' を削除'); rm.textContent = '×';
rm.addEventListener('click', function () { li.remove(); });
li.appendChild(nameEl); li.appendChild(track); li.appendChild(rm);
list.appendChild(li);
requestAnimationFrame(function () { fill.style.transform = 'scaleX(1)'; });
setTimeout(function () { li.classList.add('fu__file--done'); }, 900);
}
どこで壊れるか — 落とし穴
九つを作っていて実際にはまった落とし穴は、ケバブメニューのように自分の箱の外へはみ出す要素でした。480×300のデスクトップ表示では問題なかったのに、320×200のスマホ幅で測り直すとドロップダウンが画面の半分をスクロール領域に変えてしまいました。overflow: hidden で見た目には隠れていても、scrollHeight はその下にある実際のレイアウトサイズをそのまま測ってしまうということで、メニューの項目数を減らして位置を調整してからようやく通りました。カンバンボードの幽霊カードも似た落とし穴でした——三つの列を瞬間移動させると、撮影区間のほとんどでアニメーションが止まって見え、計測スクリプトに「動いていない」と判定されました。三回の飛び移りではなく2秒かけて連続的に滑らせるよう変えてから、九つ全部が実際に動いていると計測されるようになりました。翻訳辞書にも小さな落とし穴がありました——カンバン(04)と目標カード(05)がどちらも「完了」という同じ文字を使っていて、辞書はこの一語を一度だけ登録すれば両方の項目が通ることに、九つ全部を訳し終えてから後から気づきました。カレンダー(03)の今日の輪とチームリスト(06)のオンライン表示の波紋は、コードは別々でも「薄い輪が広がって消える」という同じキーフレームの形をそれぞれ実装しています。この一件のおかげで、九つのデモ全体に散らばる残り七十語ほども項目ごとではなく文字列そのもの単位で整理し直し、抜けをずっと見つけやすくなりました。活動の記録だけを縦一列に積み上げたウィジェットは通知 ui 9選に集まっています。ダウンロード用パスワード usrg4zqs で開く zip の中身にはこれらの修正がすべて反映されています。
アクセシビリティ
九つとも prefers-reduced-motion: reduce では、ケバブメニューの自動オープン、フィード項目のスライドイン、今日の日付の脈打つ輪、幽霊カードの移動、ステップチェックの順次点灯、オンライン表示の波紋、パレットの開閉、新規タグチップ、アップロード進捗といった装飾用の自動ループをすべて止めますが、実際の状態値はそのまま残します。ケバブメニュー(01)とコマンドパレット(07)はそれぞれ role="menu"・role="dialog" と aria-expanded・aria-modal を備え、外側クリックや Esc で閉じます。カレンダー(03)の日付ボタンは aria-pressed で選択状態を伝えます。チームリスト(06)の検索欄とタグ管理(08)の削除ボタンはそれぞれ aria-label で何を検索・削除するかをスクリーンリーダーに伝えます。上の落とし穴で触れた計算方法は MDN の Element.scrollHeight のページにまとまっています。
ほかのダッシュボード素材はダッシュボードカテゴリーにあり、このサイト全体が何を扱っているかは紹介ページにあります。
FAQ
完成した管理画面ではなくこのダッシュボード ウィジェット一つだけ使ってもいいですか?
はい、そのために九つに分けて作りました。各デモは管理画面全体ではなく部品一つだけを画面中央に置いており、zip の中にもその部品一つ分のHTML・SCSS・JSしか入っていないので、自分のプロジェクトのヘッダーやサイドバーの場所へそのまま移せます。同じ画面の中で打ち込んだ命令だけで動く黒い窓はターミナル風 UI 9選が別に扱います。
React版でも実際に状態は変わりますか?
はい。コマンドパレットの開閉、カレンダーの選択日、タグ一覧のように状態を持つものはすべて useState で管理し、duration・easing・color という3つの変数は props として受け取り、CSSのカスタムプロパティとして渡しています。
ファイルアップロードや進捗バーは実際に動きますか、それとも見せかけですか?
実際に動きます。ファイルアップロード(09)は選んだファイル名を実際にリストへ追加し、進捗バーを0から満タンまで実際に埋めます。目標進捗(05)のボタンも押すたびに実際に次のステップの値までバーを埋めます。自動再生GIFで見える繰り返しの動きは、誰も操作していないときだけ回る装飾用ループで、クリックした瞬間に止まります。








