css 검색창 디자인 9종 — Cmd+K로 열리는 팔레트
css 검색창 디자인 가운데 커맨드 팔레트는 화면 위에 떠서 명령과 문서를 한 칸에서 찾게 하는 오버레이입니다. 여기 아홉 개 중 하나는 결과가 하나도 없을 때 오히려 줄이 늘어나고, 다른 하나는 목록 없이 입력칸만 먼저 띄웁니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 열자마자 뜨는 최근 항목
- 02 띄엄띄엄 쳐도 걸리는 검색
- 03 커서를 따라가는 스크롤
- 04 오른쪽 끝에 줄 맞춘 단축키 배지
- 05 한 단계 들어가는 브레드크럼
- 06 오른쪽에 붙는 미리보기 패널
- 07 빈손으로 끝나지 않는 결과 없음
- 08 화면 한가운데 큰 입력칸
- 09 아래에서 올라오는 시트형 팔레트
아홉 개의 순서는 인기순이 아니라 사용자가 팔레트를 쓰는 시간 순서입니다. 단축키로 열자마자 아직 아무것도 안 쳤을 때 무엇을 보여 줄지(01)가 먼저고, 그다음이 치기 시작했을 때의 매칭 방식(02)입니다. 목록이 상자보다 길어지면 스크롤이 커서를 따라가야 하고(03), 줄 오른쪽 끝에는 다음번엔 팔레트 없이 검색창 단축키만으로 쓰라고 배지가 붙습니다(04). 명령 하나로 안 끝나면 한 단계 들어가고(05), 이름만으로 못 고르면 미리보기가 붙습니다(06). 07은 검색이 빗나갔을 때, 08과 09는 같은 팔레트를 다른 화면 크기에 맞춰 다시 배치한 경우입니다. 아홉 개 전부 role="dialog"와 role="listbox"를 달고 화살표·Enter·Escape가 실제로 듣습니다. 같은 대시보드 안에서 숫자를 원으로 감싸 보여 주는 쪽은 원형 프로그레스바 스탯 링 위젯에 있고, 그래프 위에서의 손맛은 대시보드 차트 인터랙션이 잇습니다. 01번의 최근 항목 목록은 이 검색 매칭이 쌓인 결과라서 거꾸로 읽어도 연결됩니다. 팔레트가 처음부터 완성된 게 아니라 01번의 최근 항목이 먼저 자리를 잡고 그 위에 02번의 느슨한 매칭이 얹히고 03번의 커서 추적이 마지막에 붙는 순서로 자랐다는 점도 이 순서를 그대로 따르며, 매칭 가중치는 결과 상단에 둘수록 점수를 높게 주는 규칙 두 줄로 끝냈습니다.
01열자마자 뜨는 최근 항목
검색창 ui 디자인에서 무엇이 보이나
단축키로 열면 아직 한 글자도 치지 않았는데 「최근」과 「이동」 머리글 아래 지난번에 쓴 명령이 먼저 서 있습니다. 묶음은 role="group"과 aria-labelledby로 묶어 스크린 리더도 머리글을 줄 목록의 이름으로 읽습니다.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="명령 검색" placeholder="명령 검색" autocomplete="off">
<kbd class="pal__kbd">⌘K</kbd>
</div>
<div class="pal__list" id="list" role="listbox" aria-label="명령 팔레트">
<div class="pal__grp" role="group" aria-labelledby="g1">
<p class="pal__head" id="g1">최근</p>
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M136 80v43.47l36.12 21.67a8 8 0 0 1-8.24 13.72l-40-24A8 8 0 0 1 120 128V80a8 8 0 0 1 16 0m-8-48a95.44 95.44 0 0 0-67.92 28.15C52.81 67.51 46.35 74.59 40 82V64a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16H49c7.15-8.42 14.27-16.35 22.39-24.57a80 80 0 1 1 1.66 114.75a8 8 0 1 0-11 11.64A96 96 0 1 0 128 32"/></g></svg></span>
<span class="pal__name">주간 보고서 초안</span>
</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM200 216H56V40h88v48a8 8 0 0 0 8 8h48zm-32-80a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m0 32a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg></span>
<span class="pal__name">예산 시트</span>
</div>
</div>
<div class="pal__grp" role="group" aria-labelledby="g2">
<p class="pal__head" id="g2">이동</p>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg></span>
<span class="pal__name">대시보드</span>
</div>
<div class="pal__opt" id="o4" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 168a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M128 80a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32m109.94-52.79a8 8 0 0 0-3.89-5.4l-29.83-17l-.12-33.62a8 8 0 0 0-2.83-6.08a111.9 111.9 0 0 0-36.72-20.67a8 8 0 0 0-6.46.59L128 41.85L97.88 25a8 8 0 0 0-6.47-.6a111.9 111.9 0 0 0-36.68 20.75a8 8 0 0 0-2.83 6.07l-.15 33.65l-29.83 17a8 8 0 0 0-3.89 5.4a106.5 106.5 0 0 0 0 41.56a8 8 0 0 0 3.89 5.4l29.83 17l.12 33.63a8 8 0 0 0 2.83 6.08a111.9 111.9 0 0 0 36.72 20.67a8 8 0 0 0 6.46-.59L128 214.15L158.12 231a7.9 7.9 0 0 0 3.9 1a8.1 8.1 0 0 0 2.57-.42a112.1 112.1 0 0 0 36.68-20.73a8 8 0 0 0 2.83-6.07l.15-33.65l29.83-17a8 8 0 0 0 3.89-5.4a106.5 106.5 0 0 0-.03-41.52"/></g></svg></span>
<span class="pal__name">설정</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-1 $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal.is-shut { display: none; }
.pal__bar {
box-sizing: border-box;
height: 28px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.pal__kbd {
flex: 0 0 auto;
height: 14px;
display: inline-flex; align-items: center;
padding: 0 $sp-1;
border-radius: $r-xs;
background: #fff;
box-shadow: 0 1px 0 rgba(23, 20, 26, .22);
font-family: inherit;
font-size: 9px; font-weight: 800;
color: rgba(23, 20, 26, .5);
}
.pal__list { margin: $sp-1 0 0; }
.pal__grp + .pal__grp { margin-top: $sp-1; }
.pal__head {
margin: 0 0 $sp-1;
padding: 0 $sp-2;
font-size: 9px; line-height: 12px; font-weight: 800;
letter-spacing: .08em;
color: rgba(23, 20, 26, .72);
}
.pal__opt {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
cursor: pointer;
}
.pal__ic { display: inline-flex; color: rgba(23, 20, 26, .5); }
.pal__name {
overflow: hidden;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap; text-overflow: ellipsis;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .12); }
.pal__opt.is-at .pal__ic,
.pal__opt.is-at .pal__name { color: $color; }
.pal__opt.is-run { background: rgba(47, 109, 246, .22); }
// ── 자동 루프: 네 줄이 순서대로 떨어졌다가 같은 순서로 다시 올라온다 (열릴 때의 계단 등장) ──
// 0% 와 100% 가 같은 「다 보이는」 상태라 한 바퀴 끝에 튀는 자리가 없다
.pal.is-demo .pal__opt { animation: opt-cascade $dur-loop $easing infinite; }
.pal.is-demo #o2 { animation-delay: 60ms; }
.pal.is-demo #o3 { animation-delay: 120ms; }
.pal.is-demo #o4 { animation-delay: 180ms; }
@keyframes opt-cascade {
0%, 10% { opacity: 1; transform: translateY(0); }
30%, 38% { opacity: 0; transform: translateY(10px); }
64%, 100% { opacity: 1; transform: translateY(0); }
}
.pal.is-demo .pal__head { animation: head-dim $dur-loop $easing infinite; }
@keyframes head-dim {
0%, 10% { opacity: 1; }
30%, 38% { opacity: .25; }
64%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__opt,
.pal.is-demo .pal__head { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 4px 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal.is-shut {
display: none;
}
.pal__bar {
box-sizing: border-box;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__kbd {
flex: 0 0 auto;
height: 14px;
display: inline-flex;
align-items: center;
padding: 0 4px;
border-radius: 4px;
background: #fff;
box-shadow: 0 1px 0 rgba(23, 20, 26, 0.22);
font-family: inherit;
font-size: 9px;
font-weight: 800;
color: rgba(23, 20, 26, 0.5);
}
.pal__list {
margin: 4px 0 0;
}
.pal__grp + .pal__grp {
margin-top: 4px;
}
.pal__head {
margin: 0 0 4px;
padding: 0 8px;
font-size: 9px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.08em;
color: rgba(23, 20, 26, 0.72);
}
.pal__opt {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
cursor: pointer;
}
.pal__ic {
display: inline-flex;
color: rgba(23, 20, 26, 0.5);
}
.pal__name {
overflow: hidden;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
text-overflow: ellipsis;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.12);
}
.pal__opt.is-at .pal__ic,
.pal__opt.is-at .pal__name {
color: #2f6df6;
}
.pal__opt.is-run {
background: rgba(47, 109, 246, 0.22);
}
.pal.is-demo .pal__opt {
animation: opt-cascade 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pal.is-demo #o2 {
animation-delay: 60ms;
}
.pal.is-demo #o3 {
animation-delay: 120ms;
}
.pal.is-demo #o4 {
animation-delay: 180ms;
}
@keyframes opt-cascade {
0%, 10% {
opacity: 1;
transform: translateY(0);
}
30%, 38% {
opacity: 0;
transform: translateY(10px);
}
64%, 100% {
opacity: 1;
transform: translateY(0);
}
}
.pal.is-demo .pal__head {
animation: head-dim 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes head-dim {
0%, 10% {
opacity: 1;
}
30%, 38% {
opacity: 0.25;
}
64%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__opt,
.pal.is-demo .pal__head {
animation: none !important;
}
}// 실제 동작: ⌘K(Ctrl+K)로 열고 닫기, ↑↓ 로 줄 이동, Enter 로 실행, Escape 로 닫기.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var at = -1;
function mark(i) {
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
function live() { pal.classList.remove('is-demo'); }
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && k === 'k') {
live();
pal.classList.toggle('is-shut');
if (!pal.classList.contains('is-shut')) input.focus({ preventScroll: true });
e.preventDefault();
return;
}
if (pal.classList.contains('is-shut')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
live(); mark(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
live();
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
opts[at].classList.add('is-run');
e.preventDefault();
} else if (e.key === 'Escape') {
live(); pal.classList.add('is-shut');
}
});
opts.forEach(function (o, i) {
o.addEventListener('mousemove', function () { live(); mark(i); });
o.addEventListener('click', function () { live(); mark(i); o.classList.add('is-run'); });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RecentActionsPalette.module.scss";
export type PaletteGroup = { title: string; items: string[] };
export type RecentActionsPaletteProps = {
placeholder?: string;
groups?: PaletteGroup[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; metaKey: boolean; ctrlKey: boolean; preventDefault: () => void };
const DEFAULT_GROUPS: PaletteGroup[] = [
{ title: "Recent", items: ["Weekly report draft", "Budget sheet"] },
{ title: "Go to", items: ["Dashboard", "Settings"] },
];
export default function RecentActionsPalette({
placeholder = "Search commands",
groups = DEFAULT_GROUPS,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: RecentActionsPaletteProps) {
const [open, setOpen] = useState(true);
const [at, setAt] = useState(-1);
const [ran, setRan] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const flat = groups.flatMap((g, gi) => g.items.map((label, ii) => ({ label, id: `cmdk-${gi}-${ii}` })));
// ⌘K / Ctrl+K 는 문서 전체에서 듣는다 — 팔레트가 닫혀 있을 때도 열려야 하기 때문
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
if (!open) return null;
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="cmdk-list"
aria-activedescendant={at >= 0 ? flat[at].id : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), flat.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setRan(at);
} else if (e.key === "Escape") {
setOpen(false);
}
}}
/>
<kbd className={styles.kbd}>⌘K</kbd>
</div>
<div className={styles.list} id="cmdk-list" role="listbox" aria-label="Command palette">
{groups.map((g, gi) => (
<div className={styles.grp} key={g.title} role="group" aria-labelledby={`cmdk-h-${gi}`}>
<p className={styles.head} id={`cmdk-h-${gi}`}>{g.title}</p>
{g.items.map((label) => {
const i = flat.findIndex((f) => f.label === label);
return (
<div
key={label}
id={flat[i].id}
className={`${styles.opt} ${i === at ? styles.isAt : ""} ${i === ran ? styles.isRun : ""}`}
role="option"
aria-selected={i === ran}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => { setAt(i); setRan(i); }}
>
<span className={styles.name}>{label}</span>
</div>
);
})}
</div>
))}
</div>
</div>
);
}01 · 열자마자 뜨는 최근 항목 — 측정 누적면적 2.486% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
여닫는 단축키는 맥의 ⌘와 윈도의 Ctrl을 둘 다 봅니다.
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && k === 'k') {
live();
pal.classList.toggle('is-shut');
if (!pal.classList.contains('is-shut')) input.focus({ preventScroll: true });
e.preventDefault();
return;
}
if (pal.classList.contains('is-shut')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
live(); mark(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
live();
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
opts[at].classList.add('is-run');
e.preventDefault();
} else if (e.key === 'Escape') {
live(); pal.classList.add('is-shut');
}
});
02띄엄띄엄 쳐도 걸리는 검색
화면에서 일어나는 일
글자를 이어서 치지 않고 건너뛰어 쳐도 순서만 맞으면 걸립니다. 찾는 방식이 부분 문자열이 아니라 부분 수열이라서, 질의의 글자를 하나씩 indexOf(c, from)으로 뒤로만 밀어 가며 찾고 하나라도 못 찾으면 그 줄을 뺍니다.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="명령 검색" placeholder="명령 검색" autocomplete="off">
<span class="pal__ghost" aria-hidden="true">새서</span>
</div>
<div class="pal__box">
<div class="pal__list" id="list" role="listbox" aria-label="명령 팔레트">
<div class="pal__opt pal__opt--out" id="o1" role="option" aria-selected="false" tabindex="-1"><span class="pal__name">댓글 달기</span></div>
<div class="pal__opt pal__opt--out" id="o2" role="option" aria-selected="false" tabindex="-1"><span class="pal__name">설정 열기</span></div>
<div class="pal__opt pal__opt--hit" id="o3" role="option" aria-selected="false" tabindex="-1"><span class="pal__name"><mark class="pal__m">새</mark> 문<mark class="pal__m">서</mark> 만들기</span></div>
<div class="pal__opt pal__opt--hit" id="o4" role="option" aria-selected="false" tabindex="-1"><span class="pal__name"><mark class="pal__m">새</mark> 창에<mark class="pal__m">서</mark> 열기</span></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
position: relative;
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
// 자동 루프는 친 글자를 고스트로 겹쳐 보여주므로, 루프 동안에는 placeholder 를 비운다 (두 글자가 겹쳐 읽혔다)
.pal.is-demo .pal__input::placeholder { color: transparent; }
.pal__ghost {
position: absolute;
left: 32px; top: 50%;
transform: translateY(-50%);
font-size: 12px; font-weight: 700;
color: $subject-ink;
opacity: 0;
pointer-events: none;
}
.pal__box {
box-sizing: border-box;
height: 100px;
margin: $sp-2 0 0;
overflow: hidden;
}
.pal__list { padding: 0; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .12); }
.pal__name {
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
}
.pal__m {
position: relative;
z-index: 0;
background: transparent;
color: inherit;
font-weight: 800;
}
.pal__m::before {
content: "";
position: absolute;
inset: -2px;
z-index: -1;
border-radius: $r-xs;
background: rgba(255, 210, 63, .9);
opacity: 0;
}
.pal__m.is-on::before { opacity: 1; }
// ── 자동 루프: 「새서」를 치면 안 맞는 두 줄이 한 프레임에 사라지고, 맞은 두 줄이 그 자리로 올라온다 ──
// 사라짐과 올라옴이 같은 자리를 지나므로 사라짐은 steps(1) 로 먼저 끊고 그다음 미끄러진다 (겹쳐 비치는 프레임 0)
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__m::before { animation: cut-in $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__opt--out { animation: cut-out $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__opt--hit { animation: rise $dur-loop $easing infinite; }
@keyframes cut-in {
0%, 17% { opacity: 0; }
18%, 82% { opacity: 1; }
83%, 100% { opacity: 0; }
}
@keyframes cut-out {
0%, 17% { opacity: 1; }
18%, 82% { opacity: 0; }
83%, 100% { opacity: 1; }
}
@keyframes rise {
0%, 18% { transform: translateY(0); }
42%, 62% { transform: translateY(-48px); }
83%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__m::before,
.pal.is-demo .pal__opt--out,
.pal.is-demo .pal__opt--hit { animation: none !important; }
.pal__ghost { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
position: relative;
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal.is-demo .pal__input::placeholder {
color: transparent;
}
.pal__ghost {
position: absolute;
left: 32px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
font-weight: 700;
color: #17141a;
opacity: 0;
pointer-events: none;
}
.pal__box {
box-sizing: border-box;
height: 100px;
margin: 8px 0 0;
overflow: hidden;
}
.pal__list {
padding: 0;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.12);
}
.pal__name {
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
}
.pal__m {
position: relative;
z-index: 0;
background: transparent;
color: inherit;
font-weight: 800;
}
.pal__m::before {
content: "";
position: absolute;
inset: -2px;
z-index: -1;
border-radius: 4px;
background: rgba(255, 210, 63, 0.9);
opacity: 0;
}
.pal__m.is-on::before {
opacity: 1;
}
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__m::before {
animation: cut-in 2s steps(1, end) infinite;
}
.pal.is-demo .pal__opt--out {
animation: cut-out 2s steps(1, end) infinite;
}
.pal.is-demo .pal__opt--hit {
animation: rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cut-in {
0%, 17% {
opacity: 0;
}
18%, 82% {
opacity: 1;
}
83%, 100% {
opacity: 0;
}
}
@keyframes cut-out {
0%, 17% {
opacity: 1;
}
18%, 82% {
opacity: 0;
}
83%, 100% {
opacity: 1;
}
}
@keyframes rise {
0%, 18% {
transform: translateY(0);
}
42%, 62% {
transform: translateY(-48px);
}
83%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__m::before,
.pal.is-demo .pal__opt--out,
.pal.is-demo .pal__opt--hit {
animation: none !important;
}
.pal__ghost {
opacity: 0;
}
}// 실제 동작: 글자를 건너뛰어 쳐도 순서만 맞으면 걸린다(부분 수열). 맞은 글자만 mark 로 감싼다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var names = opts.map(function (o) { return o.querySelector('.pal__name').textContent; });
var at = -1;
function hits(name, q) {
var lower = name.toLowerCase(), need = q.toLowerCase();
var idx = [], i = 0;
for (var c = 0; c < need.length; c++) {
var k = lower.indexOf(need[c], i);
if (k < 0) return null;
idx.push(k); i = k + 1;
}
return idx;
}
function paint(name, idx) {
var html = '', last = 0;
idx.forEach(function (k) {
html += name.slice(last, k) + '<mark class="pal__m is-on">' + name[k] + '</mark>';
last = k + 1;
});
return html + name.slice(last);
}
function render(q) {
pal.classList.remove('is-demo');
opts.forEach(function (o, i) {
var idx = q ? hits(names[i], q) : [];
o.hidden = !idx;
if (idx) o.querySelector('.pal__name').innerHTML = idx.length ? paint(names[i], idx) : names[i];
});
at = -1;
}
input.addEventListener('input', function () { render(input.value.trim()); });
document.addEventListener('keydown', function (e) {
var vis = opts.filter(function (o) { return !o.hidden; });
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), vis.length - 1));
vis.forEach(function (o, i) { o.classList.toggle('is-at', i === at); });
if (vis[at]) input.setAttribute('aria-activedescendant', vis[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && vis[at]) {
vis.forEach(function (o) { o.setAttribute('aria-selected', String(o === vis[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; render(''); input.focus({ preventScroll: true });
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FuzzyPalette.module.scss";
export type FuzzyPaletteProps = {
placeholder?: string;
options?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
// 부분 수열 매칭 — 글자를 건너뛰어 쳐도 순서만 맞으면 걸린다. 맞은 자리를 그대로 돌려준다
export function fuzzy(name: string, query: string): number[] | null {
if (!query) return [];
const hay = name.toLowerCase();
const need = query.toLowerCase();
const hit: number[] = [];
let from = 0;
for (let c = 0; c < need.length; c++) {
const k = hay.indexOf(need[c], from);
if (k < 0) return null;
hit.push(k);
from = k + 1;
}
return hit;
}
function parts(name: string, hit: number[]) {
const out: { text: string; on: boolean }[] = [];
let last = 0;
hit.forEach((k) => {
if (k > last) out.push({ text: name.slice(last, k), on: false });
out.push({ text: name[k], on: true });
last = k + 1;
});
if (last < name.length) out.push({ text: name.slice(last), on: false });
return out;
}
export default function FuzzyPalette({
placeholder = "Search commands",
options = ["Add comment", "Open settings", "New document", "New window"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FuzzyPaletteProps) {
const [query, setQuery] = useState("");
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const visible = options
.map((name, i) => ({ name, i, hit: fuzzy(name, query.trim()) }))
.filter((row) => row.hit !== null);
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="fz-list"
aria-activedescendant={at >= 0 && visible[at] ? `fz-o${visible[at].i}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setAt(-1); }}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), visible.length - 1)));
} else if (e.key === "Enter" && visible[at]) {
e.preventDefault();
setPicked(visible[at].name);
} else if (e.key === "Escape") {
setQuery(""); setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.box}>
<div className={styles.list} id="fz-list" role="listbox" aria-label="Command palette">
{visible.map((row, vi) => (
<div
key={row.name}
id={`fz-o${row.i}`}
className={`${styles.opt} ${vi === at ? styles.isAt : ""}`}
role="option"
aria-selected={row.name === picked}
tabIndex={-1}
onMouseMove={() => setAt(vi)}
onClick={() => setPicked(row.name)}
>
<span className={styles.name}>
{parts(row.name, row.hit ?? []).map((p, pi) =>
p.on ? <mark className={styles.mark} key={pi}>{p.text}</mark> : <span key={pi}>{p.text}</span>
)}
</span>
</div>
))}
</div>
</div>
</div>
);
}02 · 띄엄띄엄 쳐도 걸리는 검색 — 측정 누적면적 4.095% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
맞은 자리는 구간이 아니라 글자 하나씩 <mark>로 감싸야 띄엄띄엄 걸렸다는 게 눈에 보입니다.
function hits(name, q) {
var lower = name.toLowerCase(), need = q.toLowerCase();
var idx = [], i = 0;
for (var c = 0; c < need.length; c++) {
var k = lower.indexOf(need[c], i);
if (k < 0) return null;
idx.push(k); i = k + 1;
}
return idx;
}
function paint(name, idx) {
var html = '', last = 0;
idx.forEach(function (k) {
html += name.slice(last, k) + '<mark class="pal__m is-on">' + name[k] + '</mark>';
last = k + 1;
});
return html + name.slice(last);
}
03커서를 따라가는 스크롤
어떤 움직임인가
일곱 줄이 들어 있지만 상자는 네 줄 높이만 보여 주고, 커서가 아래 끝에 닿는 순간부터 목록이 한 줄씩 밀려 올라갑니다. 이 일을 하는 건 scrollIntoView의 block: 'nearest' 한 줄이고, 이미 보이는 줄에서는 아무 일도 하지 않기 때문에 화면이 덜컥거리지 않습니다.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="명령 검색" placeholder="명령 검색" autocomplete="off">
<kbd class="pal__kbd">↓</kbd>
</div>
<div class="pal__box" id="box">
<span class="pal__cursor" aria-hidden="true"></span>
<div class="pal__track" id="list" role="listbox" aria-label="명령 팔레트">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">문서 만들기</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">폴더 만들기</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">팀원 초대</div>
<div class="pal__opt" id="o4" role="option" aria-selected="false" tabindex="-1">알림 끄기</div>
<div class="pal__opt" id="o5" role="option" aria-selected="false" tabindex="-1">테마 바꾸기</div>
<div class="pal__opt" id="o6" role="option" aria-selected="false" tabindex="-1">내보내기</div>
<div class="pal__opt" id="o7" role="option" aria-selected="false" tabindex="-1">로그아웃</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-orange);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.pal__kbd {
flex: 0 0 auto;
height: 14px;
display: inline-flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-xs;
background: #fff;
box-shadow: 0 1px 0 rgba(23, 20, 26, .22);
font-family: inherit;
font-size: 9px; font-weight: 800;
color: rgba(23, 20, 26, .5);
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: $sp-2 0 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.pal__box::-webkit-scrollbar { width: 0; }
.pal__cursor {
position: absolute;
left: 0; top: 0;
z-index: 0;
width: 100%; height: 24px;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
opacity: 0;
}
.pal__track { position: relative; z-index: 1; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: $subject-ink; // 목록이 흰 상자 밖(주황 무대)으로 밀려 나온 줄까지 읽히게 — 알파 없이
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .14); color: $color; }
// ── 자동 루프: 커서가 넷째 줄까지 내려간 뒤로는 커서가 서고 목록이 대신 밀려 올라간다 ──
// 커서와 목록이 각각 transform 으로만 움직인다 (실제 scrollLeft·scrollTop 은 WAAPI 로 못 잰다)
.pal.is-demo .pal__cursor { opacity: 1; animation: cur-run $dur-loop $easing infinite; }
.pal.is-demo .pal__track { animation: track-follow $dur-loop $easing infinite; }
@keyframes cur-run {
0% { transform: translateY(0); }
28%, 66% { transform: translateY(72px); }
90%, 100% { transform: translateY(0); }
}
@keyframes track-follow {
0%, 28% { transform: translateY(0); }
52%, 66% { transform: translateY(-72px); }
90%, 100% { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__track { animation: none !important; }
.pal.is-demo .pal__cursor { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__kbd {
flex: 0 0 auto;
height: 14px;
display: inline-flex;
align-items: center;
padding: 0 8px;
border-radius: 4px;
background: #fff;
box-shadow: 0 1px 0 rgba(23, 20, 26, 0.22);
font-family: inherit;
font-size: 9px;
font-weight: 800;
color: rgba(23, 20, 26, 0.5);
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: 8px 0 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.pal__box::-webkit-scrollbar {
width: 0;
}
.pal__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 24px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.pal__track {
position: relative;
z-index: 1;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: #17141a;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
}
.pal.is-demo .pal__cursor {
opacity: 1;
animation: cur-run 2s ease-out infinite;
}
.pal.is-demo .pal__track {
animation: track-follow 2s ease-out infinite;
}
@keyframes cur-run {
0% {
transform: translateY(0);
}
28%, 66% {
transform: translateY(72px);
}
90%, 100% {
transform: translateY(0);
}
}
@keyframes track-follow {
0%, 28% {
transform: translateY(0);
}
52%, 66% {
transform: translateY(-72px);
}
90%, 100% {
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__track {
animation: none !important;
}
.pal.is-demo .pal__cursor {
opacity: 1;
}
}// 실제 동작: ↑↓ 로 커서를 옮기고, 커서가 상자 밖으로 나가려는 순간 scrollIntoView 가 목록만 밀어 올린다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var at = -1;
function move(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
// block: 'nearest' 라서 이미 보이는 줄이면 스크롤이 아예 일어나지 않는다
opts[at].scrollIntoView({ block: 'nearest' });
}
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Home' || e.key === 'End') {
move(e.key === 'Home' ? 0 : opts.length - 1); e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
opts.forEach(function (o) { o.classList.remove('is-at'); });
at = -1; input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) { o.addEventListener('mousemove', function () { move(i); }); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollFollowPalette.module.scss";
export type ScrollFollowPaletteProps = {
placeholder?: string;
options?: string[];
rows?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_OPTIONS = [
"Create document", "Create folder", "Invite member", "Mute notifications",
"Change theme", "Export", "Sign out",
];
export default function ScrollFollowPalette({
placeholder = "Search commands",
options = DEFAULT_OPTIONS,
rows = 4,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: ScrollFollowPaletteProps) {
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]) as { current: (HTMLDivElement | null)[] };
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, height: `${rows * 24}px` };
// block: "nearest" 라서 이미 보이는 줄이면 스크롤이 아예 일어나지 않는다 — 커서가 끝에 닿을 때만 목록이 움직인다
function move(next: number) {
const i = Math.max(0, Math.min(next, options.length - 1));
setAt(i);
rowRefs.current[i]?.scrollIntoView({ block: "nearest" });
}
return (
<div className={styles.pal} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="sf-list"
aria-activedescendant={at >= 0 ? `sf-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
move(at + (e.key === "ArrowDown" ? 1 : -1));
} else if (e.key === "Home" || e.key === "End") {
e.preventDefault();
move(e.key === "Home" ? 0 : options.length - 1);
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(options[at]);
} else if (e.key === "Escape") {
setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
<kbd className={styles.kbd}>↓</kbd>
</div>
<div className={styles.box} style={vars}>
<div className={styles.track} id="sf-list" role="listbox" aria-label="Command palette">
{options.map((label, i) => (
<div
key={label}
id={`sf-o${i}`}
ref={(el: HTMLDivElement | null) => { rowRefs.current[i] = el; }}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => move(i)}
onClick={() => setPicked(label)}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}03 · 커서를 따라가는 스크롤 — 측정 누적면적 16.5% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
Home과 End도 받아 양 끝으로 한 번에 갑니다.
function move(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
// block: 'nearest' 라서 이미 보이는 줄이면 스크롤이 아예 일어나지 않는다
opts[at].scrollIntoView({ block: 'nearest' });
}
04오른쪽 끝에 줄 맞춘 단축키 배지
무엇이 보이나
명령 이름이 두 글자든 여섯 글자든 키캡 배지는 오른쪽 끝에서 한 줄로 맞습니다. 줄을 맞추는 건 이름 쪽에 준 flex가 아니라 배지 묶음에 준 margin-left: auto이고, 남는 가로 자리를 그 마진이 통째로 먹습니다.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="명령 검색" placeholder="명령 검색" autocomplete="off">
</div>
<div class="pal__box">
<span class="pal__cursor" aria-hidden="true"></span>
<div class="pal__list" id="list" role="listbox" aria-label="명령 팔레트">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">새 문서</span>
<span class="pal__keys"><kbd class="pal__kbd">⌘</kbd><kbd class="pal__kbd">N</kbd></span>
</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">검색</span>
<span class="pal__keys"><kbd class="pal__kbd">⌘</kbd><kbd class="pal__kbd">F</kbd></span>
</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">저장</span>
<span class="pal__keys"><kbd class="pal__kbd">⌘</kbd><kbd class="pal__kbd">S</kbd></span>
</div>
<div class="pal__opt" id="o4" role="option" aria-selected="false" tabindex="-1">
<span class="pal__name">설정 열기</span>
<span class="pal__keys"><kbd class="pal__kbd">⌘</kbd><kbd class="pal__kbd">,</kbd></span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-paper);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: $subject-ink;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(255, 247, 230, .08);
}
.pal__mag { display: inline-flex; color: rgba(255, 247, 230, .5); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $color;
}
.pal__input::placeholder { color: rgba(255, 247, 230, .42); }
.pal__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; border-radius: $r-xs; }
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: $sp-2 0 0;
}
.pal__cursor {
position: absolute;
left: 0; top: 0;
z-index: 0;
width: 100%; height: 24px;
border-radius: $r-sm;
background: rgba(255, 247, 230, .1);
opacity: 0;
}
.pal__list { position: relative; z-index: 1; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(255, 247, 230, .1); }
.pal__opt.is-run { background: rgba(47, 109, 246, .42); }
.pal__name {
font-size: 12px; font-weight: 600;
color: rgba(255, 247, 230, .88);
white-space: nowrap;
}
// 이름 길이와 무관하게 배지는 늘 오른쪽 끝 — 남는 자리를 auto 마진이 통째로 먹는다
.pal__keys { margin-left: auto; display: inline-flex; gap: $sp-1; }
.pal__kbd {
box-sizing: border-box;
min-width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0 $sp-1;
border-radius: $r-xs;
background: rgba(255, 247, 230, .14);
box-shadow: 0 1px 0 rgba(255, 247, 230, .28);
font-family: inherit;
font-size: 9px; font-weight: 800;
color: rgba(255, 247, 230, .72);
}
// ── 자동 루프: 커서가 네 줄을 차례로 짚고, 커서가 선 줄의 키캡만 1px 내려앉는다 ──
.pal.is-demo .pal__cursor { opacity: 1; animation: cur-step $dur-loop $easing infinite; }
@keyframes cur-step {
0%, 4% { transform: translateY(0); }
22%, 26% { transform: translateY(24px); }
44%, 48% { transform: translateY(48px); }
66%, 70% { transform: translateY(72px); }
92%, 100% { transform: translateY(0); }
}
.pal.is-demo #o1 .pal__kbd { animation: press-1 $dur-loop $ease-out infinite; }
.pal.is-demo #o2 .pal__kbd { animation: press-2 $dur-loop $ease-out infinite; }
.pal.is-demo #o3 .pal__kbd { animation: press-3 $dur-loop $ease-out infinite; }
.pal.is-demo #o4 .pal__kbd { animation: press-4 $dur-loop $ease-out infinite; }
@mixin press($a, $b) {
0%, #{$a} { transform: translateY(0); box-shadow: 0 1px 0 rgba(255, 247, 230, .28); background: rgba(255, 247, 230, .14); }
#{$a + 2%}, #{$b} { transform: translateY(1px); box-shadow: 0 0 0 rgba(255, 247, 230, 0); background: rgba(255, 247, 230, .3); }
#{$b + 2%}, 100% { transform: translateY(0); box-shadow: 0 1px 0 rgba(255, 247, 230, .28); background: rgba(255, 247, 230, .14); }
}
@keyframes press-1 { @include press(2%, 16%); }
@keyframes press-2 { @include press(24%, 38%); }
@keyframes press-3 { @include press(46%, 60%); }
@keyframes press-4 { @include press(68%, 82%); }
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__kbd { animation: none !important; }
.pal.is-demo .pal__cursor { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #17141a;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.08);
}
.pal__mag {
display: inline-flex;
color: rgba(255, 247, 230, 0.5);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #fff7e6;
}
.pal__input::placeholder {
color: rgba(255, 247, 230, 0.42);
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: 8px 0 0;
}
.pal__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 24px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.1);
opacity: 0;
}
.pal__list {
position: relative;
z-index: 1;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(255, 247, 230, 0.1);
}
.pal__opt.is-run {
background: rgba(47, 109, 246, 0.42);
}
.pal__name {
font-size: 12px;
font-weight: 600;
color: rgba(255, 247, 230, 0.88);
white-space: nowrap;
}
.pal__keys {
margin-left: auto;
display: inline-flex;
gap: 4px;
}
.pal__kbd {
box-sizing: border-box;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.14);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
font-family: inherit;
font-size: 9px;
font-weight: 800;
color: rgba(255, 247, 230, 0.72);
}
.pal.is-demo .pal__cursor {
opacity: 1;
animation: cur-step 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes cur-step {
0%, 4% {
transform: translateY(0);
}
22%, 26% {
transform: translateY(24px);
}
44%, 48% {
transform: translateY(48px);
}
66%, 70% {
transform: translateY(72px);
}
92%, 100% {
transform: translateY(0);
}
}
.pal.is-demo #o1 .pal__kbd {
animation: press-1 2s ease-out infinite;
}
.pal.is-demo #o2 .pal__kbd {
animation: press-2 2s ease-out infinite;
}
.pal.is-demo #o3 .pal__kbd {
animation: press-3 2s ease-out infinite;
}
.pal.is-demo #o4 .pal__kbd {
animation: press-4 2s ease-out infinite;
}
@keyframes press-1 {
0%, 2% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
4%, 16% {
transform: translateY(1px);
box-shadow: 0 0 0 rgba(255, 247, 230, 0);
background: rgba(255, 247, 230, 0.3);
}
18%, 100% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
}
@keyframes press-2 {
0%, 24% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
26%, 38% {
transform: translateY(1px);
box-shadow: 0 0 0 rgba(255, 247, 230, 0);
background: rgba(255, 247, 230, 0.3);
}
40%, 100% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
}
@keyframes press-3 {
0%, 46% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
48%, 60% {
transform: translateY(1px);
box-shadow: 0 0 0 rgba(255, 247, 230, 0);
background: rgba(255, 247, 230, 0.3);
}
62%, 100% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
}
@keyframes press-4 {
0%, 68% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
70%, 82% {
transform: translateY(1px);
box-shadow: 0 0 0 rgba(255, 247, 230, 0);
background: rgba(255, 247, 230, 0.3);
}
84%, 100% {
transform: translateY(0);
box-shadow: 0 1px 0 rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.14);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__kbd {
animation: none !important;
}
.pal.is-demo .pal__cursor {
opacity: 1;
}
}// 실제 동작: ↑↓ 로 줄을 옮기면 그 줄의 키캡만 눌린 모양이 되고, 실제 ⌘/Ctrl 조합을 치면 그 줄이 실행된다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var keys = ['n', 'f', 's', ','];
var at = -1;
function move(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
function run(i) {
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === i)); });
opts[i].classList.add('is-run');
}
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if (e.metaKey || e.ctrlKey) {
var i = keys.indexOf(k);
if (i >= 0) { pal.classList.remove('is-demo'); move(i); run(i); e.preventDefault(); }
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && at >= 0) {
run(at); e.preventDefault();
} else if (e.key === 'Escape') {
opts.forEach(function (o) { o.classList.remove('is-at', 'is-run'); });
at = -1; input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) { o.addEventListener('mousemove', function () { move(i); }); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShortcutBadgePalette.module.scss";
export type ShortcutRow = { label: string; keys: string[]; accel: string };
export type ShortcutBadgePaletteProps = {
placeholder?: string;
rows?: ShortcutRow[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_ROWS: ShortcutRow[] = [
{ label: "New document", keys: ["⌘", "N"], accel: "n" },
{ label: "Search", keys: ["⌘", "F"], accel: "f" },
{ label: "Save", keys: ["⌘", "S"], accel: "s" },
{ label: "Open settings", keys: ["⌘", ","], accel: "," },
];
export default function ShortcutBadgePalette({
placeholder = "Search commands",
rows = DEFAULT_ROWS,
duration = 140,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ShortcutBadgePaletteProps) {
const [at, setAt] = useState(-1);
const [ran, setRan] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 배지에 적힌 조합을 실제로 치면 그 줄이 실행된다 — 치트시트가 장식이 아니게 하는 부분
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey)) return;
const i = rows.findIndex((r) => r.accel === e.key.toLowerCase());
if (i < 0) return;
e.preventDefault();
setAt(i);
setRan(i);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [rows]);
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" />
<path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="sb-list"
aria-activedescendant={at >= 0 ? `sb-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), rows.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setRan(at);
} else if (e.key === "Escape") {
setAt(-1); setRan(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.list} id="sb-list" role="listbox" aria-label="Command palette">
{rows.map((row, i) => (
<div
key={row.label}
id={`sb-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""} ${i === ran ? styles.isRun : ""}`}
role="option"
aria-selected={i === ran}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => { setAt(i); setRan(i); }}
>
<span className={styles.name}>{row.label}</span>
<span className={styles.keys}>
{row.keys.map((k) => <kbd className={styles.kbd} key={k}>{k}</kbd>)}
</span>
</div>
))}
</div>
</div>
);
}04 · 오른쪽 끝에 줄 맞춘 단축키 배지 — 측정 누적면적 15.966% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
배지에 적힌 조합을 실제로 치면 그 줄이 실행되므로 치트시트가 그림이 아닙니다.
// 이름 길이와 무관하게 배지는 늘 오른쪽 끝 — 남는 자리를 auto 마진이 통째로 먹는다
.pal__keys { margin-left: auto; display: inline-flex; gap: $sp-1; }
.pal__kbd {
box-sizing: border-box;
min-width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0 $sp-1;
border-radius: $r-xs;
background: rgba(255, 247, 230, .14);
box-shadow: 0 1px 0 rgba(255, 247, 230, .28);
font-family: inherit;
font-size: 9px; font-weight: 800;
color: rgba(255, 247, 230, .72);
}
05한 단계 들어가는 브레드크럼
화면에서 일어나는 일
첫 줄을 고르면 입력칸 앞에 알약 칩이 하나 생기고 목록이 그 명령의 대상 목록으로 갈립니다. 돌아오는 길은 뒤로 가기 버튼이 아니라 빈 입력칸에서의 Backspace라서, 손이 입력칸을 떠나지 않습니다.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<div class="pal__bar">
<span class="pal__chip" id="chip">
<span class="pal__chipic" aria-hidden="true"><svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2"/><path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52"/></g></svg></span>
팀에 초대
</span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list-a" aria-label="명령 검색" placeholder="명령 검색" autocomplete="off">
</div>
<p class="pal__live" id="live" aria-live="polite"></p>
<div class="pal__box">
<div class="pal__list pal__list--a" id="list-a" role="listbox" aria-label="명령 팔레트">
<div class="pal__opt" id="a1" role="option" aria-selected="false" tabindex="-1">팀에 초대</div>
<div class="pal__opt" id="a2" role="option" aria-selected="false" tabindex="-1">문서 공유</div>
<div class="pal__opt" id="a3" role="option" aria-selected="false" tabindex="-1">링크 복사</div>
<div class="pal__opt" id="a4" role="option" aria-selected="false" tabindex="-1">보관함으로 옮기기</div>
</div>
<div class="pal__list pal__list--b" id="list-b" role="listbox" aria-label="팀에 초대" hidden>
<div class="pal__opt" id="b1" role="option" aria-selected="false" tabindex="-1">디자인 팀</div>
<div class="pal__opt" id="b2" role="option" aria-selected="false" tabindex="-1">개발 팀</div>
<div class="pal__opt" id="b3" role="option" aria-selected="false" tabindex="-1">마케팅 팀</div>
<div class="pal__opt" id="b4" role="option" aria-selected="false" tabindex="-1">경영지원 팀</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__chip {
flex: 0 0 auto;
height: 18px;
display: inline-flex; align-items: center; gap: $sp-1;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(47, 109, 246, .14);
font-size: 10px; font-weight: 800;
color: $color;
white-space: nowrap;
cursor: pointer;
opacity: 0;
transform: translateX(-8px);
}
.pal__chipic { display: inline-flex; }
.pal.is-deep .pal__chip { opacity: 1; transform: translateX(0); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.pal__live {
position: absolute;
width: 1px; height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: $sp-2 0 0;
}
.pal__list { position: absolute; left: 0; top: 0; width: 100%; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .12); color: $color; }
// ── 자동 루프: 칩이 생기며 명령 목록이 팀 목록으로 갈렸다가, 칩이 빠지며 되돌아온다 ──
// 두 목록은 같은 자리를 쓰므로 steps(1) 로 한 프레임에 교체한다 (겹쳐 비치는 프레임 0)
.pal.is-demo .pal__list--b[hidden] { display: block; }
.pal.is-demo .pal__chip { animation: chip-in $dur-loop $easing infinite; }
.pal.is-demo .pal__list--a { animation: cut-out $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__list--b { animation: cut-in $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__list--b .pal__opt { animation: slide-in $dur-loop $easing infinite; }
.pal.is-demo #b2 { animation-delay: 50ms; }
.pal.is-demo #b3 { animation-delay: 100ms; }
.pal.is-demo #b4 { animation-delay: 150ms; }
@keyframes chip-in {
0%, 16% { opacity: 0; transform: translateX(-8px); }
26%, 78% { opacity: 1; transform: translateX(0); }
88%, 100% { opacity: 0; transform: translateX(-8px); }
}
@keyframes cut-out {
0%, 17% { opacity: 1; }
18%, 84% { opacity: 0; }
85%, 100% { opacity: 1; }
}
@keyframes cut-in {
0%, 17% { opacity: 0; }
18%, 84% { opacity: 1; }
85%, 100% { opacity: 0; }
}
@keyframes slide-in {
0%, 18% { transform: translateX(16px); }
32%, 84% { transform: translateX(0); }
85%, 100% { transform: translateX(16px); }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__chip,
.pal.is-demo .pal__list--a,
.pal.is-demo .pal__list--b,
.pal.is-demo .pal__list--b .pal__opt { animation: none !important; }
.pal.is-demo .pal__list--b[hidden] { display: none; }
.pal.is-demo .pal__chip { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__chip {
flex: 0 0 auto;
height: 18px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.14);
font-size: 10px;
font-weight: 800;
color: #2f6df6;
white-space: nowrap;
cursor: pointer;
opacity: 0;
transform: translateX(-8px);
}
.pal__chipic {
display: inline-flex;
}
.pal.is-deep .pal__chip {
opacity: 1;
transform: translateX(0);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__live {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
}
.pal__box {
position: relative;
box-sizing: border-box;
height: 96px;
margin: 8px 0 0;
}
.pal__list {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
}
.pal.is-demo .pal__list--b[hidden] {
display: block;
}
.pal.is-demo .pal__chip {
animation: chip-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pal.is-demo .pal__list--a {
animation: cut-out 2s steps(1, end) infinite;
}
.pal.is-demo .pal__list--b {
animation: cut-in 2s steps(1, end) infinite;
}
.pal.is-demo .pal__list--b .pal__opt {
animation: slide-in 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pal.is-demo #b2 {
animation-delay: 50ms;
}
.pal.is-demo #b3 {
animation-delay: 100ms;
}
.pal.is-demo #b4 {
animation-delay: 150ms;
}
@keyframes chip-in {
0%, 16% {
opacity: 0;
transform: translateX(-8px);
}
26%, 78% {
opacity: 1;
transform: translateX(0);
}
88%, 100% {
opacity: 0;
transform: translateX(-8px);
}
}
@keyframes cut-out {
0%, 17% {
opacity: 1;
}
18%, 84% {
opacity: 0;
}
85%, 100% {
opacity: 1;
}
}
@keyframes cut-in {
0%, 17% {
opacity: 0;
}
18%, 84% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes slide-in {
0%, 18% {
transform: translateX(16px);
}
32%, 84% {
transform: translateX(0);
}
85%, 100% {
transform: translateX(16px);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__chip,
.pal.is-demo .pal__list--a,
.pal.is-demo .pal__list--b,
.pal.is-demo .pal__list--b .pal__opt {
animation: none !important;
}
.pal.is-demo .pal__list--b[hidden] {
display: none;
}
.pal.is-demo .pal__chip {
opacity: 0;
}
}// 실제 동작: 첫 줄을 고르면 입력칸 앞에 칩이 생기며 하위 목록으로 갈리고, 빈 입력칸에서 Backspace 를 치면 되돌아온다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var chip = document.getElementById('chip');
var live = document.getElementById('live');
var A = document.getElementById('list-a');
var B = document.getElementById('list-b');
var deep = false, at = -1;
function rows() { return [].slice.call((deep ? B : A).querySelectorAll('.pal__opt')); }
function paint() {
pal.classList.remove('is-demo');
pal.classList.toggle('is-deep', deep);
A.hidden = deep; B.hidden = !deep;
input.setAttribute('aria-controls', deep ? 'list-b' : 'list-a');
live.textContent = deep ? '팀에 초대' : '';
at = -1;
}
function move(i) {
var r = rows();
at = Math.max(0, Math.min(i, r.length - 1));
r.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', r[at].id);
}
document.addEventListener('keydown', function (e) {
var r = rows();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
pal.classList.remove('is-demo'); move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && r[at]) {
if (!deep && at === 0) { deep = true; paint(); } else { r[at].setAttribute('aria-selected', 'true'); }
e.preventDefault();
} else if (e.key === 'Backspace' && deep && !input.value) {
deep = false; paint(); input.focus({ preventScroll: true }); e.preventDefault();
} else if (e.key === 'Escape') {
deep = false; paint(); input.value = ''; input.focus({ preventScroll: true });
}
});
A.querySelectorAll('.pal__opt')[0].addEventListener('click', function () { deep = true; paint(); });
chip.addEventListener('click', function () { deep = false; paint(); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BreadcrumbPalette.module.scss";
export type BreadcrumbPaletteProps = {
placeholder?: string;
commands?: string[];
targets?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
export default function BreadcrumbPalette({
placeholder = "Search commands",
commands = ["Invite to team", "Share document", "Copy link", "Move to archive"],
targets = ["Design team", "Engineering team", "Marketing team", "Operations team"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BreadcrumbPaletteProps) {
const [deep, setDeep] = useState(false);
const [query, setQuery] = useState("");
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const rows = deep ? targets : commands;
function enter() { setDeep(true); setQuery(""); setAt(-1); }
function back() {
setDeep(false); setQuery(""); setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
{deep ? (
<button className={styles.chip} type="button" onClick={back}>
<span className={styles.chipic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="11" height="11">
<g fill="currentColor">
<path d="M168 100a60 60 0 1 1-60-60a60 60 0 0 1 60 60" opacity=".2" />
<path d="M256 136a8 8 0 0 1-8 8h-16v16a8 8 0 0 1-16 0v-16h-16a8 8 0 0 1 0-16h16v-16a8 8 0 0 1 16 0v16h16a8 8 0 0 1 8 8m-57.87 58.85a8 8 0 0 1-12.26 10.3C165.75 181.19 138.09 168 108 168s-57.75 13.19-77.87 37.15a8 8 0 0 1-12.25-10.3c14.94-17.78 33.52-30.41 54.17-37.17a68 68 0 1 1 71.9 0c20.65 6.76 39.23 19.39 54.18 37.17M108 152a52 52 0 1 0-52-52a52.06 52.06 0 0 0 52 52" />
</g>
</svg>
</span>
{commands[0]}
</button>
) : null}
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="bc-list"
aria-activedescendant={at >= 0 ? `bc-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => setQuery(e.target.value)}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), rows.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
if (!deep && at === 0) enter(); else setPicked(rows[at]);
} else if (e.key === "Backspace" && deep && !query) {
// 빈 입력칸에서의 Backspace 는 글자가 아니라 한 단계를 지운다
e.preventDefault();
back();
} else if (e.key === "Escape") {
back();
}
}}
/>
</div>
<p className={styles.live} aria-live="polite">{deep ? commands[0] : ""}</p>
<div className={styles.box}>
<div className={styles.list} id="bc-list" role="listbox" aria-label={deep ? commands[0] : "Command palette"}>
{rows.map((label, i) => (
<div
key={label}
id={`bc-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => { if (!deep && i === 0) enter(); else setPicked(label); }}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}05 · 한 단계 들어가는 브레드크럼 — 측정 누적면적 2.706% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
지금 어느 단계인지는 화면에서 숨긴 aria-live="polite" 문단이 읽어 줍니다.
document.addEventListener('keydown', function (e) {
var r = rows();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
pal.classList.remove('is-demo'); move(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter' && r[at]) {
if (!deep && at === 0) { deep = true; paint(); } else { r[at].setAttribute('aria-selected', 'true'); }
e.preventDefault();
} else if (e.key === 'Backspace' && deep && !input.value) {
deep = false; paint(); input.focus({ preventScroll: true }); e.preventDefault();
} else if (e.key === 'Escape') {
deep = false; paint(); input.value = ''; input.focus({ preventScroll: true });
}
});
06오른쪽에 붙는 미리보기 패널
어떤 움직임인가
커서가 선 줄의 내용이 오른쪽 반쪽에 바로 뜨고, 줄이 바뀔 때마다 패널 안쪽만 통째로 갈립니다. 왼쪽 목록과 오른쪽 패널은 grid-template-columns: 1fr 104px 한 줄로 나눴고, 입력칸에 aria-describedby를 걸어 지금 보이는 미리보기가 무엇인지도 읽히게 했습니다.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-describedby="pane" aria-label="문서 검색" placeholder="문서 검색" autocomplete="off">
</div>
<div class="pal__body">
<div class="pal__side">
<span class="pal__cursor" aria-hidden="true"></span>
<div class="pal__list" id="list" role="listbox" aria-label="문서 목록">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">주간 보고서</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">예산 시트</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">회의 메모</div>
</div>
</div>
<div class="pal__pane" id="pane">
<div class="pal__pv" id="p1">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM200 216H56V40h88v48a8 8 0 0 0 8 8h48zm-32-80a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m0 32a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8"/></g></svg></span>
<p class="pal__pvt">주간 보고서</p>
<span class="pal__ln" style="width: 90%"></span>
<span class="pal__ln" style="width: 64%"></span>
<span class="pal__tag">보고</span>
</div>
<div class="pal__pv" id="p2">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 64v128H88V64Z" opacity=".2"/><path d="M80 64a8 8 0 0 1 8-8h128a8 8 0 0 1 0 16H88a8 8 0 0 1-8-8m136 56H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16m0 64H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16M44 52a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>
<p class="pal__pvt">예산 시트</p>
<span class="pal__ln" style="width: 56%"></span>
<span class="pal__ln" style="width: 88%"></span>
<span class="pal__tag">예산</span>
</div>
<div class="pal__pv" id="p3">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m200 88l-72 72H96v-32l72-72Z" opacity=".2"/><path d="m229.66 58.34l-32-32a8 8 0 0 0-11.32 0l-96 96A8 8 0 0 0 88 128v32a8 8 0 0 0 8 8h32a8 8 0 0 0 5.66-2.34l96-96a8 8 0 0 0 0-11.32M124.69 152H104v-20.69l64-64L188.69 88ZM200 76.69L179.31 56L192 43.31L212.69 64ZM224 128v80a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h80a8 8 0 0 1 0 16H48v160h160v-80a8 8 0 0 1 16 0"/></g></svg></span>
<p class="pal__pvt">회의 메모</p>
<span class="pal__ln" style="width: 72%"></span>
<span class="pal__ln" style="width: 50%"></span>
<span class="pal__tag">회의</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.pal {
box-sizing: border-box;
width: min(272px, 90vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.pal__body {
display: grid;
grid-template-columns: 1fr 104px;
gap: $sp-2;
margin: $sp-2 0 0;
}
.pal__side { position: relative; box-sizing: border-box; height: 92px; }
.pal__cursor {
position: absolute;
left: 0; top: 0;
z-index: 0;
width: 100%; height: 24px;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
opacity: 0;
}
.pal__list { position: relative; z-index: 1; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .14); color: $color; }
.pal__pane {
position: relative;
box-sizing: border-box;
height: 92px;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
overflow: hidden;
}
.pal__pv {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: $sp-2;
opacity: 0;
}
.pal__pv:first-child { opacity: 1; }
.pal__pv.is-on { opacity: 1; }
.pal__pvic { display: block; color: $color; }
.pal__pvt {
margin: $sp-1 0 $sp-2;
font-size: 10px; font-weight: 800;
color: rgba(23, 20, 26, .78);
white-space: nowrap;
}
.pal__ln {
display: block;
height: 4px;
margin: 0 0 $sp-1;
border-radius: $r-xs;
background: rgba(23, 20, 26, .16);
}
.pal__tag {
display: inline-flex; align-items: center;
height: 14px;
margin: $sp-1 0 0;
padding: 0 $sp-2;
border-radius: $r-pill;
font-size: 8px; font-weight: 800;
color: #fff;
}
// 세 미리보기의 바탕색까지 다르게 둔다 — 교체 순간이 이 데모에서 가장 크게 변하는 프레임이 되어야
// poster(가장 크게 변하는 프레임)가 「커서는 아직 이동 중인데 패널만 바뀐」 장면으로 잡히지 않는다
#p1 { background: rgba(47, 109, 246, .1); }
#p2 { background: rgba(23, 20, 26, .07); }
#p3 { background: rgba(76, 212, 166, .16); }
#p1 .pal__tag { background: $color; }
#p2 .pal__tag { background: $subject-ink; }
#p3 .pal__tag { background: $subject-mint; }
#p2 .pal__pvic { color: $subject-ink; }
#p3 .pal__pvic { color: $subject-mint; }
// ── 자동 루프: 커서가 세 줄을 짚는 동안 오른쪽 패널 내용만 한 프레임에 통째로 바뀐다 ──
// 세 미리보기가 같은 자리에 겹쳐 있으므로 steps(1) 로 끊는다 — 두 장이 동시에 비치는 프레임이 없다
.pal.is-demo .pal__cursor { opacity: 1; animation: cur-step $dur-loop $easing infinite; }
.pal.is-demo #p1 { animation: pv-1 $dur-loop steps(1, end) infinite; }
.pal.is-demo #p2 { animation: pv-2 $dur-loop steps(1, end) infinite; }
.pal.is-demo #p3 { animation: pv-3 $dur-loop steps(1, end) infinite; }
// 커서가 줄에 **닿아 선 뒤에** 패널이 바뀌어야 한다 — 포스터로 쓰이는 「가장 크게 변하는 프레임」이
// 곧 교체 프레임이라, 교체가 커서 이동 중에 일어나면 커서 1번·패널 3번인 장면이 대표 이미지가 된다 (초안 실측).
// 마지막 프레임이 2초의 95.8% 지점이라 되돌아오는 교체도 90% 안에 끝내야 샘플에 잡힌다
@keyframes cur-step {
0%, 18% { transform: translateY(0); }
30%, 50% { transform: translateY(24px); }
62%, 74% { transform: translateY(48px); }
86%, 100% { transform: translateY(0); }
}
@keyframes pv-1 {
0%, 33% { opacity: 1; }
34%, 89% { opacity: 0; }
90%, 100% { opacity: 1; }
}
@keyframes pv-2 {
0%, 33% { opacity: 0; }
34%, 65% { opacity: 1; }
66%, 100% { opacity: 0; }
}
@keyframes pv-3 {
0%, 65% { opacity: 0; }
66%, 89% { opacity: 1; }
90%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__pv { animation: none !important; }
.pal.is-demo .pal__cursor { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(272px, 90vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal__body {
display: grid;
grid-template-columns: 1fr 104px;
gap: 8px;
margin: 8px 0 0;
}
.pal__side {
position: relative;
box-sizing: border-box;
height: 92px;
}
.pal__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 24px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.pal__list {
position: relative;
z-index: 1;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.14);
color: #2f6df6;
}
.pal__pane {
position: relative;
box-sizing: border-box;
height: 92px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
overflow: hidden;
}
.pal__pv {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: 8px;
opacity: 0;
}
.pal__pv:first-child {
opacity: 1;
}
.pal__pv.is-on {
opacity: 1;
}
.pal__pvic {
display: block;
color: #2f6df6;
}
.pal__pvt {
margin: 4px 0 8px;
font-size: 10px;
font-weight: 800;
color: rgba(23, 20, 26, 0.78);
white-space: nowrap;
}
.pal__ln {
display: block;
height: 4px;
margin: 0 0 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.16);
}
.pal__tag {
display: inline-flex;
align-items: center;
height: 14px;
margin: 4px 0 0;
padding: 0 8px;
border-radius: 999px;
font-size: 8px;
font-weight: 800;
color: #fff;
}
#p1 {
background: rgba(47, 109, 246, 0.1);
}
#p2 {
background: rgba(23, 20, 26, 0.07);
}
#p3 {
background: rgba(76, 212, 166, 0.16);
}
#p1 .pal__tag {
background: #2f6df6;
}
#p2 .pal__tag {
background: #17141a;
}
#p3 .pal__tag {
background: #4cd4a6;
}
#p2 .pal__pvic {
color: #17141a;
}
#p3 .pal__pvic {
color: #4cd4a6;
}
.pal.is-demo .pal__cursor {
opacity: 1;
animation: cur-step 2s ease-out infinite;
}
.pal.is-demo #p1 {
animation: pv-1 2s steps(1, end) infinite;
}
.pal.is-demo #p2 {
animation: pv-2 2s steps(1, end) infinite;
}
.pal.is-demo #p3 {
animation: pv-3 2s steps(1, end) infinite;
}
@keyframes cur-step {
0%, 18% {
transform: translateY(0);
}
30%, 50% {
transform: translateY(24px);
}
62%, 74% {
transform: translateY(48px);
}
86%, 100% {
transform: translateY(0);
}
}
@keyframes pv-1 {
0%, 33% {
opacity: 1;
}
34%, 89% {
opacity: 0;
}
90%, 100% {
opacity: 1;
}
}
@keyframes pv-2 {
0%, 33% {
opacity: 0;
}
34%, 65% {
opacity: 1;
}
66%, 100% {
opacity: 0;
}
}
@keyframes pv-3 {
0%, 65% {
opacity: 0;
}
66%, 89% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__pv {
animation: none !important;
}
.pal.is-demo .pal__cursor {
opacity: 1;
}
}// 실제 동작: ↑↓ 로 줄을 옮기면 오른쪽 패널이 그 줄의 미리보기로 통째로 바뀐다 (겹치는 프레임 없이 교체).
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var pvs = [].slice.call(document.querySelectorAll('.pal__pv'));
var at = 0;
function show(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
pvs.forEach(function (p, k) { p.classList.toggle('is-on', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
show(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter') {
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === at)); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) { o.addEventListener('mousemove', function () { show(i); }); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PreviewPanePalette.module.scss";
export type PreviewDoc = { title: string; tag: string; lines: number[]; tint: string };
export type PreviewPanePaletteProps = {
placeholder?: string;
docs?: PreviewDoc[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PaneVars = CSSProperties & { "--tint"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_DOCS: PreviewDoc[] = [
{ title: "Weekly report", tag: "Report", lines: [90, 64], tint: "rgba(47, 109, 246, .1)" },
{ title: "Budget sheet", tag: "Budget", lines: [56, 88], tint: "rgba(23, 20, 26, .07)" },
{ title: "Meeting notes", tag: "Meeting", lines: [72, 50], tint: "rgba(76, 212, 166, .16)" },
];
export default function PreviewPanePalette({
placeholder = "Search documents",
docs = DEFAULT_DOCS,
duration = 140,
easing = "ease-out",
color = "#2f6df6",
}: PreviewPanePaletteProps) {
const [at, setAt] = useState(0);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const doc = docs[at];
const paneVars: PaneVars = { "--tint": doc.tint };
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="pp-list"
aria-describedby="pp-pane"
aria-activedescendant={`pp-o${at}`}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), docs.length - 1)));
} else if (e.key === "Enter") {
e.preventDefault();
setPicked(doc.title);
} else if (e.key === "Escape") {
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.body}>
<div className={styles.list} id="pp-list" role="listbox" aria-label="Document list">
{docs.map((d, i) => (
<div
key={d.title}
id={`pp-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={d.title === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(d.title)}
>
{d.title}
</div>
))}
</div>
{/* 줄이 바뀌면 패널 내용이 통째로 갈린다 — 페이드 없이 한 번에 (두 장이 겹쳐 비치지 않게) */}
<div className={styles.pane} id="pp-pane" style={paneVars} key={doc.title}>
<span className={styles.pvic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v176a16 16 0 0 0 16 16h144a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM200 216H56V40h88v48a8 8 0 0 0 8 8h48zm-32-80a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m0 32a8 8 0 0 1-8 8H96a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8" />
</g>
</svg>
</span>
<p className={styles.pvt}>{doc.title}</p>
{doc.lines.map((w, i) => <span className={styles.ln} style={{ width: `${w}%` }} key={i} />)}
<span className={styles.tag}>{doc.tag}</span>
</div>
</div>
</div>
);
}06 · 오른쪽에 붙는 미리보기 패널 — 측정 누적면적 13.172% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
교체는 서서히 섞는 대신 한 프레임에 끊습니다.
function show(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
pvs.forEach(function (p, k) { p.classList.toggle('is-on', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
07빈손으로 끝나지 않는 결과 없음
무엇이 보이나
맞는 줄이 하나도 없으면 안내 한 줄만 남기지 않고, 방금 친 글자를 그대로 담은 줄 두 개가 대신 올라옵니다. 두 줄에도 role="option"이 달려 있어 화살표와 Enter가 그대로 듣습니다.

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<div class="pal__bar">
<span class="pal__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="pal__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="명령 검색" placeholder="명령 검색" autocomplete="off">
<span class="pal__ghost" aria-hidden="true">월간 리포트</span>
</div>
<div class="pal__box">
<div class="pal__list" id="list" role="listbox" aria-label="명령 팔레트">
<div class="pal__opt pal__opt--out" id="o1" role="option" aria-selected="false" tabindex="-1">문서 만들기</div>
<div class="pal__opt pal__opt--out" id="o2" role="option" aria-selected="false" tabindex="-1">팀원 초대</div>
<div class="pal__opt pal__opt--out" id="o3" role="option" aria-selected="false" tabindex="-1">설정 열기</div>
<div class="pal__empty" id="empty" hidden>
<p class="pal__note">검색 결과 없음</p>
<div class="pal__opt pal__opt--sug" id="s1" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8"/></g></svg></span>
<span class="pal__act">새로 만들기</span><span class="pal__q">월간 리포트</span>
</div>
<div class="pal__opt pal__opt--sug" id="s2" role="option" aria-selected="false" tabindex="-1">
<span class="pal__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<span class="pal__act">전체 검색</span><span class="pal__q">월간 리포트</span>
</div>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: $sp-2;
background: #fff;
border-radius: $r-card;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.pal__bar {
position: relative;
box-sizing: border-box;
height: 30px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.pal__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $subject-ink;
}
.pal__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
// 자동 루프는 친 글자를 고스트로 겹쳐 보여주므로, 루프 동안에는 placeholder 를 비운다 (두 글자가 겹쳐 읽혔다)
.pal.is-demo .pal__input::placeholder { color: transparent; }
.pal__ghost {
position: absolute;
left: 32px; top: 50%;
transform: translateY(-50%);
font-size: 12px; font-weight: 700;
color: $subject-ink;
opacity: 0;
pointer-events: none;
}
.pal__box {
box-sizing: border-box;
height: 96px;
margin: $sp-2 0 0;
overflow: hidden;
}
.pal__list { position: relative; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at { background: rgba(47, 109, 246, .12); }
.pal__ic { display: inline-flex; color: $color; }
.pal__name { overflow: hidden; text-overflow: ellipsis; }
.pal__act { flex: 0 0 auto; }
.pal__q {
overflow: hidden;
font-weight: 800;
color: $color;
text-overflow: ellipsis;
}
.pal__empty { position: absolute; left: 0; top: 0; width: 100%; }
.pal.is-demo .pal__empty[hidden] { display: block; }
.pal__note {
margin: 0 0 $sp-1;
padding: 0 $sp-2;
height: 20px;
display: flex; align-items: center;
font-size: 10px; font-weight: 700;
color: rgba(23, 20, 26, .4);
}
// ── 자동 루프: 맞는 줄이 없어지는 순간 안내 한 줄과 제안 두 줄이 같은 자리로 올라온다 ──
// 사라지는 줄과 올라오는 줄이 겹치지 않도록 사라짐·나타남은 steps(1) 로 끊고, 제안만 미끄러져 올라온다
.pal.is-demo .pal__ghost { animation: cut-in $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__opt--out { animation: cut-out $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__empty { animation: cut-in $dur-loop steps(1, end) infinite; }
.pal.is-demo .pal__opt--sug { animation: rise $dur-loop $easing infinite; }
.pal.is-demo #s2 { animation-delay: 70ms; }
@keyframes cut-in {
0%, 17% { opacity: 0; }
18%, 82% { opacity: 1; }
83%, 100% { opacity: 0; }
}
@keyframes cut-out {
0%, 17% { opacity: 1; }
18%, 82% { opacity: 0; }
83%, 100% { opacity: 1; }
}
@keyframes rise {
0%, 18% { transform: translateY(20px); }
40%, 62% { transform: translateY(0); }
83%, 100% { transform: translateY(20px); }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__opt--out,
.pal.is-demo .pal__empty,
.pal.is-demo .pal__opt--sug { animation: none !important; }
.pal.is-demo .pal__empty[hidden] { display: none; }
.pal__ghost { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(264px, 88vw);
padding: 8px;
background: #fff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.pal__bar {
position: relative;
box-sizing: border-box;
height: 30px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.pal__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.pal__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.pal__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.pal.is-demo .pal__input::placeholder {
color: transparent;
}
.pal__ghost {
position: absolute;
left: 32px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
font-weight: 700;
color: #17141a;
opacity: 0;
pointer-events: none;
}
.pal__box {
box-sizing: border-box;
height: 96px;
margin: 8px 0 0;
overflow: hidden;
}
.pal__list {
position: relative;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.pal__opt.is-at {
background: rgba(47, 109, 246, 0.12);
}
.pal__ic {
display: inline-flex;
color: #2f6df6;
}
.pal__name {
overflow: hidden;
text-overflow: ellipsis;
}
.pal__act {
flex: 0 0 auto;
}
.pal__q {
overflow: hidden;
font-weight: 800;
color: #2f6df6;
text-overflow: ellipsis;
}
.pal__empty {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.pal.is-demo .pal__empty[hidden] {
display: block;
}
.pal__note {
margin: 0 0 4px;
padding: 0 8px;
height: 20px;
display: flex;
align-items: center;
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, 0.4);
}
.pal.is-demo .pal__ghost {
animation: cut-in 2s steps(1, end) infinite;
}
.pal.is-demo .pal__opt--out {
animation: cut-out 2s steps(1, end) infinite;
}
.pal.is-demo .pal__empty {
animation: cut-in 2s steps(1, end) infinite;
}
.pal.is-demo .pal__opt--sug {
animation: rise 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.pal.is-demo #s2 {
animation-delay: 70ms;
}
@keyframes cut-in {
0%, 17% {
opacity: 0;
}
18%, 82% {
opacity: 1;
}
83%, 100% {
opacity: 0;
}
}
@keyframes cut-out {
0%, 17% {
opacity: 1;
}
18%, 82% {
opacity: 0;
}
83%, 100% {
opacity: 1;
}
}
@keyframes rise {
0%, 18% {
transform: translateY(20px);
}
40%, 62% {
transform: translateY(0);
}
83%, 100% {
transform: translateY(20px);
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__ghost,
.pal.is-demo .pal__opt--out,
.pal.is-demo .pal__empty,
.pal.is-demo .pal__opt--sug {
animation: none !important;
}
.pal.is-demo .pal__empty[hidden] {
display: none;
}
.pal__ghost {
opacity: 0;
}
}// 실제 동작: 맞는 줄이 하나도 없으면 안내만 남기지 않고, 친 글자를 그대로 담은 실행 가능한 줄 두 개를 대신 올린다.
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var empty = document.getElementById('empty');
var rows = [].slice.call(document.querySelectorAll('.pal__opt--out'));
var sugs = [].slice.call(document.querySelectorAll('.pal__opt--sug'));
var names = rows.map(function (o) { return o.textContent.trim(); });
var at = -1;
function render(q) {
pal.classList.remove('is-demo');
var shown = 0;
rows.forEach(function (o, i) {
var hit = !q || names[i].indexOf(q) >= 0;
o.hidden = !hit;
if (hit) shown++;
});
empty.hidden = !(q && shown === 0);
// 제안 줄에 방금 친 글자를 그대로 넣는다 — "그 이름으로" 가 이 상태의 값이다
document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
at = -1;
}
function live() { return sugs.filter(function (o) { return !empty.hidden; }).concat(rows.filter(function (o) { return !o.hidden; })); }
input.addEventListener('input', function () { render(input.value.trim()); });
document.addEventListener('keydown', function (e) {
var vis = live();
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), vis.length - 1));
vis.forEach(function (o, i) { o.classList.toggle('is-at', i === at); });
if (vis[at]) input.setAttribute('aria-activedescendant', vis[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && vis[at]) {
vis.forEach(function (o) { o.setAttribute('aria-selected', String(o === vis[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; render(''); input.focus({ preventScroll: true });
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EmptySuggestPalette.module.scss";
export type EmptySuggestPaletteProps = {
placeholder?: string;
options?: string[];
emptyText?: string;
suggestions?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
export default function EmptySuggestPalette({
placeholder = "Search commands",
options = ["Create document", "Invite member", "Open settings"],
emptyText = "No results",
suggestions = ["Create new", "Search all"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: EmptySuggestPaletteProps) {
const [query, setQuery] = useState("");
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const q = query.trim();
const hits = options.filter((o) => !q || o.toLowerCase().includes(q.toLowerCase()));
// 빗나간 검색은 안내 한 줄로 끝내지 않는다 — 친 글자를 그대로 담은 실행 가능한 줄을 대신 세운다
const empty = q.length > 0 && hits.length === 0;
const rows = empty ? suggestions : hits;
return (
<div className={styles.pal} style={vars} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded="true"
aria-controls="es-list"
aria-activedescendant={at >= 0 ? `es-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setAt(-1); }}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), rows.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(rows[at]);
} else if (e.key === "Escape") {
setQuery(""); setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.box}>
{empty ? <p className={styles.note}>{emptyText}</p> : null}
<div className={styles.list} id="es-list" role="listbox" aria-label="Command palette">
{rows.map((label, i) => (
<div
key={label}
id={`es-o${i}`}
className={`${styles.opt} ${empty ? styles.isSug : ""} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(label)}
>
{empty ? (
<>
<span className={styles.ic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88m48-88a8 8 0 0 1-8 8h-32v32a8 8 0 0 1-16 0v-32H88a8 8 0 0 1 0-16h32V88a8 8 0 0 1 16 0v32h32a8 8 0 0 1 8 8" />
</g>
</svg>
</span>
<span className={styles.act}>{label}</span>
<span className={styles.q}>{q}</span>
</>
) : label}
</div>
))}
</div>
</div>
</div>
);
}07 · 빈손으로 끝나지 않는 결과 없음 — 측정 누적면적 4.858% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
안내문과 선택지는 여기서 서로 다른 물건입니다.
function render(q) {
pal.classList.remove('is-demo');
var shown = 0;
rows.forEach(function (o, i) {
var hit = !q || names[i].indexOf(q) >= 0;
o.hidden = !hit;
if (hit) shown++;
});
empty.hidden = !(q && shown === 0);
// 제안 줄에 방금 친 글자를 그대로 넣는다 — "그 이름으로" 가 이 상태의 값이다
document.querySelectorAll('.pal__q').forEach(function (s) { s.textContent = q; });
at = -1;
}
08화면 한가운데 큰 입력칸
화면에서 일어나는 일
처음에는 큰 입력칸 하나만 떠 있다가 글자가 들어오는 순간 아래로 결과가 펼쳐집니다. 펼치는 값은 높이도 scaleY도 아닌 clip-path의 inset 아래쪽 값이고, 패널이 차지할 84px 자리는 접혀 있을 때도 그대로 잡아 둡니다.

<main class="stage">
<div class="sp is-demo is-open" role="dialog" aria-modal="true" aria-label="전체 검색">
<div class="sp__field">
<span class="sp__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="sp__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="전체 검색" placeholder="전체 검색" autocomplete="off">
<span class="sp__ghost" aria-hidden="true">보고서</span>
</div>
<div class="sp__panel">
<span class="sp__sheet" aria-hidden="true"></span>
<div class="sp__rows" id="list" role="listbox" aria-label="검색 결과">
<div class="sp__opt" id="o1" role="option" aria-selected="false" tabindex="-1">주간 보고서</div>
<div class="sp__opt" id="o2" role="option" aria-selected="false" tabindex="-1">분기 보고서</div>
<div class="sp__opt" id="o3" role="option" aria-selected="false" tabindex="-1">보고서 서식</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-paper);
.sp {
box-sizing: border-box;
width: min(264px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
}
.sp__field {
position: relative;
box-sizing: border-box;
height: 44px;
display: flex; align-items: center; gap: $sp-3;
padding: 0 $sp-4;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
}
.sp__mag { display: inline-flex; color: rgba(255, 247, 230, .5); }
.sp__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 15px; font-weight: 600;
color: $color;
}
.sp__input::placeholder { color: rgba(255, 247, 230, .4); }
.sp__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 4px; border-radius: $r-sm; }
// 자동 루프는 친 글자를 고스트로 겹쳐 보여주므로, 루프 동안에는 placeholder 를 비운다
.sp.is-demo .sp__input::placeholder { color: transparent; }
.sp__ghost {
position: absolute;
left: 46px; top: 50%;
transform: translateY(-50%);
font-size: 15px; font-weight: 700;
color: $color;
opacity: 0;
pointer-events: none;
}
// 패널 자리는 늘 잡아 두고(레이아웃이 흔들리지 않게) 위 모서리부터 잘라 보여준다.
// scaleY 로 줄였더니 글자까지 같이 눌려 납작해졌다 — clip-path 는 그린 것을 그대로 두고 잘라내기만 한다
.sp__panel {
position: relative;
box-sizing: border-box;
height: 84px;
margin: $sp-2 0 0;
clip-path: inset(0 0 100% 0 round 16px);
}
.sp__sheet {
position: absolute;
inset: 0;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-press;
}
.sp__rows {
position: relative;
box-sizing: border-box;
padding: $sp-2;
}
.sp.is-open .sp__panel { clip-path: inset(0 0 0 0 round 16px); }
.sp__opt {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
color: rgba(255, 247, 230, .82);
white-space: nowrap;
cursor: pointer;
}
.sp__opt.is-at { background: rgba(255, 247, 230, .12); }
// ── 자동 루프: 글자가 들어오면 패널이 위 모서리부터 펴지고, 글자가 지워지면 같은 자리로 접힌다 ──
// 접힘·펴짐이 둘 다 위 모서리 기준이라 한 바퀴 끝에 자리가 튀지 않는다
.sp.is-demo .sp__panel { animation: panel-open $dur-loop $easing infinite; }
.sp.is-demo .sp__ghost { animation: ghost-cut $dur-loop steps(1, end) infinite; }
@keyframes panel-open {
0%, 42% { clip-path: inset(0 0 0 0 round 16px); }
58%, 74% { clip-path: inset(0 0 100% 0 round 16px); }
90%, 100% { clip-path: inset(0 0 0 0 round 16px); }
}
@keyframes ghost-cut {
0%, 49% { opacity: 1; }
50%, 87% { opacity: 0; }
88%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__panel,
.sp.is-demo .sp__ghost { animation: none !important; }
.sp__ghost { opacity: 0; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sp {
box-sizing: border-box;
width: min(264px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff7e6;
}
.sp__field {
position: relative;
box-sizing: border-box;
height: 44px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.sp__mag {
display: inline-flex;
color: rgba(255, 247, 230, 0.5);
}
.sp__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 15px;
font-weight: 600;
color: #fff7e6;
}
.sp__input::placeholder {
color: rgba(255, 247, 230, 0.4);
}
.sp__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
border-radius: 8px;
}
.sp.is-demo .sp__input::placeholder {
color: transparent;
}
.sp__ghost {
position: absolute;
left: 46px;
top: 50%;
transform: translateY(-50%);
font-size: 15px;
font-weight: 700;
color: #fff7e6;
opacity: 0;
pointer-events: none;
}
.sp__panel {
position: relative;
box-sizing: border-box;
height: 84px;
margin: 8px 0 0;
clip-path: inset(0 0 100% 0 round 16px);
}
.sp__sheet {
position: absolute;
inset: 0;
border-radius: 16px;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.sp__rows {
position: relative;
box-sizing: border-box;
padding: 8px;
}
.sp.is-open .sp__panel {
clip-path: inset(0 0 0 0 round 16px);
}
.sp__opt {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
color: rgba(255, 247, 230, 0.82);
white-space: nowrap;
cursor: pointer;
}
.sp__opt.is-at {
background: rgba(255, 247, 230, 0.12);
}
.sp.is-demo .sp__panel {
animation: panel-open 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.sp.is-demo .sp__ghost {
animation: ghost-cut 2s steps(1, end) infinite;
}
@keyframes panel-open {
0%, 42% {
clip-path: inset(0 0 0 0 round 16px);
}
58%, 74% {
clip-path: inset(0 0 100% 0 round 16px);
}
90%, 100% {
clip-path: inset(0 0 0 0 round 16px);
}
}
@keyframes ghost-cut {
0%, 49% {
opacity: 1;
}
50%, 87% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.sp.is-demo .sp__panel,
.sp.is-demo .sp__ghost {
animation: none !important;
}
.sp__ghost {
opacity: 0;
}
}// 실제 동작: 글자를 치면 결과 패널이 위 모서리를 축으로 펼쳐지고, 지우거나 Escape 를 치면 같은 축으로 접힌다.
var sp = document.querySelector('.sp');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.sp__opt'));
var at = -1;
function open(on) {
sp.classList.remove('is-demo');
sp.classList.toggle('is-open', on);
input.setAttribute('aria-expanded', String(on));
if (!on) { opts.forEach(function (o) { o.classList.remove('is-at'); }); at = -1; }
}
input.addEventListener('input', function () { open(input.value.trim().length > 0); });
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
if (!sp.classList.contains('is-open')) return;
sp.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; open(false); input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) {
o.addEventListener('mousemove', function () {
sp.classList.remove('is-demo');
at = i; opts.forEach(function (x, k) { x.classList.toggle('is-at', k === i); });
});
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpotlightSearch.module.scss";
export type SpotlightSearchProps = {
placeholder?: string;
results?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
export default function SpotlightSearch({
placeholder = "Search all",
results = ["Weekly report", "Quarterly report", "Report template"],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SpotlightSearchProps) {
const [query, setQuery] = useState("");
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 결과 자리는 늘 잡아 두고 위 모서리부터 잘라 보여준다 — scaleY 로 줄이면 글자까지 눌린다
const open = query.trim().length > 0;
return (
<div className={styles.sp} style={vars} role="dialog" aria-modal="true" aria-label={placeholder}>
<div className={styles.field}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="18" height="18">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded={open}
aria-controls="sl-list"
aria-activedescendant={at >= 0 ? `sl-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
value={query}
onChange={(e: ChangeEvt) => { setQuery(e.target.value); setAt(-1); }}
onKeyDown={(e: KeyEvt) => {
if (!open) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), results.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(results[at]);
} else if (e.key === "Escape") {
setQuery(""); setAt(-1);
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={`${styles.panel} ${open ? styles.isOpen : ""}`}>
<span className={styles.sheet} aria-hidden="true" />
<div className={styles.rows} id="sl-list" role="listbox" aria-label="Results">
{results.map((label, i) => (
<div
key={label}
id={`sl-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(label)}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}08 · 화면 한가운데 큰 입력칸 — 측정 누적면적 15.516% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
그래서 펼쳐질 때 아래 내용이 밀리지 않습니다.
.sp__panel {
position: relative;
box-sizing: border-box;
height: 84px;
margin: $sp-2 0 0;
clip-path: inset(0 0 100% 0 round 16px);
}
.sp.is-open .sp__panel { clip-path: inset(0 0 0 0 round 16px); }
09아래에서 올라오는 시트형 팔레트
어떤 움직임인가
폰 폭에서는 가운데 모달 대신 시트가 아래에서 올라와 화면 바닥에 붙습니다. 위 모서리만 둥글리고 맨 위 손잡이 막대는 장식이 아니라 aria-label이 붙은 진짜 닫기 버튼입니다.

<main class="stage">
<div class="ph is-demo is-open">
<div class="ph__page" aria-hidden="true">
<span class="ph__ln" style="width: 56%"></span>
<span class="ph__ln" style="width: 84%"></span>
<span class="ph__ln" style="width: 72%"></span>
<span class="ph__ln" style="width: 40%"></span>
</div>
<span class="ph__dim" aria-hidden="true"></span>
<div class="ph__sheet" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<button class="ph__grab" id="grab" type="button" aria-label="팔레트 닫기"></button>
<div class="ph__bar">
<span class="ph__mag" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<input class="ph__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="명령 검색" placeholder="명령 검색" autocomplete="off">
</div>
<div class="ph__list" id="list" role="listbox" aria-label="명령 팔레트">
<div class="ph__opt" id="o1" role="option" aria-selected="false" tabindex="-1">문서 만들기</div>
<div class="ph__opt" id="o2" role="option" aria-selected="false" tabindex="-1">팀원 초대</div>
<div class="ph__opt" id="o3" role="option" aria-selected="false" tabindex="-1">설정 열기</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 폰 화면 한 칸 — 시트는 이 상자 바닥에서 올라온다
.ph {
position: relative;
box-sizing: border-box;
width: 168px; height: 156px;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.ph__page { box-sizing: border-box; padding: $sp-4 $sp-3; }
.ph__ln {
display: block;
height: 6px;
margin: 0 0 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .14);
}
.ph__dim {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, .45);
opacity: 0;
}
.ph.is-open .ph__dim { opacity: 1; }
.ph__sheet {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
padding: $sp-2 $sp-2 calc(#{$sp-3} + env(safe-area-inset-bottom));
border-radius: $r-card $r-card 0 0;
background: #fff;
box-shadow: $shadow-raised;
transform: translateY(100%);
}
.ph.is-open .ph__sheet { transform: translateY(0); }
.ph__grab {
display: block;
width: 32px; height: 4px;
margin: 0 auto $sp-2;
padding: 0;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .2);
cursor: pointer;
}
.ph__grab:focus-visible { outline: 2px solid $color; outline-offset: 4px; }
.ph__bar {
box-sizing: border-box;
height: 28px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
}
.ph__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.ph__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 11px; font-weight: 600;
color: $subject-ink;
}
.ph__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.ph__list { margin: $sp-2 0 0; }
.ph__opt {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 11px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.ph__opt.is-at { background: rgba(47, 109, 246, .12); color: $color; }
// ── 자동 루프: 시트가 바닥으로 내려갔다 같은 길로 다시 올라온다 ──
// 올라올 때와 내려갈 때가 같은 축(아래 끝)이라 한 바퀴 끝에 순간이동하는 프레임이 없다
.ph.is-demo .ph__sheet { animation: sheet-slide $dur-loop $easing infinite; }
.ph.is-demo .ph__dim { animation: dim-fade $dur-loop $easing infinite; }
@keyframes sheet-slide {
0%, 34% { transform: translateY(0); }
56%, 70% { transform: translateY(100%); }
92%, 100% { transform: translateY(0); }
}
@keyframes dim-fade {
0%, 34% { opacity: 1; }
56%, 70% { opacity: 0; }
92%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__sheet,
.ph.is-demo .ph__dim { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ph {
position: relative;
box-sizing: border-box;
width: 168px;
height: 156px;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ph__page {
box-sizing: border-box;
padding: 16px 12px;
}
.ph__ln {
display: block;
height: 6px;
margin: 0 0 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
}
.ph__dim {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.45);
opacity: 0;
}
.ph.is-open .ph__dim {
opacity: 1;
}
.ph__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
padding: 8px 8px calc(12px + env(safe-area-inset-bottom));
border-radius: 16px 16px 0 0;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(100%);
}
.ph.is-open .ph__sheet {
transform: translateY(0);
}
.ph__grab {
display: block;
width: 32px;
height: 4px;
margin: 0 auto 8px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.2);
cursor: pointer;
}
.ph__grab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
}
.ph__bar {
box-sizing: border-box;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
}
.ph__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.ph__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 11px;
font-weight: 600;
color: #17141a;
}
.ph__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.ph__list {
margin: 8px 0 0;
}
.ph__opt {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.ph__opt.is-at {
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
}
.ph.is-demo .ph__sheet {
animation: sheet-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ph.is-demo .ph__dim {
animation: dim-fade 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes sheet-slide {
0%, 34% {
transform: translateY(0);
}
56%, 70% {
transform: translateY(100%);
}
92%, 100% {
transform: translateY(0);
}
}
@keyframes dim-fade {
0%, 34% {
opacity: 1;
}
56%, 70% {
opacity: 0;
}
92%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__sheet,
.ph.is-demo .ph__dim {
animation: none !important;
}
}// 실제 동작: ⌘K(Ctrl+K)로 시트를 올리고 내린다. 손잡이를 누르거나 Escape 를 쳐도 같은 길로 내려간다.
var ph = document.querySelector('.ph');
var input = document.getElementById('q');
var grab = document.getElementById('grab');
var opts = [].slice.call(document.querySelectorAll('.ph__opt'));
var at = -1;
function open(on) {
ph.classList.remove('is-demo');
ph.classList.toggle('is-open', on);
if (on) input.focus({ preventScroll: true });
else { opts.forEach(function (o) { o.classList.remove('is-at'); }); at = -1; }
}
grab.addEventListener('click', function () { open(false); });
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && k === 'k') {
open(!ph.classList.contains('is-open')); e.preventDefault(); return;
}
if (!ph.classList.contains('is-open')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
ph.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), opts.length - 1));
opts.forEach(function (o, i) { o.classList.toggle('is-at', i === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
open(false);
}
});
opts.forEach(function (o, i) {
o.addEventListener('mousemove', function () {
ph.classList.remove('is-demo');
at = i; opts.forEach(function (x, k) { x.classList.toggle('is-at', k === i); });
});
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SheetPalette.module.scss";
export type SheetPaletteProps = {
placeholder?: string;
options?: string[];
closeLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
export default function SheetPalette({
placeholder = "Search commands",
options = ["Create document", "Invite member", "Open settings"],
closeLabel = "Close palette",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SheetPaletteProps) {
const [open, setOpen] = useState(true);
const [at, setAt] = useState(-1);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((v) => !v);
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, []);
useEffect(() => {
if (open) inputRef.current?.focus({ preventScroll: true });
else setAt(-1);
}, [open]);
return (
<div className={`${styles.ph} ${open ? styles.isOpen : ""}`} style={vars}>
<div className={styles.page} aria-hidden="true">
{[56, 84, 72, 40].map((w) => <span className={styles.ln} style={{ width: `${w}%` }} key={w} />)}
</div>
<span className={styles.dim} aria-hidden="true" />
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label="Command palette">
{/* 손잡이 막대는 장식이 아니라 닫는 버튼이다 — 터치에서 시트를 내리는 유일한 길 */}
<button className={styles.grab} type="button" aria-label={closeLabel} onClick={() => setOpen(false)} />
<div className={styles.bar}>
<span className={styles.mag} aria-hidden="true">
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2" />
<path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72" />
</g>
</svg>
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
role="combobox"
aria-expanded={open}
aria-controls="ms-list"
aria-activedescendant={at >= 0 ? `ms-o${at}` : undefined}
aria-label={placeholder}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
setAt((a) => Math.max(0, Math.min(a + (e.key === "ArrowDown" ? 1 : -1), options.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(options[at]);
} else if (e.key === "Escape") {
setOpen(false);
}
}}
/>
</div>
<div className={styles.list} id="ms-list" role="listbox" aria-label="Command palette">
{options.map((label, i) => (
<div
key={label}
id={`ms-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(label)}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}09 · 아래에서 올라오는 시트형 팔레트 — 측정 누적면적 18.065% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
아래 패딩에 env(safe-area-inset-bottom)을 더해 두었기 때문에, 노치 화면에서도 마지막 줄이 홈 인디케이터에 걸리지 않습니다.
.ph__sheet {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
padding: $sp-2 $sp-2 calc(#{$sp-3} + env(safe-area-inset-bottom));
border-radius: $r-card $r-card 0 0;
background: #fff;
box-shadow: $shadow-raised;
transform: translateY(100%);
}
.ph.is-open .ph__sheet { transform: translateY(0); }
어디서 깨지는가 — 함정
이 편에서 제일 오래 잡고 있던 건 애니메이션이 아니라 대표 이미지로 잘려 나가는 한 프레임이었습니다. 여기 쓰는 렌더 도구는 2초 루프를 24장으로 뜯은 뒤 앞 장과 가장 많이 달라진 장을 대표 이미지로 고릅니다. 06번은 커서가 줄 사이를 지나는 동안 오른쪽 패널이 바뀌도록 짜여 있었는데, 하필 그 교체 순간이 가장 크게 달라진 장이라 커서는 둘째 줄에 있고 패널은 셋째 줄 내용인 장면이 대표 이미지로 뽑혔습니다. 고친 방법은 두 가지였습니다. 먼저 커서가 줄에 닿아 선 뒤에 패널이 바뀌도록 시점을 어긋나게 두고, 미리보기마다 바탕색까지 다르게 줘서 교체 순간이 확실히 가장 큰 변화가 되게 했습니다. 여기서 하나 더 걸렸는데, 24장의 마지막 장이 2초의 95.8% 지점이라 96%에 넣어 둔 「처음으로 되돌아오는 교체」는 아예 촬영되지 않았습니다. 되돌리는 시점을 90%로 당기고 나서야 한 바퀴가 끊기지 않았습니다.
둘째 함정은 입력칸의 글자 두 겹입니다. 02·07·08번은 자동 재생에서 「사람이 치고 있는 글자」를 보여 주려고 입력칸 위에 글자를 한 겹 얹었는데, 그 아래 placeholder가 그대로 남아 두 글자가 겹쳐 보였습니다. 「월간 리포트」와 「명령 검색」이 겹쳐 읽을 수 없는 낱말이 된 화면이 그대로 대표 이미지가 됐습니다. 자동 재생 동안에만 ::placeholder를 투명으로 두면 끝나는 일이었지만, 캡처를 열어 보기 전까지는 아무 게이트도 이걸 잡아 주지 않았습니다.
셋째는 폰 폭입니다. 01번은 입력칸 하나에 머리글 둘, 줄 넷이라 세로가 186px이 나왔는데, 320×200 화면에서 무대 여백을 빼면 쓸 수 있는 높이는 174px입니다. 12px 모자라서 폰에서는 마지막 줄이 잘렸습니다. 줄 높이를 24px에서 22px로, 머리글은 line-height를 12px로 고정하고, 카드의 세로 패딩만 8px에서 4px로 줄여 164px로 맞췄습니다. 가로 패딩은 그대로 뒀기 때문에 넓은 화면에서의 인상은 바뀌지 않았습니다. 설치 명령을 복사하게 만드는 상자도 같은 자리 문제를 겪는데 터미널 UI가 그 판을 통째로 다룹니다. 이 세 가지를 다 반영한 소스는 압축 비밀번호 vswx5meq로 열리는 zip 안에 아홉 벌 그대로 들어 있습니다.
넷째는 08번에서 나왔습니다. 결과 패널을 접을 때 처음에는 scaleY(0)을 썼는데, 위 모서리를 축으로 줄이면 그 안의 글자까지 같이 눌려 납작해집니다. 절반쯤 접힌 순간의 글자가 세로로 찌그러진 채 남아 있어서, 접는 동작이 아니라 화면이 망가진 것처럼 보였습니다. clip-path: inset()은 요소를 그린 다음 잘라내기만 하므로 글자 비율이 그대로입니다. 대신 overflow: hidden과 달리 둥근 모서리를 따로 적어 줘야 해서 round 16px를 붙였습니다.
접근성
아홉 개 모두 초점은 입력칸에 붙여 둔 채로 움직입니다. 화살표를 눌러도 focus()가 줄로 옮겨 가지 않고, 지금 가리키는 줄은 입력칸의 aria-activedescendant가 바뀌면서 알려집니다. 이렇게 하는 이유는 입력칸이 계속 글자를 받아야 하기 때문이고, 이 패턴의 정의와 요구 사항은 MDN의 combobox 역할 문서에 정리돼 있습니다. 오버레이 자체에는 role="dialog"와 aria-modal="true"가 붙어 뒤 화면이 아니라 이 상자가 지금의 맥락임을 알립니다.
| 키 | 아홉 개에서 하는 일 |
|---|---|
| ⌘K · Ctrl+K | 팔레트 열고 닫기 (01·09). 맥과 윈도를 한 번에 받으려면 metaKey와 ctrlKey를 둘 다 본다 |
| ↑ ↓ | 줄 이동. 03은 여기서 scrollIntoView({ block: 'nearest' })가 함께 돈다 |
| Enter | 선 줄 실행. aria-selected가 그 줄로 옮겨 간다 |
| Escape | 닫기 또는 입력 비우기. 초점은 focus({ preventScroll: true })로 돌려준다 |
| Backspace | 05에서만 특별하다 — 입력칸이 비어 있으면 글자가 아니라 한 단계를 지운다 |
prefers-reduced-motion: reduce에서는 자동 재생 루프만 멈추고 지금 상태는 그대로 남깁니다. 05의 칩은 미끄러지지 않고 바로 자리에 서고, 08의 패널은 잘려 나가는 과정 없이 펼쳐진 채로 나타나며, 09의 시트는 올라오는 길 없이 붙은 상태로 있습니다. 어느 줄이 선택돼 있는지는 움직임을 다 꺼도 aria-selected가 계속 말해 줍니다. 초점을 되돌릴 때 preventScroll을 붙이는 이유는 이 데모들이 글 안에 iframe으로 들어 있어서, 옵션 없이 focus()를 부르면 읽던 사람의 화면이 데모 쪽으로 끌려가기 때문입니다.
같은 화면에서 함께 쓰는 부품은 대시보드 카테고리에, 눌러서 여는 부품은 클릭 카테고리에 모아 두었습니다.
FAQ
검색창 디자인 css 만 놓고 보면 왜 role="dialog"까지 붙이나요?
떠 있는 상자가 뒤 화면을 가리는 순간부터 그것은 입력칸이 아니라 화면 하나이기 때문입니다. role="dialog"와 aria-modal="true"가 없으면 스크린 리더는 뒤 화면의 링크와 버튼을 계속 읽을 수 있는 것으로 안내하고, 사용자는 자기가 어디에 있는지 알 수 없게 됩니다. 반대로 페이지 안에 그대로 앉아 아무것도 가리지 않는 검색칸이라면 dialog 없이 combobox만으로도 충분합니다. 팔레트의 키보드 조작 전체를 안내하는 창은 키보드 단축키 UI가 키캡 표현부터 풀어 놨습니다.
부분 수열 검색이 부분 문자열보다 항상 나은가요?
목록이 짧으면 오히려 나쁩니다. 건너뛰며 맞추는 방식은 걸리는 줄이 많아지므로, 선택지가 열 개 안쪽이면 엉뚱한 줄이 위에 오는 일이 잦습니다. 02번처럼 쓰려면 걸린 자리의 개수와 간격으로 점수를 매겨 정렬까지 해 주는 편이 낫고, 이 편의 02번은 정렬 없이 순서만 지킨 최소 구현입니다.
아홉 개를 한 화면에 같이 넣어도 되나요?
단축키가 겹치지 않는지만 보면 됩니다. 01번과 09번이 같은 ⌘K를 듣고 있어서 그대로 두 개를 올리면 한 번 눌렀을 때 둘 다 열립니다. zip 안에서는 데모마다 따로 돌게 파일이 나뉘어 있으니, 실제 화면에 얹을 때는 단축키를 듣는 곳을 하나로 모으고 나머지는 그 하나가 열어 주는 방식으로 바꾸는 것이 안전합니다.








