07빈손으로 끝나지 않는 결과 없음
맞는 줄이 하나도 없으면 안내 한 줄만 두지 않고, 방금 친 글자를 그대로 담은 실행 가능한 줄 두 개가 대신 올라옵니다.
쓰임검색이 빗나갔을 때 사용자를 막다른 곳에 두지 않고 「그 이름으로 새로 만들기」 같은 다음 행동으로 넘겨야 할 때
hiddenrole=optionopacity

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


