06右に付くプレビューパネル
カーソルのある行の中身が右半分にすぐ出て、行が変わるたびにパネルの中だけが1フレームで丸ごと入れ替わります。
使いどころ選ぶ前に確認が要る対象(文書の中身・設定値・宛先)で、名前だけでは選べないリストに付けるとき
grid-template-columnssteps(1, end)aria-describedby

<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-describedby="pane" aria-label="文書を検索" placeholder="文書を検索" autocomplete="off">
</div>
<div class="pal__body">
<div class="pal__side">
<span class="pal__cursor" aria-hidden="true"></span>
<div class="pal__list" id="list" role="listbox" aria-label="文書一覧">
<div class="pal__opt" id="o1" role="option" aria-selected="false" tabindex="-1">週次レポート</div>
<div class="pal__opt" id="o2" role="option" aria-selected="false" tabindex="-1">予算シート</div>
<div class="pal__opt" id="o3" role="option" aria-selected="false" tabindex="-1">会議メモ</div>
</div>
</div>
<div class="pal__pane" id="pane">
<div class="pal__pv" id="p1">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><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>
<p class="pal__pvt">週次レポート</p>
<span class="pal__ln" style="width: 90%"></span>
<span class="pal__ln" style="width: 64%"></span>
<span class="pal__tag">報告</span>
</div>
<div class="pal__pv" id="p2">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M216 64v128H88V64Z" opacity=".2"/><path d="M80 64a8 8 0 0 1 8-8h128a8 8 0 0 1 0 16H88a8 8 0 0 1-8-8m136 56H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16m0 64H88a8 8 0 1 0 0 16h128a8 8 0 0 0 0-16M44 52a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12m0 64a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>
<p class="pal__pvt">予算シート</p>
<span class="pal__ln" style="width: 56%"></span>
<span class="pal__ln" style="width: 88%"></span>
<span class="pal__tag">予算</span>
</div>
<div class="pal__pv" id="p3">
<span class="pal__pvic" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="m200 88l-72 72H96v-32l72-72Z" opacity=".2"/><path d="m229.66 58.34l-32-32a8 8 0 0 0-11.32 0l-96 96A8 8 0 0 0 88 128v32a8 8 0 0 0 8 8h32a8 8 0 0 0 5.66-2.34l96-96a8 8 0 0 0 0-11.32M124.69 152H104v-20.69l64-64L188.69 88ZM200 76.69L179.31 56L192 43.31L212.69 64ZM224 128v80a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h80a8 8 0 0 1 0 16H48v160h160v-80a8 8 0 0 1 16 0"/></g></svg></span>
<p class="pal__pvt">会議メモ</p>
<span class="pal__ln" style="width: 72%"></span>
<span class="pal__ln" style="width: 50%"></span>
<span class="pal__tag">会議</span>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.pal {
box-sizing: border-box;
width: min(272px, 90vw);
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 {
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; }
.pal__body {
display: grid;
grid-template-columns: 1fr 104px;
gap: $sp-2;
margin: $sp-2 0 0;
}
.pal__side { position: relative; box-sizing: border-box; height: 92px; }
.pal__cursor {
position: absolute;
left: 0; top: 0;
z-index: 0;
width: 100%; height: 24px;
border-radius: $r-sm;
background: rgba(47, 109, 246, .14);
opacity: 0;
}
.pal__list { position: relative; z-index: 1; }
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center;
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, .14); color: $color; }
.pal__pane {
position: relative;
box-sizing: border-box;
height: 92px;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
overflow: hidden;
}
.pal__pv {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: $sp-2;
opacity: 0;
}
.pal__pv:first-child { opacity: 1; }
.pal__pv.is-on { opacity: 1; }
.pal__pvic { display: block; color: $color; }
.pal__pvt {
margin: $sp-1 0 $sp-2;
font-size: 10px; font-weight: 800;
color: rgba(23, 20, 26, .78);
white-space: nowrap;
}
.pal__ln {
display: block;
height: 4px;
margin: 0 0 $sp-1;
border-radius: $r-xs;
background: rgba(23, 20, 26, .16);
}
.pal__tag {
display: inline-flex; align-items: center;
height: 14px;
margin: $sp-1 0 0;
padding: 0 $sp-2;
border-radius: $r-pill;
font-size: 8px; font-weight: 800;
color: #fff;
}
// 세 미리보기의 바탕색까지 다르게 둔다 — 교체 순간이 이 데모에서 가장 크게 변하는 프레임이 되어야
// poster(가장 크게 변하는 프레임)가 「커서는 아직 이동 중인데 패널만 바뀐」 장면으로 잡히지 않는다
#p1 { background: rgba(47, 109, 246, .1); }
#p2 { background: rgba(23, 20, 26, .07); }
#p3 { background: rgba(76, 212, 166, .16); }
#p1 .pal__tag { background: $color; }
#p2 .pal__tag { background: $subject-ink; }
#p3 .pal__tag { background: $subject-mint; }
#p2 .pal__pvic { color: $subject-ink; }
#p3 .pal__pvic { color: $subject-mint; }
// ── 자동 루프: 커서가 세 줄을 짚는 동안 오른쪽 패널 내용만 한 프레임에 통째로 바뀐다 ──
// 세 미리보기가 같은 자리에 겹쳐 있으므로 steps(1) 로 끊는다 — 두 장이 동시에 비치는 프레임이 없다
.pal.is-demo .pal__cursor { opacity: 1; animation: cur-step $dur-loop $easing infinite; }
.pal.is-demo #p1 { animation: pv-1 $dur-loop steps(1, end) infinite; }
.pal.is-demo #p2 { animation: pv-2 $dur-loop steps(1, end) infinite; }
.pal.is-demo #p3 { animation: pv-3 $dur-loop steps(1, end) infinite; }
// 커서가 줄에 **닿아 선 뒤에** 패널이 바뀌어야 한다 — 포스터로 쓰이는 「가장 크게 변하는 프레임」이
// 곧 교체 프레임이라, 교체가 커서 이동 중에 일어나면 커서 1번·패널 3번인 장면이 대표 이미지가 된다 (초안 실측).
// 마지막 프레임이 2초의 95.8% 지점이라 되돌아오는 교체도 90% 안에 끝내야 샘플에 잡힌다
@keyframes cur-step {
0%, 18% { transform: translateY(0); }
30%, 50% { transform: translateY(24px); }
62%, 74% { transform: translateY(48px); }
86%, 100% { transform: translateY(0); }
}
@keyframes pv-1 {
0%, 33% { opacity: 1; }
34%, 89% { opacity: 0; }
90%, 100% { opacity: 1; }
}
@keyframes pv-2 {
0%, 33% { opacity: 0; }
34%, 65% { opacity: 1; }
66%, 100% { opacity: 0; }
}
@keyframes pv-3 {
0%, 65% { opacity: 0; }
66%, 89% { opacity: 1; }
90%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__pv { animation: none !important; }
.pal.is-demo .pal__cursor { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pal {
box-sizing: border-box;
width: min(272px, 90vw);
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 {
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__body {
display: grid;
grid-template-columns: 1fr 104px;
gap: 8px;
margin: 8px 0 0;
}
.pal__side {
position: relative;
box-sizing: border-box;
height: 92px;
}
.pal__cursor {
position: absolute;
left: 0;
top: 0;
z-index: 0;
width: 100%;
height: 24px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.14);
opacity: 0;
}
.pal__list {
position: relative;
z-index: 1;
}
.pal__opt {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
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.14);
color: #2f6df6;
}
.pal__pane {
position: relative;
box-sizing: border-box;
height: 92px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
overflow: hidden;
}
.pal__pv {
position: absolute;
inset: 0;
box-sizing: border-box;
padding: 8px;
opacity: 0;
}
.pal__pv:first-child {
opacity: 1;
}
.pal__pv.is-on {
opacity: 1;
}
.pal__pvic {
display: block;
color: #2f6df6;
}
.pal__pvt {
margin: 4px 0 8px;
font-size: 10px;
font-weight: 800;
color: rgba(23, 20, 26, 0.78);
white-space: nowrap;
}
.pal__ln {
display: block;
height: 4px;
margin: 0 0 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.16);
}
.pal__tag {
display: inline-flex;
align-items: center;
height: 14px;
margin: 4px 0 0;
padding: 0 8px;
border-radius: 999px;
font-size: 8px;
font-weight: 800;
color: #fff;
}
#p1 {
background: rgba(47, 109, 246, 0.1);
}
#p2 {
background: rgba(23, 20, 26, 0.07);
}
#p3 {
background: rgba(76, 212, 166, 0.16);
}
#p1 .pal__tag {
background: #2f6df6;
}
#p2 .pal__tag {
background: #17141a;
}
#p3 .pal__tag {
background: #4cd4a6;
}
#p2 .pal__pvic {
color: #17141a;
}
#p3 .pal__pvic {
color: #4cd4a6;
}
.pal.is-demo .pal__cursor {
opacity: 1;
animation: cur-step 2s ease-out infinite;
}
.pal.is-demo #p1 {
animation: pv-1 2s steps(1, end) infinite;
}
.pal.is-demo #p2 {
animation: pv-2 2s steps(1, end) infinite;
}
.pal.is-demo #p3 {
animation: pv-3 2s steps(1, end) infinite;
}
@keyframes cur-step {
0%, 18% {
transform: translateY(0);
}
30%, 50% {
transform: translateY(24px);
}
62%, 74% {
transform: translateY(48px);
}
86%, 100% {
transform: translateY(0);
}
}
@keyframes pv-1 {
0%, 33% {
opacity: 1;
}
34%, 89% {
opacity: 0;
}
90%, 100% {
opacity: 1;
}
}
@keyframes pv-2 {
0%, 33% {
opacity: 0;
}
34%, 65% {
opacity: 1;
}
66%, 100% {
opacity: 0;
}
}
@keyframes pv-3 {
0%, 65% {
opacity: 0;
}
66%, 89% {
opacity: 1;
}
90%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.pal.is-demo .pal__cursor,
.pal.is-demo .pal__pv {
animation: none !important;
}
.pal.is-demo .pal__cursor {
opacity: 1;
}
}// 실제 동작: ↑↓ 로 줄을 옮기면 오른쪽 패널이 그 줄의 미리보기로 통째로 바뀐다 (겹치는 프레임 없이 교체).
var pal = document.querySelector('.pal');
var input = document.getElementById('q');
var opts = [].slice.call(document.querySelectorAll('.pal__opt'));
var pvs = [].slice.call(document.querySelectorAll('.pal__pv'));
var at = 0;
function show(i) {
pal.classList.remove('is-demo');
at = Math.max(0, Math.min(i, opts.length - 1));
opts.forEach(function (o, k) { o.classList.toggle('is-at', k === at); });
pvs.forEach(function (p, k) { p.classList.toggle('is-on', k === at); });
input.setAttribute('aria-activedescendant', opts[at].id);
}
document.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
show(at + (e.key === 'ArrowDown' ? 1 : -1)); e.preventDefault();
} else if (e.key === 'Enter') {
opts.forEach(function (o, k) { o.setAttribute('aria-selected', String(k === at)); });
e.preventDefault();
} else if (e.key === 'Escape') {
input.value = ''; input.focus({ preventScroll: true });
}
});
opts.forEach(function (o, i) { o.addEventListener('mousemove', function () { show(i); }); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PreviewPanePalette.module.scss";
export type PreviewDoc = { title: string; tag: string; lines: number[]; tint: string };
export type PreviewPanePaletteProps = {
placeholder?: string;
docs?: PreviewDoc[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PaneVars = CSSProperties & { "--tint"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_DOCS: PreviewDoc[] = [
{ title: "Weekly report", tag: "Report", lines: [90, 64], tint: "rgba(47, 109, 246, .1)" },
{ title: "Budget sheet", tag: "Budget", lines: [56, 88], tint: "rgba(23, 20, 26, .07)" },
{ title: "Meeting notes", tag: "Meeting", lines: [72, 50], tint: "rgba(76, 212, 166, .16)" },
];
export default function PreviewPanePalette({
placeholder = "Search documents",
docs = DEFAULT_DOCS,
duration = 140,
easing = "ease-out",
color = "#2f6df6",
}: PreviewPanePaletteProps) {
const [at, setAt] = useState(0);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const doc = docs[at];
const paneVars: PaneVars = { "--tint": doc.tint };
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="pp-list"
aria-describedby="pp-pane"
aria-activedescendant={`pp-o${at}`}
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), docs.length - 1)));
} else if (e.key === "Enter") {
e.preventDefault();
setPicked(doc.title);
} else if (e.key === "Escape") {
inputRef.current?.focus({ preventScroll: true });
}
}}
/>
</div>
<div className={styles.body}>
<div className={styles.list} id="pp-list" role="listbox" aria-label="Document list">
{docs.map((d, i) => (
<div
key={d.title}
id={`pp-o${i}`}
className={`${styles.opt} ${i === at ? styles.isAt : ""}`}
role="option"
aria-selected={d.title === picked}
tabIndex={-1}
onMouseMove={() => setAt(i)}
onClick={() => setPicked(d.title)}
>
{d.title}
</div>
))}
</div>
{/* 줄이 바뀌면 패널 내용이 통째로 갈린다 — 페이드 없이 한 번에 (두 장이 겹쳐 비치지 않게) */}
<div className={styles.pane} id="pp-pane" style={paneVars} key={doc.title}>
<span className={styles.pvic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="14" height="14">
<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>
<p className={styles.pvt}>{doc.title}</p>
{doc.lines.map((w, i) => <span className={styles.ln} style={{ width: `${w}%` }} key={i} />)}
<span className={styles.tag}>{doc.tag}</span>
</div>
</div>
</div>
);
}06 右に付くプレビューパネル — 計測 累積面積 13.172% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · 検索窓 デザイン css 9選 — Cmd+Kで開くパレット
このデモが属する記事
検索窓 デザイン css 9選 — Cmd+Kで開くパレット検索窓 デザイン css の中でもコマンドパレットは、画面の上に浮かんでコマンドと文書を1つの欄から探すオーバーレイです。9つのうち1つは該当なしのときにむしろ行が増え、もう1つはリストを持たず入力欄だけを先に出します。 この記事のデモをすべて見る

