Command Palette UI Design — 9 That Really Open
Command palette UI design is the pattern behind every Cmd+K overlay: one floating field that searches commands, documents, and screens at once.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Recent items on open
- 02 Fuzzy match highlight
- 03 Scroll that follows the cursor
- 04 Right-aligned shortcut badges
- 05 Breadcrumb into a submenu
- 06 Preview pane on the right
- 07 Empty state that still suggests
- 08 Spotlight-style center field
- 09 Bottom sheet palette
The nine are ordered by when a person meets them, not by popularity. What the palette shows before a single key is pressed comes first (01), then how it matches once typing starts (02). A list taller than its box has to let the scroll chase the cursor (03), and the right edge of each row carries the shortcut so next time nobody needs the palette at all (04). When one command is not the whole job it goes a level deeper (05), and when a name is not enough to choose by, a preview rides alongside (06). Number 07 is the missed search in this search overlay ui design, and 08 and 09 are the same palette rebuilt for a different screen. All nine carry role="dialog" and role="listbox", and arrows, Enter, and Escape are wired for real. The same dashboard wrapping numbers in rings is SVG Progress Ring Widgets, and the hands-on feel over the graphs continues in Chart UI Design.
01Recent items on open
What you see in this command palette design
The palette opens with nothing typed yet, and the commands you last used already stand under the Recent and Go to headings. Each cluster is wrapped in role="group" with aria-labelledby, so a screen reader announces the heading as the name of that run of rows.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="Command palette">
<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="Search commands" placeholder="Search commands" autocomplete="off">
<kbd class="pal__kbd">⌘K</kbd>
</div>
<div class="pal__list" id="list" role="listbox" aria-label="Command palette">
<div class="pal__grp" role="group" aria-labelledby="g1">
<p class="pal__head" id="g1">Recent</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">Weekly report draft</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">Budget sheet</span>
</div>
</div>
<div class="pal__grp" role="group" aria-labelledby="g2">
<p class="pal__head" id="g2">Go to</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">Dashboard</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">Settings</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)로 열고 닫기, ↑↓ 로 줄 Go to, 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 · Recent items on open — Measured area 2.486% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The open shortcut watches metaKey and ctrlKey together, because a Mac sends one and Windows sends the other.
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');
}
});
02Fuzzy match highlight
What happens on screen
Skipping letters still finds the row as long as the order holds. The test is a subsequence rather than a substring, so each character of the query is located with indexOf(c, from) moving strictly forward, and a single miss drops the row.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="Command palette">
<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="Search commands" placeholder="Search commands" autocomplete="off">
<span class="pal__ghost" aria-hidden="true">no</span>
</div>
<div class="pal__box">
<div class="pal__list" id="list" role="listbox" aria-label="Command palette">
<div class="pal__opt pal__opt--out" id="o1" role="option" aria-selected="false" tabindex="-1"><span class="pal__name">Add comment</span></div>
<div class="pal__opt pal__opt--out" id="o2" role="option" aria-selected="false" tabindex="-1"><span class="pal__name">Open settings</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">N</mark> ew d<mark class="pal__m">o</mark> cument</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">N</mark> ew wind<mark class="pal__m">o</mark> w</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 · Fuzzy match highlight — Measured area 4.095% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Wrapping one character at a time in <mark> instead of one continuous run is what makes the skipping visible.
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);
}
03Scroll that follows the cursor
What moves
Seven rows live in a box that shows four, and the list starts sliding only once the highlight reaches the bottom edge. The whole behavior is scrollIntoView with block: 'nearest', which does nothing at all when the row is already visible, so the view never jerks on the first three presses.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="Command palette">
<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="Search commands" placeholder="Search commands" 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="Command palette">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">Create document</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">Create folder</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">Invite member</div>
<div class="pal__opt" id="o4" role="option" aria-selected="false" tabindex="-1">Mute notifications</div>
<div class="pal__opt" id="o5" role="option" aria-selected="false" tabindex="-1">Change theme</div>
<div class="pal__opt" id="o6" role="option" aria-selected="false" tabindex="-1">Export</div>
<div class="pal__opt" id="o7" role="option" aria-selected="false" tabindex="-1">Sign out</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 · Scroll that follows the cursor — Measured area 16.5% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The Home and End keys land on the first and last row in one press.
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);
// with block: 'nearest', a row already in view causes no scroll at all
opts[at].scrollIntoView({ block: 'nearest' });
}
04Right-aligned shortcut badges
What you see
Whether the command name is one word or four, the keycaps line up at the right edge. The alignment comes from margin-left: auto on the badge cluster rather than a flex value on the label, so the leftover width is eaten in one piece.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="Command palette">
<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="Search commands" placeholder="Search commands" 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="Command palette">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">New document</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">Search</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">Save</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">Open settings</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 · Right-aligned shortcut badges — Measured area 15.966% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Typing the printed combination actually runs that row, which is what keeps the cheat sheet from being a picture.
// the badge stays flush right no matter how long the name is — the auto margin eats all the leftover space
.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);
}
05Breadcrumb into a submenu
What happens on screen
Choosing the first row grows a pill in front of the field and swaps the list for that command's targets. The way back is not a back button but Backspace on an empty field, so the hand never leaves the input.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="Command palette">
<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>
Invite to team
</span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list-a" aria-label="Search commands" placeholder="Search commands" 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="Command palette">
<div class="pal__opt" id="a1" role="option" aria-selected="false" tabindex="-1">Invite to team</div>
<div class="pal__opt" id="a2" role="option" aria-selected="false" tabindex="-1">Share document</div>
<div class="pal__opt" id="a3" role="option" aria-selected="false" tabindex="-1">Copy link</div>
<div class="pal__opt" id="a4" role="option" aria-selected="false" tabindex="-1">Move to archive</div>
</div>
<div class="pal__list pal__list--b" id="list-b" role="listbox" aria-label="Invite to team" hidden>
<div class="pal__opt" id="b1" role="option" aria-selected="false" tabindex="-1">Design team</div>
<div class="pal__opt" id="b2" role="option" aria-selected="false" tabindex="-1">Engineering team</div>
<div class="pal__opt" id="b3" role="option" aria-selected="false" tabindex="-1">Marketing team</div>
<div class="pal__opt" id="b4" role="option" aria-selected="false" tabindex="-1">Operations team</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 ? 'Invite to team' : '';
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 · Breadcrumb into a submenu — Measured area 2.706% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
A visually hidden aria-live="polite" paragraph says which level is current.
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 });
}
});
06Preview pane on the right
What moves
Whatever row the cursor stands on appears in the right half at once, and every move replaces the pane's entire contents. One line of grid-template-columns: 1fr 104px splits the list from the preview, and aria-describedby on the field ties the two together for screen readers.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="Command palette">
<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="Search documents" placeholder="Search documents" 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="Document list">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">Weekly report</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">Budget sheet</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">Meeting notes</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">Weekly report</p>
<span class="pal__ln" style="width: 90%"></span>
<span class="pal__ln" style="width: 64%"></span>
<span class="pal__tag">Report</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">Budget sheet</p>
<span class="pal__ln" style="width: 56%"></span>
<span class="pal__ln" style="width: 88%"></span>
<span class="pal__tag">Budget</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">Meeting notes</p>
<span class="pal__ln" style="width: 72%"></span>
<span class="pal__ln" style="width: 50%"></span>
<span class="pal__tag">Meeting</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 · Preview pane on the right — Measured area 13.172% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The swap happens in a single frame instead of fading between the two.
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);
}
07Empty state that still suggests
What you see
When nothing matches, the palette does not stop at a single notice line: two rows carrying the text you just typed rise in its place. Those rows also carry role="option", so arrows and Enter keep working on them.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="Command palette">
<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="Search commands" placeholder="Search commands" autocomplete="off">
<span class="pal__ghost" aria-hidden="true">Monthly report</span>
</div>
<div class="pal__box">
<div class="pal__list" id="list" role="listbox" aria-label="Command palette">
<div class="pal__opt pal__opt--out" id="o1" role="option" aria-selected="false" tabindex="-1">Create document</div>
<div class="pal__opt pal__opt--out" id="o2" role="option" aria-selected="false" tabindex="-1">Invite member</div>
<div class="pal__opt pal__opt--out" id="o3" role="option" aria-selected="false" tabindex="-1">Open settings</div>
<div class="pal__empty" id="empty" hidden>
<p class="pal__note">No results</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">Create new</span><span class="pal__q">Monthly report</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">Search all</span><span class="pal__q">Monthly report</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 · Empty state that still suggests — Measured area 4.858% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
A notice and a choice are different objects here.
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);
// echo the just-typed text into the suggestion row — "by that name" is what this state is worth
document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
at = -1;
}
08Spotlight-style center field
What happens on screen
At first only one large field floats there, and the results unfold downward the moment a character lands. The value being animated is neither height nor scaleY but the bottom inset of a clip-path, and the panel's 84px height is reserved even while it stays closed.

<main class="stage">
<div class="sp is-demo is-open" role="dialog" aria-modal="true" aria-label="Search all">
<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="Search all" placeholder="Search all" autocomplete="off">
<span class="sp__ghost" aria-hidden="true">report</span>
</div>
<div class="sp__panel">
<span class="sp__sheet" aria-hidden="true"></span>
<div class="sp__rows" id="list" role="listbox" aria-label="Results">
<div class="sp__opt" id="o1" role="option" aria-selected="false" tabindex="-1">Weekly report</div>
<div class="sp__opt" id="o2" role="option" aria-selected="false" tabindex="-1">Quarterly report</div>
<div class="sp__opt" id="o3" role="option" aria-selected="false" tabindex="-1">Report template</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 · Spotlight-style center field — Measured area 15.516% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Nothing below it shifts when it opens.
.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); }
09Bottom sheet palette
What moves
On a phone the centered modal gives way to a sheet that rises and sticks to the bottom edge. Only the top corners are rounded, and the grabber bar at the top is a real close button with an aria-label rather than a decoration.

<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="Command palette">
<button class="ph__grab" id="grab" type="button" aria-label="Close palette"></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="Search commands" placeholder="Search commands" autocomplete="off">
</div>
<div class="ph__list" id="list" role="listbox" aria-label="Command palette">
<div class="ph__opt" id="o1" role="option" aria-selected="false" tabindex="-1">Create document</div>
<div class="ph__opt" id="o2" role="option" aria-selected="false" tabindex="-1">Invite member</div>
<div class="ph__opt" id="o3" role="option" aria-selected="false" tabindex="-1">Open settings</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 · Bottom sheet palette — Measured area 18.065% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The bottom padding adds env(safe-area-inset-bottom), so the last row sits clear of the home indicator on a notched screen.
.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); }
Where it breaks — the trap
The longest fight in this set was not an animation but the single frame that gets cut out as the cover image. The renderer here slices a two-second loop into 24 stills and picks the one that differs most from the first as the poster. Number 06 was built so the right-hand pane switched while the cursor was traveling between rows, and that switch happened to be the largest change of all, so the frame chosen showed the cursor on row two and the pane on row three. Two things fixed it. The pane now changes only after the cursor has landed and stopped, and each preview got its own background tint so the swap is unmistakably the biggest change in the loop. A second problem surfaced right there: the last of the 24 stills sits at 95.8% of two seconds, so the return swap parked at 96% was never photographed at all. Only after pulling that return forward to 90% did one lap close cleanly.
The second trap is two layers of text in one field. Items 02, 07, and 08 lay a ghost string over the input to show what a person is typing, while the placeholder underneath stayed exactly where it was, so the two strings overlapped into shapes that are not words. That unreadable field went straight into a cover image before anyone looked. Turning ::placeholder transparent for the duration of the autoplay loop is the whole fix, but no gate caught it — only opening the capture did.
The third is phone width. Item 01 stacks one field, two headings, and four rows, which came out 186px tall, while a 320×200 screen leaves 174px once the stage padding is removed. Twelve pixels short meant the last row was clipped on a phone. Rows went from 24px to 22px, headings got a fixed 12px line-height, and only the card's vertical padding dropped from 8px to 4px, landing at 164px. Horizontal padding was left alone, so nothing about the wide-screen impression changed. Boxes meant for copying install commands hit the same placement problem, and Terminal UI Design takes that panel on wholesale. The sources with all three corrections are inside the zip that opens with the archive password vswx5meq, all nine of them.
The fourth came from item 08. Folding the results panel with scaleY(0) seemed obvious, but shrinking toward the top edge squashes the text inside it as well. Half-folded frames held letters stretched flat, which read as a broken screen rather than a closing panel. clip-path: inset() paints the element first and only then cuts it away, so proportions survive; the price is that rounded corners have to be restated, which is what round 16px is doing there.
Accessibility (reduced-motion)
In all nine the focus stays parked on the input. Arrow keys never call focus() on a row; the current row is announced because aria-activedescendant on the field changes instead. The reason is that the field has to keep receiving characters, and the requirements for this arrangement are written up in the MDN combobox role reference. The overlay itself carries role="dialog" with aria-modal="true" so the box, not the page behind it, is announced as the current context.
| Key | What the nine do with it |
|---|---|
| ⌘K · Ctrl+K | Open and close the palette (01, 09). Reading metaKey and ctrlKey together covers Mac and Windows in one branch |
| ↑ ↓ | Move between rows. In 03 this also runs scrollIntoView({ block: 'nearest' }) |
| Enter | Run the standing row; aria-selected moves onto it |
| Escape | Close, or clear the field. Focus returns through focus({ preventScroll: true }) |
| Backspace | Special only in 05 — on an empty field it deletes a level, not a character |
Under prefers-reduced-motion: reduce only the autoplay loop stops; the current state is left untouched. The pill in 05 arrives in place instead of sliding, the panel in 08 appears already unfolded with no cut-away, and the sheet in 09 sits attached without traveling up. Which row is chosen is still spoken by aria-selected even with every movement switched off. The reason preventScroll is attached to every focus call is that these demos sit inside the article as iframes, and a bare focus() drags the reader's viewport down to the demo.
Parts that share a screen with this one are collected under the dashboard category, and parts that open on a press are under the click category.
FAQ
Why does a search bar design css need role="dialog" at all?
Because the moment a floating box covers the page behind it, it stops being a field and becomes a screen. Without role="dialog" and aria-modal="true" a screen reader keeps offering the links and buttons underneath, and the person using it cannot tell where they are. The opposite also holds: an inline search box that never covers the page needs no dialog and works fine as a combobox alone. The sheet that documents every palette shortcut is worked out, keycaps included, in Keyboard Shortcuts UI Design.
Is subsequence matching always better than substring matching?
It is worse on short lists. Matching with gaps lets many more rows through, so with fewer than about ten options an irrelevant row often lands on top. To use it the way 02 does, score each result by how many gaps it needed and sort on that score; the version here is the minimum implementation, with the original order kept and no ranking.
Can all nine go on the same page together?
Only after checking the shortcuts. Items 01 and 09 both listen for ⌘K, so dropping the pair in as they are opens both on a single press. Inside the zip each demo is a self-contained file, so the safe move on a real page is to keep one listener for the shortcut and let that one open whichever palette the screen calls for.








