07커맨드 팔레트 (⌘K)
버튼을 누르거나 Ctrl(⌘)+K를 누르면 패널이 실제로 화면 가운데서 커지며 열리고, 입력한 글자가 들어간 명령만 실제로 남습니다.
쓰임완성된 검색 화면 없이 헤더에 빠른 이동·검색 팝업 하나만 붙이고 싶을 때
keydownEscapescale

<main class="stage">
<div class="cp">
<button class="cp__trigger" type="button">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M116 40a76 76 0 1 0 76 76a76 76 0 0 0-76-76" opacity=".2"/><path d="m228.24 219.76l-51.38-51.38a92.12 92.12 0 1 0-8.48 8.48l51.38 51.38a6 6 0 0 0 8.48-8.48M36 116a80 80 0 1 1 80 80a80.09 80.09 0 0 1-80-80"/></g></svg>
빠른 이동 <kbd class="cp__kbd">Ctrl K</kbd>
</button>
<div class="cp__overlay is-demo">
<div class="cp__backdrop"></div>
<div class="cp__panel" role="dialog" aria-modal="true" aria-label="커맨드 팔레트">
<div class="cp__search-row">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M116 40a76 76 0 1 0 76 76a76 76 0 0 0-76-76" opacity=".2"/><path d="m228.24 219.76l-51.38-51.38a92.12 92.12 0 1 0-8.48 8.48l51.38 51.38a6 6 0 0 0 8.48-8.48M36 116a80 80 0 1 1 80 80a80.09 80.09 0 0 1-80-80"/></g></svg>
<input class="cp__input" type="text" placeholder="명령 검색…" aria-label="명령 검색">
</div>
<ul class="cp__list" role="listbox">
<li class="cp__item is-active" role="option" tabindex="-1" data-kw="이벤트 일정 캘린더 추가">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 32h-24v-8a8 8 0 0 0-16 0v8H88v-8a8 8 0 0 0-16 0v8H48a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h160a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H48V96h160Z"/></g></svg></span>
새 이벤트 추가
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="태그 라벨 관리">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M243.31 136L144 36.69A15.86 15.86 0 0 0 132.69 32H40a8 8 0 0 0-8 8v92.69A15.86 15.86 0 0 0 36.69 144L136 243.31a16 16 0 0 0 22.63 0l84.68-84.68a16 16 0 0 0 0-22.63M96 84a12 12 0 1 1 12-12a12 12 0 0 1-12 12"/></g></svg></span>
태그 관리
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="프로필 계정 설정">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24m0 192a87.7 87.7 0 0 1-56.61-20.68a72 72 0 0 1 113.22 0A87.7 87.7 0 0 1 128 216"/></g></svg></span>
프로필 설정
</li>
<li class="cp__item" role="option" tabindex="-1" data-kw="내보내기 다운로드 csv">
<span class="cp__ic"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M224 144v64a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8v-64a8 8 0 0 1 16 0v56h160v-56a8 8 0 0 1 16 0M93.66 77.66L120 51.31V144a8 8 0 0 0 16 0V51.31l26.34 26.35a8 8 0 0 0 11.32-11.32l-40-40a8 8 0 0 0-11.32 0l-40 40a8 8 0 0 0 11.32 11.32"/></g></svg></span>
파일 내보내기
</li>
</ul>
<p class="cp__empty" hidden>일치하는 명령이 없습니다</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-ink);
.cp { font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif; }
.cp__trigger {
display: inline-flex; align-items: center; gap: $sp-2;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, .25);
background: rgba(255, 247, 230, .06);
color: $color;
padding: $sp-2 $sp-3;
border-radius: $r-control;
font: inherit; font-size: 12px; font-weight: 600;
cursor: pointer;
}
.cp__kbd {
margin-left: $sp-1;
font: inherit; font-size: 9px; font-weight: 700;
line-height: 1.6;
padding: 0 $sp-1;
border-radius: $r-xs;
background: rgba(255, 247, 230, .12);
}
.cp__overlay {
position: fixed; inset: 0;
display: grid; place-items: center;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
.cp__overlay.is-open { opacity: 1; pointer-events: auto; }
.cp__backdrop {
position: absolute; inset: 0;
background: rgba(0, 0, 0, .55);
}
.cp__panel {
position: relative;
box-sizing: border-box;
width: min(230px, 82vw);
background: #232028;
border-radius: $r-card;
box-shadow: $shadow-raised;
padding: $sp-2;
transform: scale(.92);
transform-origin: center;
transition: transform $duration $easing;
}
.cp__overlay.is-open .cp__panel { transform: scale(1); }
.cp__search-row {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-1 $sp-2;
border-bottom: 1px solid rgba(255, 247, 230, .12);
color: rgba(255, 247, 230, .5);
margin: 0 0 $sp-1;
}
.cp__input {
flex: 1 1 auto; min-width: 0;
border: 0; background: transparent; outline: none;
color: $color;
font: inherit; font-size: 12px;
padding: $sp-1 0;
}
.cp__input::placeholder { color: rgba(255, 247, 230, .4); }
.cp__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; max-height: 132px; overflow: hidden; }
.cp__item {
display: flex; align-items: center; gap: $sp-2;
padding: $sp-2;
border-radius: $r-xs;
color: rgba(255, 247, 230, .75);
font-size: 12px;
cursor: pointer;
}
.cp__ic { flex: 0 0 auto; color: rgba(255, 247, 230, .5); }
.cp__item.is-active { background: rgba(255, 247, 230, .1); color: $color; }
.cp__item.is-active .cp__ic { color: $color; }
.cp__item[hidden] { display: none; }
.cp__empty { font-size: 11px; color: rgba(255, 247, 230, .5); padding: $sp-2; margin: 0; }
// ── 자동 루프: 팔레트가 열렸다 닫혔다를 반복한다 (실제 상태와 별개) ──────────────
.cp__overlay.is-demo { animation: cp-open 2s $easing infinite; }
.cp__overlay.is-demo .cp__panel { animation: cp-panel 2s $easing infinite; }
@keyframes cp-open {
0%, 100% { opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
}
@keyframes cp-panel {
0%, 100% { transform: scale(.92); }
20% { transform: scale(1); }
80% { transform: scale(1); }
}
@media (max-width: 340px) {
.cp__trigger { font-size: 11px; padding: $sp-1 $sp-2; }
.cp__panel { width: min(210px, 80vw); }
}
@media (prefers-reduced-motion: reduce) {
.cp__overlay.is-demo, .cp__overlay.is-demo .cp__panel { animation: none !important; }
.cp__overlay, .cp__panel { transition: none; }
}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);
}
.cp {
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cp__trigger {
display: inline-flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
border: 1px solid rgba(255, 247, 230, 0.25);
background: rgba(255, 247, 230, 0.06);
color: #fff7e6;
padding: 8px 12px;
border-radius: 12px;
font: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cp__kbd {
margin-left: 4px;
font: inherit;
font-size: 9px;
font-weight: 700;
line-height: 1.6;
padding: 0 4px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.12);
}
.cp__overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
opacity: 0;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cp__overlay.is-open {
opacity: 1;
pointer-events: auto;
}
.cp__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
}
.cp__panel {
position: relative;
box-sizing: border-box;
width: min(230px, 82vw);
background: #232028;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 8px;
transform: scale(0.92);
transform-origin: center;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.cp__overlay.is-open .cp__panel {
transform: scale(1);
}
.cp__search-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-bottom: 1px solid rgba(255, 247, 230, 0.12);
color: rgba(255, 247, 230, 0.5);
margin: 0 0 4px;
}
.cp__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
outline: none;
color: #fff7e6;
font: inherit;
font-size: 12px;
padding: 4px 0;
}
.cp__input::placeholder {
color: rgba(255, 247, 230, 0.4);
}
.cp__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
max-height: 132px;
overflow: hidden;
}
.cp__item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 4px;
color: rgba(255, 247, 230, 0.75);
font-size: 12px;
cursor: pointer;
}
.cp__ic {
flex: 0 0 auto;
color: rgba(255, 247, 230, 0.5);
}
.cp__item.is-active {
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
}
.cp__item.is-active .cp__ic {
color: #fff7e6;
}
.cp__item[hidden] {
display: none;
}
.cp__empty {
font-size: 11px;
color: rgba(255, 247, 230, 0.5);
padding: 8px;
margin: 0;
}
.cp__overlay.is-demo {
animation: cp-open 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.cp__overlay.is-demo .cp__panel {
animation: cp-panel 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes cp-open {
0%, 100% {
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
}
@keyframes cp-panel {
0%, 100% {
transform: scale(0.92);
}
20% {
transform: scale(1);
}
80% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.cp__trigger {
font-size: 11px;
padding: 4px 8px;
}
.cp__panel {
width: min(210px, 80vw);
}
}
@media (prefers-reduced-motion: reduce) {
.cp__overlay.is-demo, .cp__overlay.is-demo .cp__panel {
animation: none !important;
}
.cp__overlay, .cp__panel {
transition: none;
}
}// 실제 동작: 버튼이나 Ctrl(⌘)+K로 열리고, 입력하면 실제로 그 글자가 든 명령만 남으며, 방향키·Enter로 고른다.
var trigger = document.querySelector('.cp__trigger');
var overlay = document.querySelector('.cp__overlay');
var input = document.querySelector('.cp__input');
var items = document.querySelectorAll('.cp__item');
var empty = document.querySelector('.cp__empty');
var activeIndex = 0;
function visibleItems() { return Array.prototype.filter.call(items, function (it) { return !it.hidden; }); }
function setActive(i, vis) {
vis = vis || visibleItems();
items.forEach(function (it) { it.classList.remove('is-active'); });
if (vis[i]) vis[i].classList.add('is-active');
activeIndex = i;
}
function filter() {
var q = input.value.trim().toLowerCase();
items.forEach(function (it) { it.hidden = q !== '' && it.dataset.kw.toLowerCase().indexOf(q) === -1; });
var vis = visibleItems();
empty.hidden = vis.length !== 0;
setActive(0, vis);
}
function open() {
overlay.classList.remove('is-demo');
overlay.classList.add('is-open');
input.value = '';
filter();
input.focus({ preventScroll: true });
}
function close() {
overlay.classList.remove('is-open');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
document.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); open(); }
else if (e.key === 'Escape' && overlay.classList.contains('is-open')) close();
});
overlay.addEventListener('click', function (e) {
if (e.target === overlay || e.target.classList.contains('cp__backdrop')) close();
});
input.addEventListener('input', filter);
input.addEventListener('keydown', function (e) {
var vis = visibleItems();
if (e.key === 'ArrowDown') { e.preventDefault(); setActive(Math.min(activeIndex + 1, vis.length - 1), vis); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(Math.max(activeIndex - 1, 0), vis); }
else if (e.key === 'Enter') { var cur = vis[activeIndex]; if (cur) close(); }
});
items.forEach(function (it) { it.addEventListener('click', close); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CommandPalette.module.scss";
export type CommandPaletteProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const COMMANDS = [
{ id: "event", label: "Add new event", kw: "event calendar schedule" },
{ id: "tags", label: "Manage tags", kw: "tag label" },
{ id: "profile", label: "Profile settings", kw: "profile account settings" },
{ id: "export", label: "Export file", kw: "export download csv" },
];
export default function CommandPalette({ duration = 220, easing = "cubic-bezier(.2,1.4,.4,1)", color = "#fff7e6" }: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [active, setActive] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const visible = COMMANDS.filter((c) => !query.trim() || c.kw.includes(query.trim().toLowerCase()));
function openPalette() {
setOpen(true); setQuery(""); setActive(0);
requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));
}
function close() {
setOpen(false);
triggerRef.current?.focus({ preventScroll: true });
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); openPalette(); }
else if (e.key === "Escape" && open) close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
});
return (
<div className={styles.cp} style={vars}>
<button ref={triggerRef} type="button" className={styles.trigger} onClick={openPalette}>
🔍 Quick jump <kbd className={styles.kbd}>Ctrl K</kbd>
</button>
{open && (
<div className={styles.overlay} onClick={(e: any) => { if (e.target === e.currentTarget) close(); }}>
<div className={styles.panel} role="dialog" aria-modal="true" aria-label="Command palette">
<div className={styles.searchRow}>
🔍
<input
ref={inputRef}
className={styles.input}
type="text"
placeholder="Search commands…"
aria-label="Command search"
value={query}
onChange={(e: any) => { setQuery(e.target.value); setActive(0); }}
onKeyDown={(e: any) => {
if (e.key === "ArrowDown") { e.preventDefault(); setActive((i) => Math.min(i + 1, visible.length - 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((i) => Math.max(i - 1, 0)); }
else if (e.key === "Enter" && visible[active]) close();
}}
/>
</div>
<ul className={styles.list} role="listbox">
{visible.map((c, i) => (
<li key={c.id} className={`${styles.item} ${i === active ? styles.active : ""}`} role="option" onClick={close}>
{c.label}
</li>
))}
</ul>
{visible.length === 0 && <p className={styles.empty}>No matching commands</p>}
</div>
</div>
)}
</div>
);
}07 커맨드 팔레트 (⌘K) — 측정 누적면적 95.078% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 대시보드 컴포넌트 9종 — 화면 통째로 말고 이 부품만
이 데모가 속한 글
대시보드 컴포넌트 9종 — 화면 통째로 말고 이 부품만대시보드 컴포넌트는 완성된 관리자 화면을 통째로 붙이지 않고 헤더·피드·캘린더처럼 화면 조각 하나만 그대로 옮겨 쓰는 UI 부품입니다. 아홉 개 중 하나는 Ctrl(⌘)+K로 여는 커맨드 팔레트인데, 방향키로 실제로 이 글의 데모 전부 보기


