01Recent items on open
The palette opens with nothing typed yet, and the commands you used last already stand under the Recent and Go to headings.
Best forWhen the obvious next command (the doc you just left, the screen you always open) should appear before anyone types
role=listboxrole=grouparia-labelledby

<main class="stage">
<div class="pal is-demo" role="dialog" aria-modal="true" aria-label="Command palette">
<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="Search commands" placeholder="Search commands" autocomplete="off">
<kbd class="pal__kbd">⌘K</kbd>
</div>
<div class="pal__list" id="list" role="listbox" aria-label="Command palette">
<div class="pal__grp" role="group" aria-labelledby="g1">
<p class="pal__head" id="g1">Recent</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">Weekly report draft</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">Budget sheet</span>
</div>
</div>
<div class="pal__grp" role="group" aria-labelledby="g2">
<p class="pal__head" id="g2">Go to</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">Dashboard</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">Settings</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)로 열고 닫기, ↑↓ 로 줄 Go to, 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 Recent items on open — Measured area 2.486% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · Command Palette UI Design — 9 That Really Open
The post this demo lives in
Command Palette UI Design — 9 That Really OpenCommand palette UI design is the pattern behind every Cmd+K overlay: one floating field that searches commands, document See all demos in this post
If you like this effect - similar demos
Dashboard UI09 Candlestick Chart Drawing Open-High-Low-Close With Body and Wick9 Data Visualization Chart Types With Real Math
Dashboard UI08 A Pie That Springs Open Into a Donut Chart While the Total Counts UpPie Chart CSS: 9 Slices That Pop, Tilt and Morph
Dashboard UI01 Standalone sidebar navDashboard Sidebar Design + 9 Widget UI Pieces