09아래에서 올라오는 시트형 팔레트
폰 폭에서는 가운데 모달 대신 시트가 아래에서 올라와 붙고, 손잡이 막대를 내리면 같은 길로 다시 내려갑니다.
쓰임키보드가 화면 절반을 먹는 폰에서 가운데 모달이 입력칸째 가려질 때 같은 팔레트를 시트로 바꿔 달 때
translateYrole=dialog$r-card

<main class="stage">
<div class="ph is-demo is-open">
<div class="ph__page" aria-hidden="true">
<span class="ph__ln" style="width: 56%"></span>
<span class="ph__ln" style="width: 84%"></span>
<span class="ph__ln" style="width: 72%"></span>
<span class="ph__ln" style="width: 40%"></span>
</div>
<span class="ph__dim" aria-hidden="true"></span>
<div class="ph__sheet" role="dialog" aria-modal="true" aria-label="명령 팔레트">
<button class="ph__grab" id="grab" type="button" aria-label="팔레트 닫기"></button>
<div class="ph__bar">
<span class="ph__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="ph__input" id="q" type="text" role="combobox" aria-expanded="true" aria-controls="list" aria-label="명령 검색" placeholder="명령 검색" autocomplete="off">
</div>
<div class="ph__list" id="list" role="listbox" aria-label="명령 팔레트">
<div class="ph__opt" id="o1" role="option" aria-selected="false" tabindex="-1">문서 만들기</div>
<div class="ph__opt" id="o2" role="option" aria-selected="false" tabindex="-1">팀원 초대</div>
<div class="ph__opt" id="o3" role="option" aria-selected="false" tabindex="-1">설정 열기</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
// 폰 화면 한 칸 — 시트는 이 상자 바닥에서 올라온다
.ph {
position: relative;
box-sizing: border-box;
width: 168px; height: 156px;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink;
}
.ph__page { box-sizing: border-box; padding: $sp-4 $sp-3; }
.ph__ln {
display: block;
height: 6px;
margin: 0 0 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .14);
}
.ph__dim {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, .45);
opacity: 0;
}
.ph.is-open .ph__dim { opacity: 1; }
.ph__sheet {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
padding: $sp-2 $sp-2 calc(#{$sp-3} + env(safe-area-inset-bottom));
border-radius: $r-card $r-card 0 0;
background: #fff;
box-shadow: $shadow-raised;
transform: translateY(100%);
}
.ph.is-open .ph__sheet { transform: translateY(0); }
.ph__grab {
display: block;
width: 32px; height: 4px;
margin: 0 auto $sp-2;
padding: 0;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .2);
cursor: pointer;
}
.ph__grab:focus-visible { outline: 2px solid $color; outline-offset: 4px; }
.ph__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);
}
.ph__mag { display: inline-flex; color: rgba(23, 20, 26, .42); }
.ph__input {
flex: 1 1 auto;
min-width: 0;
border: 0; background: transparent;
font-family: inherit;
font-size: 11px; font-weight: 600;
color: $subject-ink;
}
.ph__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-xs; }
.ph__list { margin: $sp-2 0 0; }
.ph__opt {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center;
padding: 0 $sp-2;
border-radius: $r-sm;
font-size: 11px; font-weight: 600;
color: rgba(23, 20, 26, .82);
white-space: nowrap;
cursor: pointer;
}
.ph__opt.is-at { background: rgba(47, 109, 246, .12); color: $color; }
// ── 자동 루프: 시트가 바닥으로 내려갔다 같은 길로 다시 올라온다 ──
// 올라올 때와 내려갈 때가 같은 축(아래 끝)이라 한 바퀴 끝에 순간이동하는 프레임이 없다
.ph.is-demo .ph__sheet { animation: sheet-slide $dur-loop $easing infinite; }
.ph.is-demo .ph__dim { animation: dim-fade $dur-loop $easing infinite; }
@keyframes sheet-slide {
0%, 34% { transform: translateY(0); }
56%, 70% { transform: translateY(100%); }
92%, 100% { transform: translateY(0); }
}
@keyframes dim-fade {
0%, 34% { opacity: 1; }
56%, 70% { opacity: 0; }
92%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__sheet,
.ph.is-demo .ph__dim { 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);
}
.ph {
position: relative;
box-sizing: border-box;
width: 168px;
height: 156px;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ph__page {
box-sizing: border-box;
padding: 16px 12px;
}
.ph__ln {
display: block;
height: 6px;
margin: 0 0 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
}
.ph__dim {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.45);
opacity: 0;
}
.ph.is-open .ph__dim {
opacity: 1;
}
.ph__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
padding: 8px 8px calc(12px + env(safe-area-inset-bottom));
border-radius: 16px 16px 0 0;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(100%);
}
.ph.is-open .ph__sheet {
transform: translateY(0);
}
.ph__grab {
display: block;
width: 32px;
height: 4px;
margin: 0 auto 8px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.2);
cursor: pointer;
}
.ph__grab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 4px;
}
.ph__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);
}
.ph__mag {
display: inline-flex;
color: rgba(23, 20, 26, 0.42);
}
.ph__input {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
font-family: inherit;
font-size: 11px;
font-weight: 600;
color: #17141a;
}
.ph__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.ph__list {
margin: 8px 0 0;
}
.ph__opt {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
padding: 0 8px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.82);
white-space: nowrap;
cursor: pointer;
}
.ph__opt.is-at {
background: rgba(47, 109, 246, 0.12);
color: #2f6df6;
}
.ph.is-demo .ph__sheet {
animation: sheet-slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.ph.is-demo .ph__dim {
animation: dim-fade 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes sheet-slide {
0%, 34% {
transform: translateY(0);
}
56%, 70% {
transform: translateY(100%);
}
92%, 100% {
transform: translateY(0);
}
}
@keyframes dim-fade {
0%, 34% {
opacity: 1;
}
56%, 70% {
opacity: 0;
}
92%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ph.is-demo .ph__sheet,
.ph.is-demo .ph__dim {
animation: none !important;
}
}// 실제 동작: ⌘K(Ctrl+K)로 시트를 올리고 내린다. 손잡이를 누르거나 Escape 를 쳐도 같은 길로 내려간다.
var ph = document.querySelector('.ph');
var input = document.getElementById('q');
var grab = document.getElementById('grab');
var opts = [].slice.call(document.querySelectorAll('.ph__opt'));
var at = -1;
function open(on) {
ph.classList.remove('is-demo');
ph.classList.toggle('is-open', on);
if (on) input.focus({ preventScroll: true });
else { opts.forEach(function (o) { o.classList.remove('is-at'); }); at = -1; }
}
grab.addEventListener('click', function () { open(false); });
document.addEventListener('keydown', function (e) {
var k = e.key.toLowerCase();
if ((e.metaKey || e.ctrlKey) && k === 'k') {
open(!ph.classList.contains('is-open')); e.preventDefault(); return;
}
if (!ph.classList.contains('is-open')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
ph.classList.remove('is-demo');
at = Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), opts.length - 1));
opts.forEach(function (o, i) { o.classList.toggle('is-at', i === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
e.preventDefault();
} else if (e.key === 'Enter' && opts[at]) {
opts.forEach(function (o) { o.setAttribute('aria-selected', String(o === opts[at])); });
e.preventDefault();
} else if (e.key === 'Escape') {
open(false);
}
});
opts.forEach(function (o, i) {
o.addEventListener('mousemove', function () {
ph.classList.remove('is-demo');
at = i; opts.forEach(function (x, k) { x.classList.toggle('is-at', k === i); });
});
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SheetPalette.module.scss";
export type SheetPaletteProps = {
placeholder?: string;
options?: string[];
closeLabel?: string;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
export default function SheetPalette({
placeholder = "Search commands",
options = ["Create document", "Invite member", "Open settings"],
closeLabel = "Close palette",
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SheetPaletteProps) {
const [open, setOpen] = useState(true);
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 };
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);
}, []);
useEffect(() => {
if (open) inputRef.current?.focus({ preventScroll: true });
else setAt(-1);
}, [open]);
return (
<div className={`${styles.ph} ${open ? styles.isOpen : ""}`} style={vars}>
<div className={styles.page} aria-hidden="true">
{[56, 84, 72, 40].map((w) => <span className={styles.ln} style={{ width: `${w}%` }} key={w} />)}
</div>
<span className={styles.dim} aria-hidden="true" />
<div className={styles.sheet} role="dialog" aria-modal="true" aria-label="Command palette">
{/* 손잡이 막대는 장식이 아니라 닫는 버튼이다 — 터치에서 시트를 내리는 유일한 길 */}
<button className={styles.grab} type="button" aria-label={closeLabel} onClick={() => setOpen(false)} />
<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={open}
aria-controls="ms-list"
aria-activedescendant={at >= 0 ? `ms-o${at}` : 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), options.length - 1)));
} else if (e.key === "Enter" && at >= 0) {
e.preventDefault();
setPicked(options[at]);
} else if (e.key === "Escape") {
setOpen(false);
}
}}
/>
</div>
<div className={styles.list} id="ms-list" role="listbox" aria-label="Command palette">
{options.map((label, i) => (
<div
key={label}
id={`ms-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={label === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(label)}
>
{label}
</div>
))}
</div>
</div>
</div>
);
}09 아래에서 올라오는 시트형 팔레트 — 측정 누적면적 18.065% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 검색창 디자인 9종 — Cmd+K로 열리는 팔레트
이 데모가 속한 글
css 검색창 디자인 9종 — Cmd+K로 열리는 팔레트css 검색창 디자인 가운데 커맨드 팔레트는 화면 위에 떠서 명령과 문서를 한 칸에서 찾게 하는 오버레이입니다. 여기 아홉 개 중 하나는 결과가 하나도 없을 때 오히려 줄이 늘어나고, 다른 하나는 목록 없이 입력칸만 이 글의 데모 전부 보기


