検索窓 デザイン css 9選 — Cmd+Kで開くパレット
検索窓 デザイン css の中でもコマンドパレットは、画面の上に浮かんでコマンドと文書を1つの欄から探すオーバーレイです。9つのうち1つは該当なしのときにむしろ行が増え、もう1つはリストを持たず入力欄だけを先に出します。
01 cmdk-recent-actions
02 cmdk-fuzzy-highlight
03 cmdk-keyboard-scroll-follow
04 cmdk-shortcut-badge-row
05 cmdk-breadcrumb-submenu
06 cmdk-preview-pane
07 cmdk-empty-suggest
08 cmdk-spotlight-center
09 cmdk-mobile-sheet
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 開いた瞬間に出る最近の項目
- 02 飛ばし打ちでも当たる検索
- 03 カーソルを追うスクロール
- 04 右端で揃うショートカットバッジ
- 05 一段潜るパンくず
- 06 右に付くプレビューパネル
- 07 空振りで終わらない検索結果なし
- 08 画面中央の大きな入力欄
- 09 下から上がるシート型パレット
並び順は人気順ではなく、人がパレットに触れる時間の順です。コマンドパレット 使い方としては、ショートカットで開いた直後にまだ何も打っていない状態で何を見せるか(01)が先で、次が打ち始めたときの一致のさせ方(02)です。リストが箱より長くなればスクロールがカーソルを追う必要があり(03)、行の右端には次回パレットを開かずに済むようショートカットのバッジが並びます(04)。コマンド1つで終わらなければ一段潜り(05)、名前だけでは選べなければプレビューが横に付きます(06)。07 は検索が外れたとき、08 と 09 は同じパレットを別の画面幅に置き直したものです。9つすべてに role="dialog" と role="listbox" が付き、矢印キー・Enter・Escape は飾りではなく実際に効きます。同じダッシュボードで数値を輪で包んで見せる側は円形プログレスバー スタットリングにあり、グラフの手触りはダッシュボード グラフ デザイン 操作が引き継ぎます。
01開いた瞬間に出る最近の項目
検索ui デザインとして何が見えるか
ショートカットで開くと、まだ一文字も打っていないのに「最近」と「移動」の見出しの下に前回使ったコマンドが並びます。まとまりは role="group" と aria-labelledby で束ねてあり、スクリーンリーダーは見出しをその行たちの名前として読みます。

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="コマンドを検索" placeholder="コマンドを検索" autocomplete="off">
<kbd class="pal__kbd">⌘K</kbd>
</div>
<div class="pal__list" id="list" role="listbox" aria-label="コマンドパレット">
<div class="pal__grp" role="group" aria-labelledby="g1">
<p class="pal__head" id="g1">最近</p>
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M136 80v43.47l36.12 21.67a8 8 0 0 1-8.24 13.72l-40-24A8 8 0 0 1 120 128V80a8 8 0 0 1 16 0m-8-48a95.44 95.44 0 0 0-67.92 28.15C52.81 67.51 46.35 74.59 40 82V64a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16H49c7.15-8.42 14.27-16.35 22.39-24.57a80 80 0 1 1 1.66 114.75a8 8 0 1 0-11 11.64A96 96 0 1 0 128 32"/></g></svg></span>
<span class="pal__name">週次レポートの下書き</span>
</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM200 216H56V40h88v48a8 8 0 0 0 8 8h48zm-32-80a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m0 32a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg></span>
<span class="pal__name">予算シート</span>
</div>
</div>
<div class="pal__grp" role="group" aria-labelledby="g2">
<p class="pal__head" id="g2">移動</p>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg></span>
<span class="pal__name">ダッシュボード</span>
</div>
<div class="pal__opt" id="o4" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m109.94-52.79a8 8 0 0 0-3.89-5.4l-29.83-17l-.12-33.62a8 8 0 0 0-2.83-6.08a111.9 111.9 0 0 0-36.72-20.67a8 8 0 0 0-6.46.59L128 41.85L97.88 25a8 8 0 0 0-6.47-.6a111.9 111.9 0 0 0-36.68 20.75a8 8 0 0 0-2.83 6.07l-.15 33.65l-29.83 17a8 8 0 0 0-3.89 5.4a106.5 106.5 0 0 0 0 41.56a8 8 0 0 0 3.89 5.4l29.83 17l.12 33.63a8 8 0 0 0 2.83 6.08a111.9 111.9 0 0 0 36.72 20.67a8 8 0 0 0 6.46-.59L128 214.15L158.12 231a7.9 7.9 0 0 0 3.9 1a8.1 8.1 0 0 0 2.57-.42a112.1 112.1 0 0 0 36.68-20.73a8 8 0 0 0 2.83-6.07l.15-33.65l29.83-17a8 8 0 0 0 3.89-5.4a106.5 106.5 0 0 0-.03-41.52"/></g></svg></span>
<span class="pal__name">設定</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-1 $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal.is-shut { display: none; }
.pal__bar {
box-sizing: border-box;
height: 28px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.pal__kbd {
flex: 0 0 auto;
height: 14px;
display: inline-flex; align-items: center;
padding: 0 $sp-1;
border-radius: $r-xs;
background: #fff;
box-shadow: 0 1px 0 rgba(23, 20, 26, .22);
font-family: inherit;
font-size: 9px; font-weight: 800;
color: rgba(23, 20, 26, .5);
}
.pal__list { margin: $sp-1 0 0; }
.pal__grp + .pal__grp { margin-top: $sp-1; }
.pal__head {
margin: 0 0 $sp-1;
padding: 0 $sp-2;
font-size: 9px; line-height: 12px; font-weight: 800;
letter-spacing: .08em;
color: rgba(23, 20, 26, .72);
}
.pal__opt {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
cursor: pointer;
}
.pal__ic { display: inline-flex; color: rgba(23, 20, 26, .5); }
.pal__name {
overflow: hidden;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap; text-overflow: ellipsis;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .12); }
.pal__opt.is-at .pal__ic,
.pal__opt.is-at .pal__name { color: $color; }
.pal__opt.is-run { background: rgba(47, 109, 246, .22); }
// ── 자동 루프: 네 줄이 순서대로 떨어졌다가 같은 순서로 다시 올라온다 (열릴 때의 계단 등장) ──
// 0% 와 100% 가 같은 「다 보이는」 상태라 한 바퀴 끝에 튀는 자리가 없다
.pal.is-demo .pal__opt { animation: opt-cascade $dur-loop $easing infinite; }
.pal.is-demo #o2 { animation-delay: 60ms; }
.pal.is-demo #o3 { animation-delay: 120ms; }
.pal.is-demo #o4 { animation-delay: 180ms; }
@keyframes opt-cascade {
0%, 10% { opacity: 1; transform: translateY(0); }
30%, 38% { opacity: 0; transform: translateY(10px); }
64%, 100% { opacity: 1; transform: translateY(0); }
}
.pal.is-demo .pal__head { animation: head-dim $dur-loop $easing infinite; }
@keyframes head-dim {
0%, 10% { opacity: 1; }
30%, 38% { opacity: .25; }
64%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__opt,
.pal.is-demo .pal__head { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 4px 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal.is-shut {
display: none;
}
.pal__bar {
box-sizing: border-box;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__kbd {
flex: 0 0 auto;
height: 14px;
display: inline-flex;
align-items: center;
padding: 0 4px;
border-radius: 4px;
background: #fff;
box-shadow: 0 1px 0 rgba(23, 20, 26, 0.22);
font-family: inherit;
font-size: 9px;
font-weight: 800;
color: rgba(23, 20, 26, 0.5);
}
.pal__list {
margin: 4px 0 0;
}
.pal__grp + .pal__grp {
margin-top: 4px;
}
.pal__head {
margin: 0 0 4px;
padding: 0 8px;
font-size: 9px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.08em;
color: rgba(23, 20, 26, 0.72);
}
.pal__opt {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
cursor: pointer;
}
.pal__ic {
display: inline-flex;
color: rgba(23, 20, 26, 0.5);
}
.pal__name {
overflow: hidden;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
text-overflow: ellipsis;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.12);
}
.pal__opt.is-at .pal__ic,
.pal__opt.is-at .pal__name {
color: #2f6df6;
}
.pal__opt.is-run {
background: rgba(47, 109, 246, 0.22);
}
.pal.is-demo .pal__opt {
animation: opt-cascade 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pal.is-demo #o2 {
animation-delay: 60ms;
}
.pal.is-demo #o3 {
animation-delay: 120ms;
}
.pal.is-demo #o4 {
animation-delay: 180ms;
}
@keyframes opt-cascade {
0%, 10% {
opacity: 1;
transform: translateY(0);
}
30%, 38% {
opacity: 0;
transform: translateY(10px);
}
64%, 100% {
opacity: 1;
transform: translateY(0);
}
}
.pal.is-demo .pal__head {
animation: head-dim 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes head-dim {
0%, 10% {
opacity: 1;
}
30%, 38% {
opacity: 0.25;
}
64%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__opt,
.pal.is-demo .pal__head {
animation: none !important;
}
}// 실제 동작: ⌘K(Ctrl+K)로 열고 닫기, ↑↓ 로 줄 移動, Enter 로 실행, Escape 로 닫기.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var at = -1;
function mark(i) {
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
function live() { pal.classList.remove('is-demo'); }
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && k === 'k') {
live();
pal.classList.toggle('is-shut');
if (!pal.classList.contains('is-shut')) input.focus({ preventScroll: true });
e.preventDefault();
return;
}
if (pal.classList.contains('is-shut')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
live(); mark(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
live();
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
opts[at].classList.add('is-run');
e.preventDefault();
} else if (e.key === 'Escape') {
live(); pal.classList.add('is-shut');
}
});
opts.forEach(function (o, i) {
o.addEventListener('mousemove', function () { live(); mark(i); });
o.addEventListener('click', function () { live(); mark(i); o.classList.add('is-run'); });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RecentActionsPalette.module.scss";
export type PaletteGroup = { title: string; items: string[] };
export type RecentActionsPaletteProps = {
placeholder?: string;
groups?: PaletteGroup[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; metaKey: boolean; ctrlKey: boolean; preventDefault: () => void };
const DEFAULT_GROUPS: PaletteGroup[] = [
{ title: "Recent", items: ["Weekly report draft", "Budget sheet"] },
{ title: "Go to", items: ["Dashboard", "Settings"] },
];
export default function RecentActionsPalette({
placeholder = "Search commands",
groups = DEFAULT_GROUPS,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: RecentActionsPaletteProps) {
const [open, setOpen] = useState(true);
const [at, setAt] = useState(-1);
const [ran, setRan] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const flat = groups.flatMap((g, gi) => g.items.map((label, ii) => ({ label, id: `cmdk-${gi}-${ii}` })));
// ⌘K / Ctrl+K 는 문서 전체에서 듣는다 — 팔레트가 닫혀 있을 때도 열려야 하기 때문
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
if (!open) return null;
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="cmdk-list"
aria-activedescendant={at >= 0 ? flat[at].id : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), flat.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setRan(at);
} else if (e.key === "Escape") {
setOpen(false);
}
}}
/>
<kbd className={styles.kbd}>⌘K</kbd>
</div>
<div className={styles.list} id="cmdk-list" role="listbox" aria-label="Command palette">
{groups.map((g, gi) => (
<div className={styles.grp} key={g.title} role="group" aria-labelledby={`cmdk-h-${gi}`}>
<p className={styles.head} id={`cmdk-h-${gi}`}>{g.title}</p>
{g.items.map((label) => {
const i = flat.findIndex((f) => f.label === label);
return (
<div
key={label}
id={flat[i].id}
className={`${styles.opt} ${i === at ? styles.isAt : ""} ${i === ran ? styles.isRun : ""}`}
role="option"
aria-selected={i === ran}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => { setAt(i); setRan(i); }}
>
<span className={styles.name}>{label}</span>
</div>
);
})}
</div>
))}
</div>
</div>
);
}01 · 開いた瞬間に出る最近の項目 — 計測 累積面積 2.486% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
開閉のショートカットは Mac の ⌘ と Windows の Ctrl を両方見ています。
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && k === 'k') {
live();
pal.classList.toggle('is-shut');
if (!pal.classList.contains('is-shut')) input.focus({ preventScroll: true });
e.preventDefault();
return;
}
if (pal.classList.contains('is-shut')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
live(); mark(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
live();
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
opts[at].classList.add('is-run');
e.preventDefault();
} else if (e.key === 'Escape') {
live(); pal.classList.add('is-shut');
}
});
02飛ばし打ちでも当たる検索
画面で起きること
文字を続けて打たず飛ばして打っても、順番さえ合えば当たります。探し方が部分文字列ではなく部分列なので、クエリの文字を1つずつ indexOf(c, from) で前へ前へと探し、1つでも見つからなければその行を外します。

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="コマンドを検索" placeholder="コマンドを検索" autocomplete="off">
<span class="pal__ghost" aria-hidden="true">新書</span>
</div>
<div class="pal__box">
<div class="pal__list" id="list" role="listbox" aria-label="コマンドパレット">
<div class="pal__opt pal__opt--out" id="o1" role="option" aria-selected="false" tabindex="-1"><span class="pal__name">コメントを追加</span></div>
<div class="pal__opt pal__opt--out" id="o2" role="option" aria-selected="false" tabindex="-1"><span class="pal__name">設定を開く</span></div>
<div class="pal__opt pal__opt--hit" id="o3" role="option" aria-selected="false" tabindex="-1"><span class="pal__name"><mark class="pal__m">新</mark> 規<mark class="pal__m">書</mark> 類を作成</span></div>
<div class="pal__opt pal__opt--hit" id="o4" role="option" aria-selected="false" tabindex="-1"><span class="pal__name"><mark class="pal__m">新</mark> しい文<mark class="pal__m">書</mark> を開く</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
position: relative;
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
// 자동 루프는 친 글자를 고스트로 겹쳐 보여주므로, 루프 동안에는 placeholder 를 비운다 (두 글자가 겹쳐 읽혔다)
.pal.is-demo .pal__input::placeholder { color: transparent; }
.pal__ghost {
position: absolute;
left: 32px; top: 50%;
transform: translateY(-50%);
font-size: 12px; font-weight: 700;
color: $subject-ink;
opacity: 0;
pointer-events: none;
}
.pal__box {
box-sizing: border-box;
height: 100px;
margin: $sp-2 0 0;
overflow: hidden;
}
.pal__list { padding: 0; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .12); }
.pal__name {
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
}
.pal__m {
position: relative;
z-index: 0;
background: transparent;
color: inherit;
font-weight: 800;
}
.pal__m::before {
content: "";
position: absolute;
inset: -2px;
z-index: -1;
border-radius: $r-xs;
background: rgba(255, 210, 63, .9);
opacity: 0;
}
.pal__m.is-on::before { opacity: 1; }
// ── 자동 루프: 「새서」를 치면 안 맞는 두 줄이 한 프레임에 사라지고, 맞은 두 줄이 그 자리로 올라온다 ──
// 사라짐과 올라옴이 같은 자리를 지나므로 사라짐은 steps(1) 로 먼저 끊고 그다음 미끄러진다 (겹쳐 비치는 프레임 0)
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__m::before { animation: cut-in $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__opt--out { animation: cut-out $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__opt--hit { animation: rise $dur-loop $easing infinite; }
@keyframes cut-in {
0%, 17% { opacity: 0; }
18%, 82% { opacity: 1; }
83%, 100% { opacity: 0; }
}
@keyframes cut-out {
0%, 17% { opacity: 1; }
18%, 82% { opacity: 0; }
83%, 100% { opacity: 1; }
}
@keyframes rise {
0%, 18% { transform: translateY(0); }
42%, 62% { transform: translateY(-48px); }
83%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__m::before,
.pal.is-demo .pal__opt--out,
.pal.is-demo .pal__opt--hit { animation: none !important; }
.pal__ghost { opacity: 0; }
}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);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
position: relative;
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal.is-demo .pal__input::placeholder {
color: transparent;
}
.pal__ghost {
position: absolute;
left: 32px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
font-weight: 700;
color: #17141a;
opacity: 0;
pointer-events: none;
}
.pal__box {
box-sizing: border-box;
height: 100px;
margin: 8px 0 0;
overflow: hidden;
}
.pal__list {
padding: 0;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.12);
}
.pal__name {
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
}
.pal__m {
position: relative;
z-index: 0;
background: transparent;
color: inherit;
font-weight: 800;
}
.pal__m::before {
content: "";
position: absolute;
inset: -2px;
z-index: -1;
border-radius: 4px;
background: rgba(255, 210, 63, 0.9);
opacity: 0;
}
.pal__m.is-on::before {
opacity: 1;
}
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__m::before {
animation: cut-in 2s steps(1, end) infinite;
}
.pal.is-demo .pal__opt--out {
animation: cut-out 2s steps(1, end) infinite;
}
.pal.is-demo .pal__opt--hit {
animation: rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cut-in {
0%, 17% {
opacity: 0;
}
18%, 82% {
opacity: 1;
}
83%, 100% {
opacity: 0;
}
}
@keyframes cut-out {
0%, 17% {
opacity: 1;
}
18%, 82% {
opacity: 0;
}
83%, 100% {
opacity: 1;
}
}
@keyframes rise {
0%, 18% {
transform: translateY(0);
}
42%, 62% {
transform: translateY(-48px);
}
83%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__m::before,
.pal.is-demo .pal__opt--out,
.pal.is-demo .pal__opt--hit {
animation: none !important;
}
.pal__ghost {
opacity: 0;
}
}// 실제 동작: 글자를 건너뛰어 쳐도 순서만 맞으면 걸린다(부분 수열). 맞은 글자만 mark 로 감싼다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var names = opts.map(function (o) { return o.querySelector('.pal__name').textContent; });
var at = -1;
function hits(name, q) {
var lower = name.toLowerCase(), need = q.toLowerCase();
var idx = [], i = 0;
for (var c = 0; c < need.length; c++) {
var k = lower.indexOf(need[c], i);
if (k < 0) return null;
idx.push(k); i = k + 1;
}
return idx;
}
function paint(name, idx) {
var html = '', last = 0;
idx.forEach(function (k) {
html += name.slice(last, k) + '<mark class="pal__m is-on">' + name[k] + '</mark>';
last = k + 1;
});
return html + name.slice(last);
}
function render(q) {
pal.classList.remove('is-demo');
opts.forEach(function (o, i) {
var idx = q ? hits(names[i], q) : [];
o.hidden = !idx;
if (idx) o.querySelector('.pal__name').innerHTML = idx.length ? paint(names[i], idx) : names[i];
});
at = -1;
}
input.addEventListener('input', function () { render(input.value.trim()); });
document.addEventListener('keydown', function (e) {
var vis = opts.filter(function (o) { return !o.hidden; });
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), vis.length - 1));
vis.forEach(function (o, i) { o.classList.toggle('is-at', i === at); });
if (vis[at]) input.setAttribute('aria-activedescendant', vis[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && vis[at]) {
vis.forEach(function (o) { o.setAttribute('aria-selected', String(o === vis[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; render(''); input.focus({ preventScroll: true });
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FuzzyPalette.module.scss";
export type FuzzyPaletteProps = {
placeholder?: string;
options?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
// 부분 수열 매칭 — 글자를 건너뛰어 쳐도 순서만 맞으면 걸린다. 맞은 자리를 그대로 돌려준다
export function fuzzy(name: string, query: string): number[] | null {
if (!query) return [];
const hay = name.toLowerCase();
const need = query.toLowerCase();
const hit: number[] = [];
let from = 0;
for (let c = 0; c < need.length; c++) {
const k = hay.indexOf(need[c], from);
if (k < 0) return null;
hit.push(k);
from = k + 1;
}
return hit;
}
function parts(name: string, hit: number[]) {
const out: { text: string; on: boolean }[] = [];
let last = 0;
hit.forEach((k) => {
if (k > last) out.push({ text: name.slice(last, k), on: false });
out.push({ text: name[k], on: true });
last = k + 1;
});
if (last < name.length) out.push({ text: name.slice(last), on: false });
return out;
}
export default function FuzzyPalette({
placeholder = "Search commands",
options = ["Add comment", "Open settings", "New document", "New window"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FuzzyPaletteProps) {
const [query, setQuery] = useState("");
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const visible = options
.map((name, i) => ({ name, i, hit: fuzzy(name, query.trim()) }))
.filter((row) => row.hit !== null);
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="fz-list"
aria-activedescendant={at >= 0 && visible[at] ? `fz-o${visible[at].i}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setAt(-1); }}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), visible.length - 1)));
} else if (e.key === "Enter" && visible[at]) {
e.preventDefault();
setPicked(visible[at].name);
} else if (e.key === "Escape") {
setQuery(""); setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.box}>
<div className={styles.list} id="fz-list" role="listbox" aria-label="Command palette">
{visible.map((row, vi) => (
<div
key={row.name}
id={`fz-o${row.i}`}
className={`${styles.opt} ${vi === at ? styles.isAt : ""}`}
role="option"
aria-selected={row.name === picked}
tabIndex={-1}
onMouseMove={() => setAt(vi)}
onClick={() => setPicked(row.name)}
>
<span className={styles.name}>
{parts(row.name, row.hit ?? []).map((p, pi) =>
p.on ? <mark className={styles.mark} key={pi}>{p.text}</mark> : <span key={pi}>{p.text}</span>
)}
</span>
</div>
))}
</div>
</div>
</div>
);
}02 · 飛ばし打ちでも当たる検索 — 計測 累積面積 4.095% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
一致した箇所は区間ではなく一文字ずつ <mark> で包むことで、飛ばして当たったことが目に見えます。
function hits(name, q) {
var lower = name.toLowerCase(), need = q.toLowerCase();
var idx = [], i = 0;
for (var c = 0; c < need.length; c++) {
var k = lower.indexOf(need[c], i);
if (k < 0) return null;
idx.push(k); i = k + 1;
}
return idx;
}
function paint(name, idx) {
var html = '', last = 0;
idx.forEach(function (k) {
html += name.slice(last, k) + '<mark class="pal__m is-on">' + name[k] + '</mark>';
last = k + 1;
});
return html + name.slice(last);
}
03カーソルを追うスクロール
どんな動きか
7行入っていますが箱は4行分しか見せず、カーソルが下端に触れた瞬間からリストが一行ずつ押し上がります。これをしているのは scrollIntoView の block: 'nearest' 一行で、すでに見えている行では何もしないため最初の三回は画面がまったく揺れません。

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="コマンドを検索" placeholder="コマンドを検索" autocomplete="off">
<kbd class="pal__kbd">↓</kbd>
</div>
<div class="pal__box" id="box">
<span class="pal__cursor" aria-hidden="true"></span>
<div class="pal__track" id="list" role="listbox" aria-label="コマンドパレット">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">文書を作成</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">フォルダを作成</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">メンバーを招待</div>
<div class="pal__opt" id="o4" role="option" aria-selected="false" tabindex="-1">通知をオフ</div>
<div class="pal__opt" id="o5" role="option" aria-selected="false" tabindex="-1">テーマを変更</div>
<div class="pal__opt" id="o6" role="option" aria-selected="false" tabindex="-1">エクスポート</div>
<div class="pal__opt" id="o7" role="option" aria-selected="false" tabindex="-1">サインアウト</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-orange);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.pal__kbd {
flex: 0 0 auto;
height: 14px;
display: inline-flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-xs;
background: #fff;
box-shadow: 0 1px 0 rgba(23, 20, 26, .22);
font-family: inherit;
font-size: 9px; font-weight: 800;
color: rgba(23, 20, 26, .5);
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: $sp-2 0 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.pal__box::-webkit-scrollbar { width: 0; }
.pal__cursor {
position: absolute;
left: 0; top: 0;
z-index: 0;
width: 100%; height: 24px;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
opacity: 0;
}
.pal__track { position: relative; z-index: 1; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: $subject-ink; // 목록이 흰 상자 밖(주황 무대)으로 밀려 나온 줄까지 읽히게 — 알파 없이
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .14); color: $color; }
// ── 자동 루프: 커서가 넷째 줄까지 내려간 뒤로는 커서가 서고 목록이 대신 밀려 올라간다 ──
// 커서와 목록이 각각 transform 으로만 움직인다 (실제 scrollLeft·scrollTop 은 WAAPI 로 못 잰다)
.pal.is-demo .pal__cursor { opacity: 1; animation: cur-run $dur-loop $easing infinite; }
.pal.is-demo .pal__track { animation: track-follow $dur-loop $easing infinite; }
@keyframes cur-run {
0% { transform: translateY(0); }
28%, 66% { transform: translateY(72px); }
90%, 100% { transform: translateY(0); }
}
@keyframes track-follow {
0%, 28% { transform: translateY(0); }
52%, 66% { transform: translateY(-72px); }
90%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__track { animation: none !important; }
.pal.is-demo .pal__cursor { opacity: 1; }
}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);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__kbd {
flex: 0 0 auto;
height: 14px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 4px;
background: #fff;
box-shadow: 0 1px 0 rgba(23, 20, 26, 0.22);
font-family: inherit;
font-size: 9px;
font-weight: 800;
color: rgba(23, 20, 26, 0.5);
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: 8px 0 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.pal__box::-webkit-scrollbar {
width: 0;
}
.pal__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 24px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.pal__track {
position: relative;
z-index: 1;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #17141a;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
}
.pal.is-demo .pal__cursor {
opacity: 1;
animation: cur-run 2s ease-out infinite;
}
.pal.is-demo .pal__track {
animation: track-follow 2s ease-out infinite;
}
@keyframes cur-run {
0% {
transform: translateY(0);
}
28%, 66% {
transform: translateY(72px);
}
90%, 100% {
transform: translateY(0);
}
}
@keyframes track-follow {
0%, 28% {
transform: translateY(0);
}
52%, 66% {
transform: translateY(-72px);
}
90%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__track {
animation: none !important;
}
.pal.is-demo .pal__cursor {
opacity: 1;
}
}// 실제 동작: ↑↓ 로 커서를 옮기고, 커서가 상자 밖으로 나가려는 순간 scrollIntoView 가 목록만 밀어 올린다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var at = -1;
function move(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
// block: 'nearest' 라서 이미 보이는 줄이면 스크롤이 아예 일어나지 않는다
opts[at].scrollIntoView({ block: 'nearest' });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Home' || e.key === 'End') {
move(e.key === 'Home' ? 0 : opts.length - 1); e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
opts.forEach(function (o) { o.classList.remove('is-at'); });
at = -1; input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) { o.addEventListener('mousemove', function () { move(i); }); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollFollowPalette.module.scss";
export type ScrollFollowPaletteProps = {
placeholder?: string;
options?: string[];
rows?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_OPTIONS = [
"Create document", "Create folder", "Invite member", "Mute notifications",
"Change theme", "Export", "Sign out",
];
export default function ScrollFollowPalette({
placeholder = "Search commands",
options = DEFAULT_OPTIONS,
rows = 4,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: ScrollFollowPaletteProps) {
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]) as { current: (HTMLDivElement | null)[] };
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, height: `${rows * 24}px` };
// block: "nearest" 라서 이미 보이는 줄이면 스크롤이 아예 일어나지 않는다 — 커서가 끝에 닿을 때만 목록이 움직인다
function move(next: number) {
const i = Math.max(0, Math.min(next, options.length - 1));
setAt(i);
rowRefs.current[i]?.scrollIntoView({ block: "nearest" });
}
return (
<div className={styles.pal} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="sf-list"
aria-activedescendant={at >= 0 ? `sf-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
move(at + (e.key === "ArrowDown" ? 1 : -1));
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
move(e.key === "Home" ? 0 : options.length - 1);
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(options[at]);
} else if (e.key === "Escape") {
setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
<kbd className={styles.kbd}>↓</kbd>
</div>
<div className={styles.box} style={vars}>
<div className={styles.track} id="sf-list" role="listbox" aria-label="Command palette">
{options.map((label, i) => (
<div
key={label}
id={`sf-o${i}`}
ref={(el: HTMLDivElement | null) => { rowRefs.current[i] = el; }}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => move(i)}
onClick={() => setPicked(label)}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}03 · カーソルを追うスクロール — 計測 累積面積 16.5% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
Home と End も受け取り、両端へ一度で飛びます。
function move(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
// block: 'nearest' なので、すでに見えている行ならスクロールは一切起きない
opts[at].scrollIntoView({ block: 'nearest' });
}
04右端で揃うショートカットバッジ
何が見えるか
コマンド名が二文字でも六文字でも、キーキャップのバッジは右端で一列に揃います。揃えているのは名前側の flex ではなくバッジ側の margin-left: auto で、余った横幅を丸ごとそのマージンが飲み込みます。

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="コマンドを検索" placeholder="コマンドを検索" autocomplete="off">
</div>
<div class="pal__box">
<span class="pal__cursor" aria-hidden="true"></span>
<div class="pal__list" id="list" role="listbox" aria-label="コマンドパレット">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">新規文書</span>
<span class="pal__keys"><kbd class="pal__kbd">⌘</kbd><kbd class="pal__kbd">N</kbd></span>
</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">検索</span>
<span class="pal__keys"><kbd class="pal__kbd">⌘</kbd><kbd class="pal__kbd">F</kbd></span>
</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">保存</span>
<span class="pal__keys"><kbd class="pal__kbd">⌘</kbd><kbd class="pal__kbd">S</kbd></span>
</div>
<div class="pal__opt" id="o4" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">設定を開く</span>
<span class="pal__keys"><kbd class="pal__kbd">⌘</kbd><kbd class="pal__kbd">,</kbd></span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-paper);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: $subject-ink;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(255, 247, 230, .08);
}
.pal__mag { display: inline-flex; color: rgba(255, 247, 230, .5); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $color;
}
.pal__input::placeholder { color: rgba(255, 247, 230, .42); }
.pal__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; border-radius: $r-xs; }
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: $sp-2 0 0;
}
.pal__cursor {
position: absolute;
left: 0; top: 0;
z-index: 0;
width: 100%; height: 24px;
border-radius: $r-sm;
background: rgba(255, 247, 230, .1);
opacity: 0;
}
.pal__list { position: relative; z-index: 1; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(255, 247, 230, .1); }
.pal__opt.is-run { background: rgba(47, 109, 246, .42); }
.pal__name {
font-size: 12px; font-weight: 600;
color: rgba(255, 247, 230, .88);
white-space: nowrap;
}
// 이름 길이와 무관하게 배지는 늘 오른쪽 끝 — 남는 자리를 auto 마진이 통째로 먹는다
.pal__keys { margin-left: auto; display: inline-flex; gap: $sp-1; }
.pal__kbd {
box-sizing: border-box;
min-width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0 $sp-1;
border-radius: $r-xs;
background: rgba(255, 247, 230, .14);
box-shadow: 0 1px 0 rgba(255, 247, 230, .28);
font-family: inherit;
font-size: 9px; font-weight: 800;
color: rgba(255, 247, 230, .72);
}
// ── 자동 루프: 커서가 네 줄을 차례로 짚고, 커서가 선 줄의 키캡만 1px 내려앉는다 ──
.pal.is-demo .pal__cursor { opacity: 1; animation: cur-step $dur-loop $easing infinite; }
@keyframes cur-step {
0%, 4% { transform: translateY(0); }
22%, 26% { transform: translateY(24px); }
44%, 48% { transform: translateY(48px); }
66%, 70% { transform: translateY(72px); }
92%, 100% { transform: translateY(0); }
}
.pal.is-demo #o1 .pal__kbd { animation: press-1 $dur-loop $ease-out infinite; }
.pal.is-demo #o2 .pal__kbd { animation: press-2 $dur-loop $ease-out infinite; }
.pal.is-demo #o3 .pal__kbd { animation: press-3 $dur-loop $ease-out infinite; }
.pal.is-demo #o4 .pal__kbd { animation: press-4 $dur-loop $ease-out infinite; }
@mixin press($a, $b) {
0%, #{$a} { transform: translateY(0); box-shadow: 0 1px 0 rgba(255, 247, 230, .28); background: rgba(255, 247, 230, .14); }
#{$a + 2%}, #{$b} { transform: translateY(1px); box-shadow: 0 0 0 rgba(255, 247, 230, 0); background: rgba(255, 247, 230, .3); }
#{$b + 2%}, 100% { transform: translateY(0); box-shadow: 0 1px 0 rgba(255, 247, 230, .28); background: rgba(255, 247, 230, .14); }
}
@keyframes press-1 { @include press(2%, 16%); }
@keyframes press-2 { @include press(24%, 38%); }
@keyframes press-3 { @include press(46%, 60%); }
@keyframes press-4 { @include press(68%, 82%); }
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__kbd { animation: none !important; }
.pal.is-demo .pal__cursor { opacity: 1; }
}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);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #17141a;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.08);
}
.pal__mag {
display: inline-flex;
color: rgba(255, 247, 230, 0.5);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.pal__input::placeholder {
color: rgba(255, 247, 230, 0.42);
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: 8px 0 0;
}
.pal__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 24px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
opacity: 0;
}
.pal__list {
position: relative;
z-index: 1;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(255, 247, 230, 0.1);
}
.pal__opt.is-run {
background: rgba(47, 109, 246, 0.42);
}
.pal__name {
font-size: 12px;
font-weight: 600;
color: rgba(255, 247, 230, 0.88);
white-space: nowrap;
}
.pal__keys {
margin-left: auto;
display: inline-flex;
gap: 4px;
}
.pal__kbd {
box-sizing: border-box;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.14);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
font-family: inherit;
font-size: 9px;
font-weight: 800;
color: rgba(255, 247, 230, 0.72);
}
.pal.is-demo .pal__cursor {
opacity: 1;
animation: cur-step 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cur-step {
0%, 4% {
transform: translateY(0);
}
22%, 26% {
transform: translateY(24px);
}
44%, 48% {
transform: translateY(48px);
}
66%, 70% {
transform: translateY(72px);
}
92%, 100% {
transform: translateY(0);
}
}
.pal.is-demo #o1 .pal__kbd {
animation: press-1 2s ease-out infinite;
}
.pal.is-demo #o2 .pal__kbd {
animation: press-2 2s ease-out infinite;
}
.pal.is-demo #o3 .pal__kbd {
animation: press-3 2s ease-out infinite;
}
.pal.is-demo #o4 .pal__kbd {
animation: press-4 2s ease-out infinite;
}
@keyframes press-1 {
0%, 2% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
4%, 16% {
transform: translateY(1px);
box-shadow: 0 0 0 rgba(255, 247, 230, 0);
background: rgba(255, 247, 230, 0.3);
}
18%, 100% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
}
@keyframes press-2 {
0%, 24% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
26%, 38% {
transform: translateY(1px);
box-shadow: 0 0 0 rgba(255, 247, 230, 0);
background: rgba(255, 247, 230, 0.3);
}
40%, 100% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
}
@keyframes press-3 {
0%, 46% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
48%, 60% {
transform: translateY(1px);
box-shadow: 0 0 0 rgba(255, 247, 230, 0);
background: rgba(255, 247, 230, 0.3);
}
62%, 100% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
}
@keyframes press-4 {
0%, 68% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
70%, 82% {
transform: translateY(1px);
box-shadow: 0 0 0 rgba(255, 247, 230, 0);
background: rgba(255, 247, 230, 0.3);
}
84%, 100% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__kbd {
animation: none !important;
}
.pal.is-demo .pal__cursor {
opacity: 1;
}
}// 실제 동작: ↑↓ 로 줄을 옮기면 그 줄의 키캡만 눌린 모양이 되고, 실제 ⌘/Ctrl 조합을 치면 그 줄이 실행된다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var keys = ['n', 'f', 's', ','];
var at = -1;
function move(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
function run(i) {
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === i)); });
opts[i].classList.add('is-run');
}
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if (e.metaKey || e.ctrlKey) {
var i = keys.indexOf(k);
if (i >= 0) { pal.classList.remove('is-demo'); move(i); run(i); e.preventDefault(); }
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && at >= 0) {
run(at); e.preventDefault();
} else if (e.key === 'Escape') {
opts.forEach(function (o) { o.classList.remove('is-at', 'is-run'); });
at = -1; input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) { o.addEventListener('mousemove', function () { move(i); }); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShortcutBadgePalette.module.scss";
export type ShortcutRow = { label: string; keys: string[]; accel: string };
export type ShortcutBadgePaletteProps = {
placeholder?: string;
rows?: ShortcutRow[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_ROWS: ShortcutRow[] = [
{ label: "New document", keys: ["⌘", "N"], accel: "n" },
{ label: "Search", keys: ["⌘", "F"], accel: "f" },
{ label: "Save", keys: ["⌘", "S"], accel: "s" },
{ label: "Open settings", keys: ["⌘", ","], accel: "," },
];
export default function ShortcutBadgePalette({
placeholder = "Search commands",
rows = DEFAULT_ROWS,
duration = 140,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ShortcutBadgePaletteProps) {
const [at, setAt] = useState(-1);
const [ran, setRan] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 배지에 적힌 조합을 실제로 치면 그 줄이 실행된다 — 치트시트가 장식이 아니게 하는 부분
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey)) return;
const i = rows.findIndex((r) => r.accel === e.key.toLowerCase());
if (i < 0) return;
e.preventDefault();
setAt(i);
setRan(i);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [rows]);
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" />
<path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="sb-list"
aria-activedescendant={at >= 0 ? `sb-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), rows.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setRan(at);
} else if (e.key === "Escape") {
setAt(-1); setRan(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.list} id="sb-list" role="listbox" aria-label="Command palette">
{rows.map((row, i) => (
<div
key={row.label}
id={`sb-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""} ${i === ran ? styles.isRun : ""}`}
role="option"
aria-selected={i === ran}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => { setAt(i); setRan(i); }}
>
<span className={styles.name}>{row.label}</span>
<span className={styles.keys}>
{row.keys.map((k) => <kbd className={styles.kbd} key={k}>{k}</kbd>)}
</span>
</div>
))}
</div>
</div>
);
}04 · 右端で揃うショートカットバッジ — 計測 累積面積 15.966% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
バッジに書いてある組み合わせを実際に打つとその行が動くので、早見表が絵で終わりません。
// 名前の長さに関係なくバッジは常に右端 — 余った場所を auto マージンが丸ごと飲み込む
.pal__keys { margin-left: auto; display: inline-flex; gap: $sp-1; }
.pal__kbd {
box-sizing: border-box;
min-width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0 $sp-1;
border-radius: $r-xs;
background: rgba(255, 247, 230, .14);
box-shadow: 0 1px 0 rgba(255, 247, 230, .28);
font-family: inherit;
font-size: 9px; font-weight: 800;
color: rgba(255, 247, 230, .72);
}
05一段潜るパンくず
画面で起きること
先頭の行を選ぶと入力欄の前にピル型のチップが1つ生まれ、リストがそのコマンドの対象一覧に切り替わります。戻る道は戻るボタンではなく空の入力欄での Backspace なので、手が入力欄を離れません。

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<div class="pal__bar">
<span class="pal__chip" id="chip">
<span class="pal__chipic" aria-hidden="true"><svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg></span>
チームに招待
</span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list-a" aria-label="コマンドを検索" placeholder="コマンドを検索" autocomplete="off">
</div>
<p class="pal__live" id="live" aria-live="polite"></p>
<div class="pal__box">
<div class="pal__list pal__list--a" id="list-a" role="listbox" aria-label="コマンドパレット">
<div class="pal__opt" id="a1" role="option" aria-selected="false" tabindex="-1">チームに招待</div>
<div class="pal__opt" id="a2" role="option" aria-selected="false" tabindex="-1">文書を共有</div>
<div class="pal__opt" id="a3" role="option" aria-selected="false" tabindex="-1">リンクをコピー</div>
<div class="pal__opt" id="a4" role="option" aria-selected="false" tabindex="-1">アーカイブへ移動</div>
</div>
<div class="pal__list pal__list--b" id="list-b" role="listbox" aria-label="チームに招待" hidden>
<div class="pal__opt" id="b1" role="option" aria-selected="false" tabindex="-1">デザインチーム</div>
<div class="pal__opt" id="b2" role="option" aria-selected="false" tabindex="-1">開発チーム</div>
<div class="pal__opt" id="b3" role="option" aria-selected="false" tabindex="-1">マーケティングチーム</div>
<div class="pal__opt" id="b4" role="option" aria-selected="false" tabindex="-1">経営支援チーム</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__chip {
flex: 0 0 auto;
height: 18px;
display: inline-flex; align-items: center; gap: $sp-1;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(47, 109, 246, .14);
font-size: 10px; font-weight: 800;
color: $color;
white-space: nowrap;
cursor: pointer;
opacity: 0;
transform: translateX(-8px);
}
.pal__chipic { display: inline-flex; }
.pal.is-deep .pal__chip { opacity: 1; transform: translateX(0); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.pal__live {
position: absolute;
width: 1px; height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: $sp-2 0 0;
}
.pal__list { position: absolute; left: 0; top: 0; width: 100%; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .12); color: $color; }
// ── 자동 루프: 칩이 생기며 명령 목록이 팀 목록으로 갈렸다가, 칩이 빠지며 되돌아온다 ──
// 두 목록은 같은 자리를 쓰므로 steps(1) 로 한 프레임에 교체한다 (겹쳐 비치는 프레임 0)
.pal.is-demo .pal__list--b[hidden] { display: block; }
.pal.is-demo .pal__chip { animation: chip-in $dur-loop $easing infinite; }
.pal.is-demo .pal__list--a { animation: cut-out $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__list--b { animation: cut-in $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__list--b .pal__opt { animation: slide-in $dur-loop $easing infinite; }
.pal.is-demo #b2 { animation-delay: 50ms; }
.pal.is-demo #b3 { animation-delay: 100ms; }
.pal.is-demo #b4 { animation-delay: 150ms; }
@keyframes chip-in {
0%, 16% { opacity: 0; transform: translateX(-8px); }
26%, 78% { opacity: 1; transform: translateX(0); }
88%, 100% { opacity: 0; transform: translateX(-8px); }
}
@keyframes cut-out {
0%, 17% { opacity: 1; }
18%, 84% { opacity: 0; }
85%, 100% { opacity: 1; }
}
@keyframes cut-in {
0%, 17% { opacity: 0; }
18%, 84% { opacity: 1; }
85%, 100% { opacity: 0; }
}
@keyframes slide-in {
0%, 18% { transform: translateX(16px); }
32%, 84% { transform: translateX(0); }
85%, 100% { transform: translateX(16px); }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__chip,
.pal.is-demo .pal__list--a,
.pal.is-demo .pal__list--b,
.pal.is-demo .pal__list--b .pal__opt { animation: none !important; }
.pal.is-demo .pal__list--b[hidden] { display: none; }
.pal.is-demo .pal__chip { opacity: 0; }
}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);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__chip {
flex: 0 0 auto;
height: 18px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.14);
font-size: 10px;
font-weight: 800;
color: #2f6df6;
white-space: nowrap;
cursor: pointer;
opacity: 0;
transform: translateX(-8px);
}
.pal__chipic {
display: inline-flex;
}
.pal.is-deep .pal__chip {
opacity: 1;
transform: translateX(0);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__live {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: 8px 0 0;
}
.pal__list {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
}
.pal.is-demo .pal__list--b[hidden] {
display: block;
}
.pal.is-demo .pal__chip {
animation: chip-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pal.is-demo .pal__list--a {
animation: cut-out 2s steps(1, end) infinite;
}
.pal.is-demo .pal__list--b {
animation: cut-in 2s steps(1, end) infinite;
}
.pal.is-demo .pal__list--b .pal__opt {
animation: slide-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pal.is-demo #b2 {
animation-delay: 50ms;
}
.pal.is-demo #b3 {
animation-delay: 100ms;
}
.pal.is-demo #b4 {
animation-delay: 150ms;
}
@keyframes chip-in {
0%, 16% {
opacity: 0;
transform: translateX(-8px);
}
26%, 78% {
opacity: 1;
transform: translateX(0);
}
88%, 100% {
opacity: 0;
transform: translateX(-8px);
}
}
@keyframes cut-out {
0%, 17% {
opacity: 1;
}
18%, 84% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes cut-in {
0%, 17% {
opacity: 0;
}
18%, 84% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes slide-in {
0%, 18% {
transform: translateX(16px);
}
32%, 84% {
transform: translateX(0);
}
85%, 100% {
transform: translateX(16px);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__chip,
.pal.is-demo .pal__list--a,
.pal.is-demo .pal__list--b,
.pal.is-demo .pal__list--b .pal__opt {
animation: none !important;
}
.pal.is-demo .pal__list--b[hidden] {
display: none;
}
.pal.is-demo .pal__chip {
opacity: 0;
}
}// 실제 동작: 첫 줄을 고르면 입력칸 앞에 칩이 생기며 하위 목록으로 갈리고, 빈 입력칸에서 Backspace 를 치면 되돌아온다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var chip = document.getElementById('chip');
var live = document.getElementById('live');
var A = document.getElementById('list-a');
var B = document.getElementById('list-b');
var deep = false, at = -1;
function rows() { return [].slice.call((deep ? B : A).querySelectorAll('.pal__opt')); }
function paint() {
pal.classList.remove('is-demo');
pal.classList.toggle('is-deep', deep);
A.hidden = deep; B.hidden = !deep;
input.setAttribute('aria-controls', deep ? 'list-b' : 'list-a');
live.textContent = deep ? 'チームに招待' : '';
at = -1;
}
function move(i) {
var r = rows();
at = Math.max(0, Math.min(i, r.length - 1));
r.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', r[at].id);
}
document.addEventListener('keydown', function (e) {
var r = rows();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
pal.classList.remove('is-demo'); move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && r[at]) {
if (!deep && at === 0) { deep = true; paint(); } else { r[at].setAttribute('aria-selected', 'true'); }
e.preventDefault();
} else if (e.key === 'Backspace' && deep && !input.value) {
deep = false; paint(); input.focus({ preventScroll: true }); e.preventDefault();
} else if (e.key === 'Escape') {
deep = false; paint(); input.value = ''; input.focus({ preventScroll: true });
}
});
A.querySelectorAll('.pal__opt')[0].addEventListener('click', function () { deep = true; paint(); });
chip.addEventListener('click', function () { deep = false; paint(); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BreadcrumbPalette.module.scss";
export type BreadcrumbPaletteProps = {
placeholder?: string;
commands?: string[];
targets?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
export default function BreadcrumbPalette({
placeholder = "Search commands",
commands = ["Invite to team", "Share document", "Copy link", "Move to archive"],
targets = ["Design team", "Engineering team", "Marketing team", "Operations team"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BreadcrumbPaletteProps) {
const [deep, setDeep] = useState(false);
const [query, setQuery] = useState("");
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const rows = deep ? targets : commands;
function enter() { setDeep(true); setQuery(""); setAt(-1); }
function back() {
setDeep(false); setQuery(""); setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
{deep ? (
<button className={styles.chip} type="button" onClick={back}>
<span className={styles.chipic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="11" height="11">
<g fill="currentColor">
<path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2" />
<path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52" />
</g>
</svg>
</span>
{commands[0]}
</button>
) : null}
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="bc-list"
aria-activedescendant={at >= 0 ? `bc-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => setQuery(e.target.value)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), rows.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
if (!deep && at === 0) enter(); else setPicked(rows[at]);
} else if (e.key === "Backspace" && deep && !query) {
// 빈 입력칸에서의 Backspace 는 글자가 아니라 한 단계를 지운다
e.preventDefault();
back();
} else if (e.key === "Escape") {
back();
}
}}
/>
</div>
<p className={styles.live} aria-live="polite">{deep ? commands[0] : ""}</p>
<div className={styles.box}>
<div className={styles.list} id="bc-list" role="listbox" aria-label={deep ? commands[0] : "Command palette"}>
{rows.map((label, i) => (
<div
key={label}
id={`bc-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => { if (!deep && i === 0) enter(); else setPicked(label); }}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}05 · 一段潜るパンくず — 計測 累積面積 2.706% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
今どの段にいるかは、画面から隠した aria-live="polite" の段落が読み上げます。
document.addEventListener('keydown', function (e) {
var r = rows();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
pal.classList.remove('is-demo'); move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && r[at]) {
if (!deep && at === 0) { deep = true; paint(); } else { r[at].setAttribute('aria-selected', 'true'); }
e.preventDefault();
} else if (e.key === 'Backspace' && deep && !input.value) {
deep = false; paint(); input.focus({ preventScroll: true }); e.preventDefault();
} else if (e.key === 'Escape') {
deep = false; paint(); input.value = ''; input.focus({ preventScroll: true });
}
});
06右に付くプレビューパネル
どんな動きか
カーソルのある行の中身が右半分にすぐ出て、行が変わるたびにパネルの中だけが丸ごと入れ替わります。左のリストと右のパネルは grid-template-columns: 1fr 104px の一行で分け、入力欄に aria-describedby を付けて今のプレビューが何かも読み上げられるようにしました。

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-describedby="pane" aria-label="文書を検索" placeholder="文書を検索" autocomplete="off">
</div>
<div class="pal__body">
<div class="pal__side">
<span class="pal__cursor" aria-hidden="true"></span>
<div class="pal__list" id="list" role="listbox" aria-label="文書一覧">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">週次レポート</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">予算シート</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">会議メモ</div>
</div>
</div>
<div class="pal__pane" id="pane">
<div class="pal__pv" id="p1">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM200 216H56V40h88v48a8 8 0 0 0 8 8h48zm-32-80a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m0 32a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg></span>
<p class="pal__pvt">週次レポート</p>
<span class="pal__ln" style="width: 90%"></span>
<span class="pal__ln" style="width: 64%"></span>
<span class="pal__tag">報告</span>
</div>
<div class="pal__pv" id="p2">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 64v128H88V64Z" opacity=".2"/><path d="M80 64a8 8 0 0 1 8-8h128a8 8 0 0 1 0 16H88a8 8 0 0 1-8-8m136 56H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16m0 64H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16M44 52a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>
<p class="pal__pvt">予算シート</p>
<span class="pal__ln" style="width: 56%"></span>
<span class="pal__ln" style="width: 88%"></span>
<span class="pal__tag">予算</span>
</div>
<div class="pal__pv" id="p3">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m200 88l-72 72H96v-32l72-72Z" opacity=".2"/><path d="m229.66 58.34l-32-32a8 8 0 0 0-11.32 0l-96 96A8 8 0 0 0 88 128v32a8 8 0 0 0 8 8h32a8 8 0 0 0 5.66-2.34l96-96a8 8 0 0 0 0-11.32M124.69 152H104v-20.69l64-64L188.69 88ZM200 76.69L179.31 56L192 43.31L212.69 64ZM224 128v80a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h80a8 8 0 0 1 0 16H48v160h160v-80a8 8 0 0 1 16 0"/></g></svg></span>
<p class="pal__pvt">会議メモ</p>
<span class="pal__ln" style="width: 72%"></span>
<span class="pal__ln" style="width: 50%"></span>
<span class="pal__tag">会議</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.pal {
box-sizing: border-box;
width: min(272px, 90vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.pal__body {
display: grid;
grid-template-columns: 1fr 104px;
gap: $sp-2;
margin: $sp-2 0 0;
}
.pal__side { position: relative; box-sizing: border-box; height: 92px; }
.pal__cursor {
position: absolute;
left: 0; top: 0;
z-index: 0;
width: 100%; height: 24px;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
opacity: 0;
}
.pal__list { position: relative; z-index: 1; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .14); color: $color; }
.pal__pane {
position: relative;
box-sizing: border-box;
height: 92px;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
overflow: hidden;
}
.pal__pv {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: $sp-2;
opacity: 0;
}
.pal__pv:first-child { opacity: 1; }
.pal__pv.is-on { opacity: 1; }
.pal__pvic { display: block; color: $color; }
.pal__pvt {
margin: $sp-1 0 $sp-2;
font-size: 10px; font-weight: 800;
color: rgba(23, 20, 26, .78);
white-space: nowrap;
}
.pal__ln {
display: block;
height: 4px;
margin: 0 0 $sp-1;
border-radius: $r-xs;
background: rgba(23, 20, 26, .16);
}
.pal__tag {
display: inline-flex; align-items: center;
height: 14px;
margin: $sp-1 0 0;
padding: 0 $sp-2;
border-radius: $r-pill;
font-size: 8px; font-weight: 800;
color: #fff;
}
// 세 미리보기의 바탕색까지 다르게 둔다 — 교체 순간이 이 데모에서 가장 크게 변하는 프레임이 되어야
// poster(가장 크게 변하는 프레임)가 「커서는 아직 이동 중인데 패널만 바뀐」 장면으로 잡히지 않는다
#p1 { background: rgba(47, 109, 246, .1); }
#p2 { background: rgba(23, 20, 26, .07); }
#p3 { background: rgba(76, 212, 166, .16); }
#p1 .pal__tag { background: $color; }
#p2 .pal__tag { background: $subject-ink; }
#p3 .pal__tag { background: $subject-mint; }
#p2 .pal__pvic { color: $subject-ink; }
#p3 .pal__pvic { color: $subject-mint; }
// ── 자동 루프: 커서가 세 줄을 짚는 동안 오른쪽 패널 내용만 한 프레임에 통째로 바뀐다 ──
// 세 미리보기가 같은 자리에 겹쳐 있으므로 steps(1) 로 끊는다 — 두 장이 동시에 비치는 프레임이 없다
.pal.is-demo .pal__cursor { opacity: 1; animation: cur-step $dur-loop $easing infinite; }
.pal.is-demo #p1 { animation: pv-1 $dur-loop steps(1, end) infinite; }
.pal.is-demo #p2 { animation: pv-2 $dur-loop steps(1, end) infinite; }
.pal.is-demo #p3 { animation: pv-3 $dur-loop steps(1, end) infinite; }
// 커서가 줄에 **닿아 선 뒤에** 패널이 바뀌어야 한다 — 포스터로 쓰이는 「가장 크게 변하는 프레임」이
// 곧 교체 프레임이라, 교체가 커서 이동 중에 일어나면 커서 1번·패널 3번인 장면이 대표 이미지가 된다 (초안 실측).
// 마지막 프레임이 2초의 95.8% 지점이라 되돌아오는 교체도 90% 안에 끝내야 샘플에 잡힌다
@keyframes cur-step {
0%, 18% { transform: translateY(0); }
30%, 50% { transform: translateY(24px); }
62%, 74% { transform: translateY(48px); }
86%, 100% { transform: translateY(0); }
}
@keyframes pv-1 {
0%, 33% { opacity: 1; }
34%, 89% { opacity: 0; }
90%, 100% { opacity: 1; }
}
@keyframes pv-2 {
0%, 33% { opacity: 0; }
34%, 65% { opacity: 1; }
66%, 100% { opacity: 0; }
}
@keyframes pv-3 {
0%, 65% { opacity: 0; }
66%, 89% { opacity: 1; }
90%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__pv { animation: none !important; }
.pal.is-demo .pal__cursor { opacity: 1; }
}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);
}
.pal {
box-sizing: border-box;
width: min(272px, 90vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__body {
display: grid;
grid-template-columns: 1fr 104px;
gap: 8px;
margin: 8px 0 0;
}
.pal__side {
position: relative;
box-sizing: border-box;
height: 92px;
}
.pal__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 24px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.pal__list {
position: relative;
z-index: 1;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
}
.pal__pane {
position: relative;
box-sizing: border-box;
height: 92px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
overflow: hidden;
}
.pal__pv {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: 8px;
opacity: 0;
}
.pal__pv:first-child {
opacity: 1;
}
.pal__pv.is-on {
opacity: 1;
}
.pal__pvic {
display: block;
color: #2f6df6;
}
.pal__pvt {
margin: 4px 0 8px;
font-size: 10px;
font-weight: 800;
color: rgba(23, 20, 26, 0.78);
white-space: nowrap;
}
.pal__ln {
display: block;
height: 4px;
margin: 0 0 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.16);
}
.pal__tag {
display: inline-flex;
align-items: center;
height: 14px;
margin: 4px 0 0;
padding: 0 8px;
border-radius: 999px;
font-size: 8px;
font-weight: 800;
color: #fff;
}
#p1 {
background: rgba(47, 109, 246, 0.1);
}
#p2 {
background: rgba(23, 20, 26, 0.07);
}
#p3 {
background: rgba(76, 212, 166, 0.16);
}
#p1 .pal__tag {
background: #2f6df6;
}
#p2 .pal__tag {
background: #17141a;
}
#p3 .pal__tag {
background: #4cd4a6;
}
#p2 .pal__pvic {
color: #17141a;
}
#p3 .pal__pvic {
color: #4cd4a6;
}
.pal.is-demo .pal__cursor {
opacity: 1;
animation: cur-step 2s ease-out infinite;
}
.pal.is-demo #p1 {
animation: pv-1 2s steps(1, end) infinite;
}
.pal.is-demo #p2 {
animation: pv-2 2s steps(1, end) infinite;
}
.pal.is-demo #p3 {
animation: pv-3 2s steps(1, end) infinite;
}
@keyframes cur-step {
0%, 18% {
transform: translateY(0);
}
30%, 50% {
transform: translateY(24px);
}
62%, 74% {
transform: translateY(48px);
}
86%, 100% {
transform: translateY(0);
}
}
@keyframes pv-1 {
0%, 33% {
opacity: 1;
}
34%, 89% {
opacity: 0;
}
90%, 100% {
opacity: 1;
}
}
@keyframes pv-2 {
0%, 33% {
opacity: 0;
}
34%, 65% {
opacity: 1;
}
66%, 100% {
opacity: 0;
}
}
@keyframes pv-3 {
0%, 65% {
opacity: 0;
}
66%, 89% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__pv {
animation: none !important;
}
.pal.is-demo .pal__cursor {
opacity: 1;
}
}// 실제 동작: ↑↓ 로 줄을 옮기면 오른쪽 패널이 그 줄의 미리보기로 통째로 바뀐다 (겹치는 프레임 없이 교체).
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var pvs = [].slice.call(document.querySelectorAll('.pal__pv'));
var at = 0;
function show(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
pvs.forEach(function (p, k) { p.classList.toggle('is-on', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
show(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter') {
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === at)); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) { o.addEventListener('mousemove', function () { show(i); }); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PreviewPanePalette.module.scss";
export type PreviewDoc = { title: string; tag: string; lines: number[]; tint: string };
export type PreviewPanePaletteProps = {
placeholder?: string;
docs?: PreviewDoc[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PaneVars = CSSProperties & { "--tint"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_DOCS: PreviewDoc[] = [
{ title: "Weekly report", tag: "Report", lines: [90, 64], tint: "rgba(47, 109, 246, .1)" },
{ title: "Budget sheet", tag: "Budget", lines: [56, 88], tint: "rgba(23, 20, 26, .07)" },
{ title: "Meeting notes", tag: "Meeting", lines: [72, 50], tint: "rgba(76, 212, 166, .16)" },
];
export default function PreviewPanePalette({
placeholder = "Search documents",
docs = DEFAULT_DOCS,
duration = 140,
easing = "ease-out",
color = "#2f6df6",
}: PreviewPanePaletteProps) {
const [at, setAt] = useState(0);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const doc = docs[at];
const paneVars: PaneVars = { "--tint": doc.tint };
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="pp-list"
aria-describedby="pp-pane"
aria-activedescendant={`pp-o${at}`}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), docs.length - 1)));
} else if (e.key === "Enter") {
e.preventDefault();
setPicked(doc.title);
} else if (e.key === "Escape") {
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.body}>
<div className={styles.list} id="pp-list" role="listbox" aria-label="Document list">
{docs.map((d, i) => (
<div
key={d.title}
id={`pp-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={d.title === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(d.title)}
>
{d.title}
</div>
))}
</div>
{/* 줄이 바뀌면 패널 내용이 통째로 갈린다 — 페이드 없이 한 번에 (두 장이 겹쳐 비치지 않게) */}
<div className={styles.pane} id="pp-pane" style={paneVars} key={doc.title}>
<span className={styles.pvic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM200 216H56V40h88v48a8 8 0 0 0 8 8h48zm-32-80a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m0 32a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8" />
</g>
</svg>
</span>
<p className={styles.pvt}>{doc.title}</p>
{doc.lines.map((w, i) => <span className={styles.ln} style={{ width: `${w}%` }} key={i} />)}
<span className={styles.tag}>{doc.tag}</span>
</div>
</div>
</div>
);
}06 · 右に付くプレビューパネル — 計測 累積面積 13.172% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
入れ替えは混ぜずに1フレームで切ります。
function show(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
pvs.forEach(function (p, k) { p.classList.toggle('is-on', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
07空振りで終わらない検索結果なし
何が見えるか
一致する行が1つもないとき、案内を一行置いて終わらせず、今打った文字をそのまま含む行が2つ代わりに上がります。その2行にも role="option" が付いているので、矢印キーと Enter がそのまま効きます。

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="コマンドを検索" placeholder="コマンドを検索" autocomplete="off">
<span class="pal__ghost" aria-hidden="true">月次レポート</span>
</div>
<div class="pal__box">
<div class="pal__list" id="list" role="listbox" aria-label="コマンドパレット">
<div class="pal__opt pal__opt--out" id="o1" role="option" aria-selected="false" tabindex="-1">文書を作成</div>
<div class="pal__opt pal__opt--out" id="o2" role="option" aria-selected="false" tabindex="-1">メンバーを招待</div>
<div class="pal__opt pal__opt--out" id="o3" role="option" aria-selected="false" tabindex="-1">設定を開く</div>
<div class="pal__empty" id="empty" hidden>
<p class="pal__note">検索結果なし</p>
<div class="pal__opt pal__opt--sug" id="s1" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><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></span>
<span class="pal__act">新規作成</span><span class="pal__q">月次レポート</span>
</div>
<div class="pal__opt pal__opt--sug" id="s2" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<span class="pal__act">全体を検索</span><span class="pal__q">月次レポート</span>
</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
position: relative;
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
// 자동 루프는 친 글자를 고스트로 겹쳐 보여주므로, 루프 동안에는 placeholder 를 비운다 (두 글자가 겹쳐 읽혔다)
.pal.is-demo .pal__input::placeholder { color: transparent; }
.pal__ghost {
position: absolute;
left: 32px; top: 50%;
transform: translateY(-50%);
font-size: 12px; font-weight: 700;
color: $subject-ink;
opacity: 0;
pointer-events: none;
}
.pal__box {
box-sizing: border-box;
height: 96px;
margin: $sp-2 0 0;
overflow: hidden;
}
.pal__list { position: relative; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .12); }
.pal__ic { display: inline-flex; color: $color; }
.pal__name { overflow: hidden; text-overflow: ellipsis; }
.pal__act { flex: 0 0 auto; }
.pal__q {
overflow: hidden;
font-weight: 800;
color: $color;
text-overflow: ellipsis;
}
.pal__empty { position: absolute; left: 0; top: 0; width: 100%; }
.pal.is-demo .pal__empty[hidden] { display: block; }
.pal__note {
margin: 0 0 $sp-1;
padding: 0 $sp-2;
height: 20px;
display: flex; align-items: center;
font-size: 10px; font-weight: 700;
color: rgba(23, 20, 26, .4);
}
// ── 자동 루프: 맞는 줄이 없어지는 순간 안내 한 줄과 제안 두 줄이 같은 자리로 올라온다 ──
// 사라지는 줄과 올라오는 줄이 겹치지 않도록 사라짐·나타남은 steps(1) 로 끊고, 제안만 미끄러져 올라온다
.pal.is-demo .pal__ghost { animation: cut-in $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__opt--out { animation: cut-out $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__empty { animation: cut-in $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__opt--sug { animation: rise $dur-loop $easing infinite; }
.pal.is-demo #s2 { animation-delay: 70ms; }
@keyframes cut-in {
0%, 17% { opacity: 0; }
18%, 82% { opacity: 1; }
83%, 100% { opacity: 0; }
}
@keyframes cut-out {
0%, 17% { opacity: 1; }
18%, 82% { opacity: 0; }
83%, 100% { opacity: 1; }
}
@keyframes rise {
0%, 18% { transform: translateY(20px); }
40%, 62% { transform: translateY(0); }
83%, 100% { transform: translateY(20px); }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__opt--out,
.pal.is-demo .pal__empty,
.pal.is-demo .pal__opt--sug { animation: none !important; }
.pal.is-demo .pal__empty[hidden] { display: none; }
.pal__ghost { opacity: 0; }
}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);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
position: relative;
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal.is-demo .pal__input::placeholder {
color: transparent;
}
.pal__ghost {
position: absolute;
left: 32px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
font-weight: 700;
color: #17141a;
opacity: 0;
pointer-events: none;
}
.pal__box {
box-sizing: border-box;
height: 96px;
margin: 8px 0 0;
overflow: hidden;
}
.pal__list {
position: relative;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.12);
}
.pal__ic {
display: inline-flex;
color: #2f6df6;
}
.pal__name {
overflow: hidden;
text-overflow: ellipsis;
}
.pal__act {
flex: 0 0 auto;
}
.pal__q {
overflow: hidden;
font-weight: 800;
color: #2f6df6;
text-overflow: ellipsis;
}
.pal__empty {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.pal.is-demo .pal__empty[hidden] {
display: block;
}
.pal__note {
margin: 0 0 4px;
padding: 0 8px;
height: 20px;
display: flex;
align-items: center;
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, 0.4);
}
.pal.is-demo .pal__ghost {
animation: cut-in 2s steps(1, end) infinite;
}
.pal.is-demo .pal__opt--out {
animation: cut-out 2s steps(1, end) infinite;
}
.pal.is-demo .pal__empty {
animation: cut-in 2s steps(1, end) infinite;
}
.pal.is-demo .pal__opt--sug {
animation: rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pal.is-demo #s2 {
animation-delay: 70ms;
}
@keyframes cut-in {
0%, 17% {
opacity: 0;
}
18%, 82% {
opacity: 1;
}
83%, 100% {
opacity: 0;
}
}
@keyframes cut-out {
0%, 17% {
opacity: 1;
}
18%, 82% {
opacity: 0;
}
83%, 100% {
opacity: 1;
}
}
@keyframes rise {
0%, 18% {
transform: translateY(20px);
}
40%, 62% {
transform: translateY(0);
}
83%, 100% {
transform: translateY(20px);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__opt--out,
.pal.is-demo .pal__empty,
.pal.is-demo .pal__opt--sug {
animation: none !important;
}
.pal.is-demo .pal__empty[hidden] {
display: none;
}
.pal__ghost {
opacity: 0;
}
}// 실제 동작: 맞는 줄이 하나도 없으면 안내만 남기지 않고, 친 글자를 그대로 담은 실행 가능한 줄 두 개를 대신 올린다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var empty = document.getElementById('empty');
var rows = [].slice.call(document.querySelectorAll('.pal__opt--out'));
var sugs = [].slice.call(document.querySelectorAll('.pal__opt--sug'));
var names = rows.map(function (o) { return o.textContent.trim(); });
var at = -1;
function render(q) {
pal.classList.remove('is-demo');
var shown = 0;
rows.forEach(function (o, i) {
var hit = !q || names[i].indexOf(q) >= 0;
o.hidden = !hit;
if (hit) shown++;
});
empty.hidden = !(q && shown === 0);
// 제안 줄에 방금 친 글자를 그대로 넣는다 — "그 이름으로" 가 이 상태의 값이다
document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
at = -1;
}
function live() { return sugs.filter(function (o) { return !empty.hidden; }).concat(rows.filter(function (o) { return !o.hidden; })); }
input.addEventListener('input', function () { render(input.value.trim()); });
document.addEventListener('keydown', function (e) {
var vis = live();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), vis.length - 1));
vis.forEach(function (o, i) { o.classList.toggle('is-at', i === at); });
if (vis[at]) input.setAttribute('aria-activedescendant', vis[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && vis[at]) {
vis.forEach(function (o) { o.setAttribute('aria-selected', String(o === vis[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; render(''); input.focus({ preventScroll: true });
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EmptySuggestPalette.module.scss";
export type EmptySuggestPaletteProps = {
placeholder?: string;
options?: string[];
emptyText?: string;
suggestions?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
export default function EmptySuggestPalette({
placeholder = "Search commands",
options = ["Create document", "Invite member", "Open settings"],
emptyText = "No results",
suggestions = ["Create new", "Search all"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: EmptySuggestPaletteProps) {
const [query, setQuery] = useState("");
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const q = query.trim();
const hits = options.filter((o) => !q || o.toLowerCase().includes(q.toLowerCase()));
// 빗나간 검색은 안내 한 줄로 끝내지 않는다 — 친 글자를 그대로 담은 실행 가능한 줄을 대신 세운다
const empty = q.length > 0 && hits.length === 0;
const rows = empty ? suggestions : hits;
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="es-list"
aria-activedescendant={at >= 0 ? `es-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setAt(-1); }}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), rows.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(rows[at]);
} else if (e.key === "Escape") {
setQuery(""); setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.box}>
{empty ? <p className={styles.note}>{emptyText}</p> : null}
<div className={styles.list} id="es-list" role="listbox" aria-label="Command palette">
{rows.map((label, i) => (
<div
key={label}
id={`es-o${i}`}
className={`${styles.opt} ${empty ? styles.isSug : ""} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(label)}
>
{empty ? (
<>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<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>
</span>
<span className={styles.act}>{label}</span>
<span className={styles.q}>{q}</span>
</>
) : label}
</div>
))}
</div>
</div>
</div>
);
}07 · 空振りで終わらない検索結果なし — 計測 累積面積 4.858% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
案内文と選択肢はここでは別の物です。
function render(q) {
pal.classList.remove('is-demo');
var shown = 0;
rows.forEach(function (o, i) {
var hit = !q || names[i].indexOf(q) >= 0;
o.hidden = !hit;
if (hit) shown++;
});
empty.hidden = !(q && shown === 0);
// 提案の行に今打った文字をそのまま入れる — 「その名前で」がこの状態の値
document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
at = -1;
}
08画面中央の大きな入力欄
画面で起きること
最初は大きな入力欄が1つ浮かんでいるだけで、文字が入った瞬間に下へ結果が開きます。開くのに使う値は高さでも scaleY でもなく clip-path の inset の下側の値で、パネルが占める 84px の場所は閉じているときも取ったままにしてあります。

<main class="stage">
<div class="sp is-demo is-open" role="dialog" aria-modal="true" aria-label="全体を検索">
<div class="sp__field">
<span class="sp__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="sp__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="全体を検索" placeholder="全体を検索" autocomplete="off">
<span class="sp__ghost" aria-hidden="true">レポート</span>
</div>
<div class="sp__panel">
<span class="sp__sheet" aria-hidden="true"></span>
<div class="sp__rows" id="list" role="listbox" aria-label="検索結果">
<div class="sp__opt" id="o1" role="option" aria-selected="false" tabindex="-1">週次レポート</div>
<div class="sp__opt" id="o2" role="option" aria-selected="false" tabindex="-1">四半期レポート</div>
<div class="sp__opt" id="o3" role="option" aria-selected="false" tabindex="-1">レポートの書式</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-paper);
.sp {
box-sizing: border-box;
width: min(264px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
}
.sp__field {
position: relative;
box-sizing: border-box;
height: 44px;
display: flex; align-items: center; gap: $sp-3;
padding: 0 $sp-4;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
}
.sp__mag { display: inline-flex; color: rgba(255, 247, 230, .5); }
.sp__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 15px; font-weight: 600;
color: $color;
}
.sp__input::placeholder { color: rgba(255, 247, 230, .4); }
.sp__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 4px; border-radius: $r-sm; }
// 자동 루프는 친 글자를 고스트로 겹쳐 보여주므로, 루프 동안에는 placeholder 를 비운다
.sp.is-demo .sp__input::placeholder { color: transparent; }
.sp__ghost {
position: absolute;
left: 46px; top: 50%;
transform: translateY(-50%);
font-size: 15px; font-weight: 700;
color: $color;
opacity: 0;
pointer-events: none;
}
// 패널 자리는 늘 잡아 두고(레이아웃이 흔들리지 않게) 위 모서리부터 잘라 보여준다.
// scaleY 로 줄였더니 글자까지 같이 눌려 납작해졌다 — clip-path 는 그린 것을 그대로 두고 잘라내기만 한다
.sp__panel {
position: relative;
box-sizing: border-box;
height: 84px;
margin: $sp-2 0 0;
clip-path: inset(0 0 100% 0 round 16px);
}
.sp__sheet {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-press;
}
.sp__rows {
position: relative;
box-sizing: border-box;
padding: $sp-2;
}
.sp.is-open .sp__panel { clip-path: inset(0 0 0 0 round 16px); }
.sp__opt {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(255, 247, 230, .82);
white-space: nowrap;
cursor: pointer;
}
.sp__opt.is-at { background: rgba(255, 247, 230, .12); }
// ── 자동 루프: 글자가 들어오면 패널이 위 모서리부터 펴지고, 글자가 지워지면 같은 자리로 접힌다 ──
// 접힘·펴짐이 둘 다 위 모서리 기준이라 한 바퀴 끝에 자리가 튀지 않는다
.sp.is-demo .sp__panel { animation: panel-open $dur-loop $easing infinite; }
.sp.is-demo .sp__ghost { animation: ghost-cut $dur-loop steps(1, end) infinite; }
@keyframes panel-open {
0%, 42% { clip-path: inset(0 0 0 0 round 16px); }
58%, 74% { clip-path: inset(0 0 100% 0 round 16px); }
90%, 100% { clip-path: inset(0 0 0 0 round 16px); }
}
@keyframes ghost-cut {
0%, 49% { opacity: 1; }
50%, 87% { opacity: 0; }
88%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__panel,
.sp.is-demo .sp__ghost { animation: none !important; }
.sp__ghost { opacity: 0; }
}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);
}
.sp {
box-sizing: border-box;
width: min(264px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.sp__field {
position: relative;
box-sizing: border-box;
height: 44px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sp__mag {
display: inline-flex;
color: rgba(255, 247, 230, 0.5);
}
.sp__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 15px;
font-weight: 600;
color: #fff7e6;
}
.sp__input::placeholder {
color: rgba(255, 247, 230, 0.4);
}
.sp__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
border-radius: 8px;
}
.sp.is-demo .sp__input::placeholder {
color: transparent;
}
.sp__ghost {
position: absolute;
left: 46px;
top: 50%;
transform: translateY(-50%);
font-size: 15px;
font-weight: 700;
color: #fff7e6;
opacity: 0;
pointer-events: none;
}
.sp__panel {
position: relative;
box-sizing: border-box;
height: 84px;
margin: 8px 0 0;
clip-path: inset(0 0 100% 0 round 16px);
}
.sp__sheet {
position: absolute;
inset: 0;
border-radius: 16px;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.sp__rows {
position: relative;
box-sizing: border-box;
padding: 8px;
}
.sp.is-open .sp__panel {
clip-path: inset(0 0 0 0 round 16px);
}
.sp__opt {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(255, 247, 230, 0.82);
white-space: nowrap;
cursor: pointer;
}
.sp__opt.is-at {
background: rgba(255, 247, 230, 0.12);
}
.sp.is-demo .sp__panel {
animation: panel-open 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sp.is-demo .sp__ghost {
animation: ghost-cut 2s steps(1, end) infinite;
}
@keyframes panel-open {
0%, 42% {
clip-path: inset(0 0 0 0 round 16px);
}
58%, 74% {
clip-path: inset(0 0 100% 0 round 16px);
}
90%, 100% {
clip-path: inset(0 0 0 0 round 16px);
}
}
@keyframes ghost-cut {
0%, 49% {
opacity: 1;
}
50%, 87% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__panel,
.sp.is-demo .sp__ghost {
animation: none !important;
}
.sp__ghost {
opacity: 0;
}
}// 실제 동작: 글자를 치면 결과 패널이 위 모서리를 축으로 펼쳐지고, 지우거나 Escape 를 치면 같은 축으로 접힌다.
var sp = document.querySelector('.sp');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.sp__opt'));
var at = -1;
function open(on) {
sp.classList.remove('is-demo');
sp.classList.toggle('is-open', on);
input.setAttribute('aria-expanded', String(on));
if (!on) { opts.forEach(function (o) { o.classList.remove('is-at'); }); at = -1; }
}
input.addEventListener('input', function () { open(input.value.trim().length > 0); });
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (!sp.classList.contains('is-open')) return;
sp.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; open(false); input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) {
o.addEventListener('mousemove', function () {
sp.classList.remove('is-demo');
at = i; opts.forEach(function (x, k) { x.classList.toggle('is-at', k === i); });
});
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpotlightSearch.module.scss";
export type SpotlightSearchProps = {
placeholder?: string;
results?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
export default function SpotlightSearch({
placeholder = "Search all",
results = ["Weekly report", "Quarterly report", "Report template"],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SpotlightSearchProps) {
const [query, setQuery] = useState("");
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 결과 자리는 늘 잡아 두고 위 모서리부터 잘라 보여준다 — scaleY 로 줄이면 글자까지 눌린다
const open = query.trim().length > 0;
return (
<div className={styles.sp} style={vars} role="dialog" aria-modal="true" aria-label={placeholder}>
<div className={styles.field}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded={open}
aria-controls="sl-list"
aria-activedescendant={at >= 0 ? `sl-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setAt(-1); }}
onKeyDown={(e: KeyEvt) => {
if (!open) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), results.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(results[at]);
} else if (e.key === "Escape") {
setQuery(""); setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={`${styles.panel} ${open ? styles.isOpen : ""}`}>
<span className={styles.sheet} aria-hidden="true" />
<div className={styles.rows} id="sl-list" role="listbox" aria-label="Results">
{results.map((label, i) => (
<div
key={label}
id={`sl-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(label)}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}08 · 画面中央の大きな入力欄 — 計測 累積面積 15.516% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
だから開いても下の内容が押されません。
.sp__panel {
position: relative;
box-sizing: border-box;
height: 84px;
margin: $sp-2 0 0;
clip-path: inset(0 0 100% 0 round 16px);
}
.sp.is-open .sp__panel { clip-path: inset(0 0 0 0 round 16px); }
09下から上がるシート型パレット
どんな動きか
スマホ幅では中央のモーダルの代わりに、シートが下から上がって画面の底に貼り付きます。上の角だけを丸め、いちばん上のつまみのバーは飾りではなく aria-label の付いた本物の閉じるボタンです。

<main class="stage">
<div class="ph is-demo is-open">
<div class="ph__page" aria-hidden="true">
<span class="ph__ln" style="width: 56%"></span>
<span class="ph__ln" style="width: 84%"></span>
<span class="ph__ln" style="width: 72%"></span>
<span class="ph__ln" style="width: 40%"></span>
</div>
<span class="ph__dim" aria-hidden="true"></span>
<div class="ph__sheet" role="dialog" aria-modal="true" aria-label="コマンドパレット">
<button class="ph__grab" id="grab" type="button" aria-label="パレットを閉じる"></button>
<div class="ph__bar">
<span class="ph__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="ph__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="コマンドを検索" placeholder="コマンドを検索" autocomplete="off">
</div>
<div class="ph__list" id="list" role="listbox" aria-label="コマンドパレット">
<div class="ph__opt" id="o1" role="option" aria-selected="false" tabindex="-1">文書を作成</div>
<div class="ph__opt" id="o2" role="option" aria-selected="false" tabindex="-1">メンバーを招待</div>
<div class="ph__opt" id="o3" role="option" aria-selected="false" tabindex="-1">設定を開く</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 폰 화면 한 칸 — 시트는 이 상자 바닥에서 올라온다
.ph {
position: relative;
box-sizing: border-box;
width: 168px; height: 156px;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.ph__page { box-sizing: border-box; padding: $sp-4 $sp-3; }
.ph__ln {
display: block;
height: 6px;
margin: 0 0 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .14);
}
.ph__dim {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, .45);
opacity: 0;
}
.ph.is-open .ph__dim { opacity: 1; }
.ph__sheet {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
padding: $sp-2 $sp-2 calc(#{$sp-3} + env(safe-area-inset-bottom));
border-radius: $r-card $r-card 0 0;
background: #fff;
box-shadow: $shadow-raised;
transform: translateY(100%);
}
.ph.is-open .ph__sheet { transform: translateY(0); }
.ph__grab {
display: block;
width: 32px; height: 4px;
margin: 0 auto $sp-2;
padding: 0;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .2);
cursor: pointer;
}
.ph__grab:focus-visible { outline: 2px solid $color; outline-offset: 4px; }
.ph__bar {
box-sizing: border-box;
height: 28px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.ph__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.ph__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 11px; font-weight: 600;
color: $subject-ink;
}
.ph__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.ph__list { margin: $sp-2 0 0; }
.ph__opt {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 11px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.ph__opt.is-at { background: rgba(47, 109, 246, .12); color: $color; }
// ── 자동 루프: 시트가 바닥으로 내려갔다 같은 길로 다시 올라온다 ──
// 올라올 때와 내려갈 때가 같은 축(아래 끝)이라 한 바퀴 끝에 순간이동하는 프레임이 없다
.ph.is-demo .ph__sheet { animation: sheet-slide $dur-loop $easing infinite; }
.ph.is-demo .ph__dim { animation: dim-fade $dur-loop $easing infinite; }
@keyframes sheet-slide {
0%, 34% { transform: translateY(0); }
56%, 70% { transform: translateY(100%); }
92%, 100% { transform: translateY(0); }
}
@keyframes dim-fade {
0%, 34% { opacity: 1; }
56%, 70% { opacity: 0; }
92%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__sheet,
.ph.is-demo .ph__dim { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ph {
position: relative;
box-sizing: border-box;
width: 168px;
height: 156px;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ph__page {
box-sizing: border-box;
padding: 16px 12px;
}
.ph__ln {
display: block;
height: 6px;
margin: 0 0 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
}
.ph__dim {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.45);
opacity: 0;
}
.ph.is-open .ph__dim {
opacity: 1;
}
.ph__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
padding: 8px 8px calc(12px + env(safe-area-inset-bottom));
border-radius: 16px 16px 0 0;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(100%);
}
.ph.is-open .ph__sheet {
transform: translateY(0);
}
.ph__grab {
display: block;
width: 32px;
height: 4px;
margin: 0 auto 8px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.2);
cursor: pointer;
}
.ph__grab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
}
.ph__bar {
box-sizing: border-box;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.ph__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.ph__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 11px;
font-weight: 600;
color: #17141a;
}
.ph__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.ph__list {
margin: 8px 0 0;
}
.ph__opt {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.ph__opt.is-at {
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
}
.ph.is-demo .ph__sheet {
animation: sheet-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ph.is-demo .ph__dim {
animation: dim-fade 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes sheet-slide {
0%, 34% {
transform: translateY(0);
}
56%, 70% {
transform: translateY(100%);
}
92%, 100% {
transform: translateY(0);
}
}
@keyframes dim-fade {
0%, 34% {
opacity: 1;
}
56%, 70% {
opacity: 0;
}
92%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__sheet,
.ph.is-demo .ph__dim {
animation: none !important;
}
}// 실제 동작: ⌘K(Ctrl+K)로 시트를 올리고 내린다. 손잡이를 누르거나 Escape 를 쳐도 같은 길로 내려간다.
var ph = document.querySelector('.ph');
var input = document.getElementById('q');
var grab = document.getElementById('grab');
var opts = [].slice.call(document.querySelectorAll('.ph__opt'));
var at = -1;
function open(on) {
ph.classList.remove('is-demo');
ph.classList.toggle('is-open', on);
if (on) input.focus({ preventScroll: true });
else { opts.forEach(function (o) { o.classList.remove('is-at'); }); at = -1; }
}
grab.addEventListener('click', function () { open(false); });
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && k === 'k') {
open(!ph.classList.contains('is-open')); e.preventDefault(); return;
}
if (!ph.classList.contains('is-open')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
ph.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), opts.length - 1));
opts.forEach(function (o, i) { o.classList.toggle('is-at', i === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
open(false);
}
});
opts.forEach(function (o, i) {
o.addEventListener('mousemove', function () {
ph.classList.remove('is-demo');
at = i; opts.forEach(function (x, k) { x.classList.toggle('is-at', k === i); });
});
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SheetPalette.module.scss";
export type SheetPaletteProps = {
placeholder?: string;
options?: string[];
closeLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
export default function SheetPalette({
placeholder = "Search commands",
options = ["Create document", "Invite member", "Open settings"],
closeLabel = "Close palette",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SheetPaletteProps) {
const [open, setOpen] = useState(true);
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if (open) inputRef.current?.focus({ preventScroll: true });
else setAt(-1);
}, [open]);
return (
<div className={`${styles.ph} ${open ? styles.isOpen : ""}`} style={vars}>
<div className={styles.page} aria-hidden="true">
{[56, 84, 72, 40].map((w) => <span className={styles.ln} style={{ width: `${w}%` }} key={w} />)}
</div>
<span className={styles.dim} aria-hidden="true" />
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label="Command palette">
{/* 손잡이 막대는 장식이 아니라 닫는 버튼이다 — 터치에서 시트를 내리는 유일한 길 */}
<button className={styles.grab} type="button" aria-label={closeLabel} onClick={() => setOpen(false)} />
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded={open}
aria-controls="ms-list"
aria-activedescendant={at >= 0 ? `ms-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), options.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(options[at]);
} else if (e.key === "Escape") {
setOpen(false);
}
}}
/>
</div>
<div className={styles.list} id="ms-list" role="listbox" aria-label="Command palette">
{options.map((label, i) => (
<div
key={label}
id={`ms-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(label)}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}09 · 下から上がるシート型パレット — 計測 累積面積 18.065% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
下のパディングに env(safe-area-inset-bottom) を足してあるので、ノッチのある画面でも最後の行がホームインジケータに掛かりません。
.ph__sheet {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
padding: $sp-2 $sp-2 calc(#{$sp-3} + env(safe-area-inset-bottom));
border-radius: $r-card $r-card 0 0;
background: #fff;
box-shadow: $shadow-raised;
transform: translateY(100%);
}
.ph.is-open .ph__sheet { transform: translateY(0); }
どこで壊れるか — 落とし穴
この回でいちばん手こずったのはアニメーションではなく、代表画像として切り出される一枚でした。ここで使っているレンダリングツールは2秒のループを24枚に割り、最初の一枚といちばん違う一枚を代表画像に選びます。06 番はカーソルが行と行の間を通っている最中に右のパネルが変わるよう組んであり、よりによってその入れ替えがいちばん大きな変化だったため、カーソルは2行目・パネルは3行目の中身という場面が代表画像に選ばれました。直し方は2つでした。まずカーソルが行に着いて止まってからパネルが変わるように時点をずらし、プレビューごとに背景色まで変えて、入れ替えが確実にいちばん大きな変化になるようにしました。ここでもう1つ引っかかったのですが、24枚の最後の一枚が2秒の 95.8% の地点なので、96% に置いていた「最初へ戻る入れ替え」はそもそも撮影されていませんでした。戻す時点を 90% まで前倒しして、ようやく一周が途切れなくなりました。
2つ目の落とし穴は入力欄の文字が二重になることです。02・07・08 番は自動再生で「人が打っている文字」を見せるため入力欄の上に文字を一枚重ねたのですが、その下の placeholder がそのまま残り、二つの文字が重なって読めない形になりました。重なって単語にならない欄が、そのまま代表画像になっていました。自動再生の間だけ ::placeholder を透明にすれば済む話でしたが、撮ったものを開いて見るまでどのゲートもこれを捕まえてくれませんでした。
3つ目はスマホ幅です。01 番は入力欄1つに見出し2つ、行4つで縦が 186px になりましたが、320×200 の画面では舞台の余白を引くと使える高さは 174px です。12px 足りず、スマホでは最後の行が切れていました。行の高さを 24px から 22px へ、見出しは line-height を 12px に固定し、カードの縦のパディングだけを 8px から 4px に減らして 164px に収めました。横のパディングはそのままなので、広い画面での印象は変わっていません。
4つ目は 08 番から出ました。結果パネルを閉じるとき最初は scaleY(0) を使ったのですが、上端を軸に縮めると中の文字まで一緒に潰れて平たくなります。半分ほど閉じた瞬間の文字が縦に潰れたまま残り、閉じる動作ではなく画面が壊れたように見えました。clip-path: inset() は要素を描いたうえで切り取るだけなので、文字の比率がそのまま残ります。代わりに overflow: hidden と違って丸い角を別に書く必要があり、そのために round 16px を付けています。インストール命令をコピーさせる箱も同じ置き場所の問題を抱え、ターミナル風 UIがその面をまるごと扱います。この4つを反映したソースは、解凍パスワード vswx5meq で開く zip の中に9つ分そろえてあります。
アクセシビリティ
9つとも、フォーカスは入力欄に置いたまま動かします。矢印キーを押しても focus() が行へ移ることはなく、今指している行は入力欄の aria-activedescendant が変わることで伝わります。こうする理由は入力欄が文字を受け取り続ける必要があるからで、この組み立ての定義と要件は MDN の combobox ロールの資料にまとまっています。オーバーレイ自体には role="dialog" と aria-modal="true" が付き、後ろのページではなくこの箱が今の文脈だと伝えます。
| キー | 9つでの働き |
|---|---|
| ⌘K・Ctrl+K | パレットの開閉(01・09)。metaKey と ctrlKey を両方見れば Mac と Windows を一度に受けられる |
| ↑ ↓ | 行の移動。03 ではここで scrollIntoView({ block: 'nearest' }) も一緒に回る |
| Enter | 立っている行の実行。aria-selected がその行へ移る |
| Escape | 閉じる、または入力を空にする。フォーカスは focus({ preventScroll: true }) で戻す |
| Backspace | 05 だけ特別で、入力欄が空なら文字ではなく一段を消す |
prefers-reduced-motion: reduce では自動再生のループだけを止め、今の状態はそのまま残します。05 のチップは滑らずにその場に立ち、08 のパネルは切り取られる過程なしに開いた状態で現れ、09 のシートは上がる道なく貼り付いたままです。どの行が選ばれているかは、動きを全部止めても aria-selected が伝え続けます。フォーカスを戻すときに preventScroll を付けるのは、これらのデモが記事の中に iframe として入っていて、素の focus() を呼ぶと読んでいる人の画面がデモの方へ引っ張られるからです。
同じ画面で一緒に使う部品はダッシュボードのカテゴリに、押して開く部品はクリックのカテゴリにまとめてあります。
FAQ
コマンドパレット ショートカットの検索欄1つになぜ role="dialog" まで付けるのですか
浮かんだ箱が後ろのページを覆った時点で、それは入力欄ではなく1つの画面になるからです。role="dialog" と aria-modal="true" がないと、スクリーンリーダーは後ろのリンクやボタンをまだ使えるものとして案内し続け、使う人は自分がどこにいるか分からなくなります。逆に画面を覆わずページの中にそのまま座るだけの検索欄なら dialog は要らず、combobox だけで足ります。パレットのキー操作全体を案内する窓はショートカットキー 表示 UIがキーキャップ表現から解説しています。
部分列の検索は部分文字列より常に優れていますか
リストが短いときはむしろ不利です。飛ばして当てる方式は通る行が増えるので、選択肢が10個ほどより少ないと的外れな行が上に来ることが多くなります。02 番のように使うなら当たった箇所の数と間隔で点を付けて並べ替えまでした方がよく、この回の 02 番は並べ替えなしで元の順だけを守る最小の実装です。
9つを同じ画面に一緒に置いてもいいですか
ショートカットが重なっていないかだけ見てください。01 番と 09 番が同じ ⌘K を聞いているので、そのまま2つ載せると一度押しただけで両方開きます。zip の中では各デモが単体で動くようファイルが分かれているので、実際の画面に載せるときはショートカットを聞く場所を1つにまとめ、残りはその1つが開く形に変えるのが安全です。








