01開いた瞬間に出る最近の項目
ショートカットで開くと、まだ一文字も打っていないのに「最近」と「移動」の見出しの下に前回使ったコマンドが並びます。
使いどころ検索語を思いつく前に、使うのが分かりきったコマンド(さっき見た文書・いつも開く画面)を先に見せたいとき
role=listboxrole=grouparia-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 オリジナル · SCSS と React は zip に同梱 · 検索窓 デザイン css 9選 — Cmd+Kで開くパレット
このデモが属する記事
検索窓 デザイン css 9選 — Cmd+Kで開くパレット検索窓 デザイン css の中でもコマンドパレットは、画面の上に浮かんでコマンドと文書を1つの欄から探すオーバーレイです。9つのうち1つは該当なしのときにむしろ行が増え、もう1つはリストを持たず入力欄だけを先に出します。 この記事のデモをすべて見る


