05한 단계 들어가는 브레드크럼
명령을 고르면 입력칸 앞에 칩이 하나 생기며 목록이 그 명령의 대상 목록으로 갈리고, Backspace 를 치면 칩이 빠지며 되돌아옵니다.
쓰임「초대」처럼 명령 하나에 대상(누구를·어디로)이 더 필요해 팔레트를 닫지 않고 한 단계 들어가야 할 때
BackspacetranslateXaria-live

<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 포함 · css 검색창 디자인 9종 — Cmd+K로 열리는 팔레트
이 데모가 속한 글
css 검색창 디자인 9종 — Cmd+K로 열리는 팔레트css 검색창 디자인 가운데 커맨드 팔레트는 화면 위에 떠서 명령과 문서를 한 칸에서 찾게 하는 오버레이입니다. 여기 아홉 개 중 하나는 결과가 하나도 없을 때 오히려 줄이 늘어나고, 다른 하나는 목록 없이 입력칸만 이 글의 데모 전부 보기


