키보드 단축키 UI 9종 — 키캡부터 도움말 시트까지
단축키 UI는 키보드로 화면을 다루게 하는 키캡·표시·안내를 짜는 일입니다. 여기 아홉 가지는 키캡 세 스타일부터 플랫폼 자동 전환·녹화·충돌 표·도움말 시트까지 CSS 와 약간의 JS 로 만들어 둔 것이라, 붙여 넣으면 그대로 돕니다.
01 kbd-keycap-styles
02 kbd-platform-switch
03 shortcut-hint-tooltip
04 kbd-roving-list-nav
05 shortcut-chord-sequence
06 shortcut-recorder-input
07 shortcut-conflict-table
08 shortcut-help-sheet
09 kbd-keycast-stream
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 키캡 스타일 세 가지
- 02 플랫폼 자동 전환 키캡
- 03 단축키 힌트 툴팁
- 04 J·K 목록 이동
- 05 두 키 연속 입력
- 06 단축키 녹화 입력칸
- 07 충돌 감지 표
- 08 물음표 도움말 시트
- 09 눌린 키 흘림 표시
아홉 개를 늘어놓은 기준은 기능의 종류가 아니라 키보드 단축키가 사람을 만나는 순서입니다. 먼저 보이는 쪽 셋 — 키캡 생김새를 고르고(01), 쓰는 사람의 OS 에 맞추고(02), 버튼에 힌트를 붙입니다(03). 다음은 손이 움직이는 쪽 둘 — 목록을 J·K 로 훑고(04), G 누르고 I 누르는 두 키 연속으로 화면을 옮깁니다(05). 그다음은 정의하는 쪽 셋 — 조합을 녹화해 등록하고(06), 겹치는 조합을 표로 잡아내고(07), 물음표로 지금 화면의 단축키를 펼쳐 봅니다(08). 마지막 하나는 남에게 보여 주는 쪽 — 방금 누른 키를 화면 구석에 흘려 녹화와 시연에 씁니다(09). 무대 색은 잉크 셋·옐로 둘·오렌지 둘·종이 둘로 돌려 아홉 칸의 밝기가 한쪽으로 쏠리지 않게 맞췄습니다. 검색창을 여는 커맨드 팔레트와 그 안의 단축키 배지는 커맨드 팔레트 검색 UI 9종이, 영상 화면의 단축키 오버레이는 비디오 플레이어 컨트롤 9종이 다룹니다. 손가락 제스처로 같은 자리를 채우는 쪽은 모바일 터치 제스처 9종에 있습니다. 키 대신 버튼 한 번으로 같은 일을 끝내는 축은 공유 버튼 UI가, 마우스를 따라 장면이 기우는 축은 포인터 따라오는 3D 장면이 각각 맡습니다.
01키캡 스타일 세 가지
무엇이 보이나
같은 글자를 평면·입체·테두리 세 키캡으로 보여줍니다. 입체 키캡의 두께는 box-shadow 로 만들고 누름은 translateY 로만 내려 — scale 로 줄이면 글자까지 눌려 보입니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="keyboard" viewBox="0 0 256 256" fill="currentColor"><path d="M232,64V192a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H224A8,8,0,0,1,232,64Z" opacity="0.2"/><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="kc is-demo" id="kc">
<p class="kc__hint"><svg class="kc__ico" aria-hidden="true"><use href="#keyboard"/></svg><span>A · S · D 를 눌러 보세요</span></p>
<div class="kc__row">
<span class="kc__cell"><kbd class="kc__cap kc__cap--flat" data-key="a">A</kbd><span class="kc__tag">평면</span></span>
<span class="kc__cell"><kbd class="kc__cap kc__cap--raised" data-key="s">S</kbd><span class="kc__tag">입체</span></span>
<span class="kc__cell"><kbd class="kc__cap kc__cap--outline" data-key="d">D</kbd><span class="kc__tag">테두리</span></span>
</div>
<p class="kc__read">
<span class="kc__readLabel">누른 키</span>
<b class="kc__readKey"><span class="kc__readText" id="readText" aria-live="polite">-</span><span class="kc__loop" aria-hidden="true"><i>A</i><i>S</i><i>D</i></span></b>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 키캡 스타일 세 가지 — 평면 · 입체 · 테두리. 입체 키캡의 두께는 box-shadow 로 만든다.
// 누름은 translateY 로만 내린다(scale 로 줄이면 글자까지 눌려 보인다 — blog-demo 규칙 8).
// 격자 미리보기(.is-demo)는 세 키캡이 순서대로 눌리는 2초 CSS 루프. 사람이 실제 키를 누르면 JS 가 루프를 뗀다.
$duration: $dur-instant;
$easing: $ease-spring;
$color: $stage-yellow;
@include stage($stage-ink);
.kc { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; }
.kc__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-cream;
}
.kc__ico { width: 16px; height: 16px; color: $stage-yellow; }
.kc__row { display: flex; align-items: flex-end; justify-content: center; gap: $sp-5; }
.kc__cell { display: flex; flex-direction: column; align-items: center; gap: $sp-2; }
.kc__tag { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba(255, 247, 230, .78); }
// 키캡 공통 — <kbd> 의 기본 서체는 monospace 라 크기·굵기를 낱개 속성으로 다시 정한다 (font 단축은 BD20)
.kc__cap {
display: grid; place-items: center; box-sizing: border-box;
width: 48px; height: 48px; border-radius: $r-control;
font-family: inherit; font-weight: 800; font-size: 20px; line-height: 1;
transition: transform $duration $easing, box-shadow $duration $easing;
}
.kc__cap--flat { background: $subject-cream; color: $subject-ink; }
// 두께 6px 은 그림자로만 만든다 — 누를 때 이 값이 2px 로 줄면서 키가 내려앉는다
.kc__cap--raised { background: $color; color: $subject-ink; box-shadow: 0 6px 0 #a89153; }
.kc__cap--outline { background: transparent; color: $subject-cream; border: 2px solid $subject-cream; }
.kc__cap.is-down { transform: translateY(4px); }
.kc__cap--raised.is-down { transform: translateY(4px); box-shadow: 0 2px 0 #a89153; }
.kc__read { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.kc__readLabel { font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: rgba(255, 247, 230, .78); }
.kc__readKey {
position: relative; display: grid; place-items: center;
min-width: 28px; height: 20px; border-radius: $r-xs; padding: 0 $sp-1;
background: rgba(255, 247, 230, .16); color: $subject-cream;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1;
}
.kc__loop { position: absolute; inset: 0; display: grid; place-items: center; visibility: hidden; }
.kc__loop i { grid-area: 1 / 1; font-style: normal; visibility: hidden; }
// ── 격자 미리보기 루프: 세 키캡이 0.6s 간격으로 눌렸다 올라온다.
// animation-delay 는 낱개 속성으로 — animation 단축은 delay 를 0s 로 되돌린다 (240 실측).
.kc.is-demo .kc__cap {
animation-name: kcPress; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.kc.is-demo .kc__cap--flat { animation-delay: 0ms; }
.kc.is-demo .kc__cap--raised { animation-name: kcPressRaised; animation-delay: 600ms; }
.kc.is-demo .kc__cap--outline { animation-delay: 1200ms; }
@keyframes kcPress {
0%, 4% { transform: translateY(0); }
9%, 22% { transform: translateY(4px); }
28%, 100% { transform: translateY(0); }
}
@keyframes kcPressRaised {
0%, 4% { transform: translateY(0); box-shadow: 0 6px 0 #a89153; }
9%, 22% { transform: translateY(4px); box-shadow: 0 2px 0 #a89153; }
28%, 100% { transform: translateY(0); box-shadow: 0 6px 0 #a89153; }
}
// 누른 키 칸도 같은 박자로 A → S → D 를 보여준다. 글자 교체는 steps(1, end) 로 한 프레임에 컷.
// 미리보기 전용 글자는 color: transparent 로만 감춘다 — display: none 은 칸 폭까지 없앤다 (274 실측)
.kc.is-demo .kc__readText { color: transparent; }
.kc.is-demo .kc__loop { visibility: visible; }
.kc.is-demo .kc__loop i {
animation-name: kcLetter; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.kc.is-demo .kc__loop i:nth-child(1) { animation-delay: 0ms; }
.kc.is-demo .kc__loop i:nth-child(2) { animation-delay: 600ms; }
.kc.is-demo .kc__loop i:nth-child(3) { animation-delay: 1200ms; }
@keyframes kcLetter {
0%, 27% { visibility: visible; }
28%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.kc { gap: $sp-2; }
.kc__row { gap: $sp-3; }
.kc__cap { width: 40px; height: 40px; font-size: 17px; }
.kc__cell { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.kc.is-demo .kc__cap,
.kc.is-demo .kc__loop i { animation: none !important; }
.kc.is-demo .kc__readText { color: $subject-cream !important; }
.kc.is-demo .kc__loop { visibility: hidden !important; }
.kc__cap { transition: 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);
}
.kc {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.kc__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #fff7e6;
}
.kc__ico {
width: 16px;
height: 16px;
color: #ffd23f;
}
.kc__row {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 20px;
}
.kc__cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.kc__tag {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.78);
}
.kc__cap {
display: grid;
place-items: center;
box-sizing: border-box;
width: 48px;
height: 48px;
border-radius: 12px;
font-family: inherit;
font-weight: 800;
font-size: 20px;
line-height: 1;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.kc__cap--flat {
background: #fff7e6;
color: #17141a;
}
.kc__cap--raised {
background: #ffd23f;
color: #17141a;
box-shadow: 0 6px 0 #a89153;
}
.kc__cap--outline {
background: transparent;
color: #fff7e6;
border: 2px solid #fff7e6;
}
.kc__cap.is-down {
transform: translateY(4px);
}
.kc__cap--raised.is-down {
transform: translateY(4px);
box-shadow: 0 2px 0 #a89153;
}
.kc__read {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.kc__readLabel {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: rgba(255, 247, 230, 0.78);
}
.kc__readKey {
position: relative;
display: grid;
place-items: center;
min-width: 28px;
height: 20px;
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 247, 230, 0.16);
color: #fff7e6;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1;
}
.kc__loop {
position: absolute;
inset: 0;
display: grid;
place-items: center;
visibility: hidden;
}
.kc__loop i {
grid-area: 1/1;
font-style: normal;
visibility: hidden;
}
.kc.is-demo .kc__cap {
animation-name: kcPress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.kc.is-demo .kc__cap--flat {
animation-delay: 0ms;
}
.kc.is-demo .kc__cap--raised {
animation-name: kcPressRaised;
animation-delay: 600ms;
}
.kc.is-demo .kc__cap--outline {
animation-delay: 1200ms;
}
@keyframes kcPress {
0%, 4% {
transform: translateY(0);
}
9%, 22% {
transform: translateY(4px);
}
28%, 100% {
transform: translateY(0);
}
}
@keyframes kcPressRaised {
0%, 4% {
transform: translateY(0);
box-shadow: 0 6px 0 #a89153;
}
9%, 22% {
transform: translateY(4px);
box-shadow: 0 2px 0 #a89153;
}
28%, 100% {
transform: translateY(0);
box-shadow: 0 6px 0 #a89153;
}
}
.kc.is-demo .kc__readText {
color: transparent;
}
.kc.is-demo .kc__loop {
visibility: visible;
}
.kc.is-demo .kc__loop i {
animation-name: kcLetter;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.kc.is-demo .kc__loop i:nth-child(1) {
animation-delay: 0ms;
}
.kc.is-demo .kc__loop i:nth-child(2) {
animation-delay: 600ms;
}
.kc.is-demo .kc__loop i:nth-child(3) {
animation-delay: 1200ms;
}
@keyframes kcLetter {
0%, 27% {
visibility: visible;
}
28%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.kc {
gap: 8px;
}
.kc__row {
gap: 12px;
}
.kc__cap {
width: 40px;
height: 40px;
font-size: 17px;
}
.kc__cell {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.kc.is-demo .kc__cap,
.kc.is-demo .kc__loop i {
animation: none !important;
}
.kc.is-demo .kc__readText {
color: #fff7e6 !important;
}
.kc.is-demo .kc__loop {
visibility: hidden !important;
}
.kc__cap {
transition: none !important;
}
}var root = document.getElementById('kc');
var readText = document.getElementById('readText');
var caps = Array.prototype.slice.call(document.querySelectorAll('.kc__cap'));
function capFor(key) {
var want = String(key || '').toLowerCase();
for (var i = 0; i < caps.length; i++) {
if (caps[i].getAttribute('data-key') === want) { return caps[i]; }
}
return null;
}
window.addEventListener('keydown', function (e) {
var cap = capFor(e.key);
if (!cap) { return; }
root.classList.remove('is-demo');
cap.classList.add('is-down');
readText.textContent = cap.getAttribute('data-key').toUpperCase();
});
window.addEventListener('keyup', function (e) {
var cap = capFor(e.key);
if (cap) { cap.classList.remove('is-down'); }
});// KeycapStyles — vanilla 판(run/288/demo/01-kbd-keycap-styles/index.html) 의 React 포트.
// 평면·입체·테두리 세 키캡. 실제 키 입력에 반응해 해당 키캡만 translateY 로 내려앉았다가 keyup 에 돌아온다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./KeycapStyles.module.scss";
export type KeycapStylesProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 키를 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Cap = { key: string; label: string; tag: string; face: string };
/** vanilla 의 data-key 세 개와 같은 순서 */
const CAPS: Cap[] = [
{ key: "a", label: "A", tag: "평면", face: "capFlat" },
{ key: "s", label: "S", tag: "입체", face: "capRaised" },
{ key: "d", label: "D", tag: "테두리", face: "capOutline" },
];
export default function KeycapStyles({
demo = true,
duration = 140,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffd23f",
}: KeycapStylesProps) {
const [live, setLive] = useState(demo);
const [down, setDown] = useState("");
const [readText, setReadText] = useState("-");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function handleDown(e: KeyboardEvent) {
const want = e.key.toLowerCase();
if (!CAPS.some((cap) => cap.key === want)) {
return;
}
setLive(false);
setDown(want);
setReadText(want.toUpperCase());
}
function handleUp(e: KeyboardEvent) {
const want = e.key.toLowerCase();
if (CAPS.some((cap) => cap.key === want)) {
setDown("");
}
}
window.addEventListener("keydown", handleDown);
window.addEventListener("keyup", handleUp);
return () => {
window.removeEventListener("keydown", handleDown);
window.removeEventListener("keyup", handleUp);
};
}, []);
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="keyboard" viewBox="0 0 256 256" fill="currentColor">
<path d="M232,64V192a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H224A8,8,0,0,1,232,64Z" opacity="0.2" />
<path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z" />
</symbol>
</defs>
</svg>
<div className={cls}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#keyboard" /></svg>
<span>A · S · D 를 눌러 보세요</span>
</p>
<div className={styles.row}>
{CAPS.map((cap) => (
<span className={styles.cell} key={cap.key}>
<kbd className={[styles.cap, styles[cap.face], down === cap.key ? styles.isDown : ""].filter(Boolean).join(" ")}>
{cap.label}
</kbd>
<span className={styles.tag}>{cap.tag}</span>
</span>
))}
</div>
<p className={styles.read}>
<span className={styles.readLabel}>누른 키</span>
<b className={styles.readKey}>
<span className={styles.readText} aria-live="polite">{readText}</span>
<span className={styles.loop} aria-hidden="true"><i>A</i><i>S</i><i>D</i></span>
</b>
</p>
</div>
</main>
);
}01 · 키캡 스타일 세 가지 — 측정 누적면적 1.499% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
실제로 A·S·D 를 누르면 해당 키캡만 내려앉았다가 keyup 에 돌아오며, 누적면적 1.495%·움직인 프레임 13/23 으로 세 키캡이 돌아가며 눌립니다.
.kc__cap {
display: grid; place-items: center; box-sizing: border-box;
width: 48px; height: 48px; border-radius: $r-control;
font-family: inherit; font-weight: 800; font-size: 20px; line-height: 1;
transition: transform $duration $easing, box-shadow $duration $easing;
}
// 두께 6px 은 그림자로만 만든다 — 누를 때 이 값이 2px 로 줄면서 키가 내려앉는다
.kc__cap--raised { background: $color; color: $subject-ink; box-shadow: 0 6px 0 #b8890a; }
.kc__cap--raised.is-down { transform: translateY(4px); box-shadow: 0 2px 0 #b8890a; }
02플랫폼 자동 전환 키캡
화면에서 일어나는 일
맥에서는 ⌘, 윈도우에서는 Ctrl 로 같은 단축키가 다르게 보입니다. 감지는 userAgentData.platform 을 먼저 보고 없으면 예전 navigator.platform 으로 내려가며, 두 표기는 같은 격자 칸에 겹쳐 visibility 로만 골라 글자가 바뀌어도 줄이 밀리지 않습니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="command" viewBox="0 0 256 256" fill="currentColor"><path d="M208,76h0a28,28,0,0,1-28,28H152V76a28,28,0,0,1,28-28h0A28,28,0,0,1,208,76ZM76,48h0A28,28,0,0,0,48,76h0a28,28,0,0,0,28,28h28V76A28,28,0,0,0,76,48ZM180,152H152v28a28,28,0,0,0,28,28h0a28,28,0,0,0,28-28h0A28,28,0,0,0,180,152ZM48,180h0a28,28,0,0,0,28,28h0a28,28,0,0,0,28-28V152H76A28,28,0,0,0,48,180Z" opacity="0.2"/><path d="M180,144H160V112h20a36,36,0,1,0-36-36V96H112V76a36,36,0,1,0-36,36H96v32H76a36,36,0,1,0,36,36V160h32v20a36,36,0,1,0,36-36ZM160,76a20,20,0,1,1,20,20H160ZM56,76a20,20,0,0,1,40,0V96H76A20,20,0,0,1,56,76ZM96,180a20,20,0,1,1-20-20H96Zm16-68h32v32H112Zm68,88a20,20,0,0,1-20-20V160h20a20,20,0,0,1,0,40Z"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="pf is-demo" id="pf" data-platform="win">
<div class="pf__head">
<p class="pf__title"><svg class="pf__ico" aria-hidden="true"><use href="#command"/></svg><span>표기가 OS 를 따라갑니다</span></p>
<div class="pf__switch" role="group" aria-label="표기할 운영체제">
<button type="button" class="pf__opt" data-os="mac" aria-pressed="false">macOS</button>
<button type="button" class="pf__opt" data-os="win" aria-pressed="true">Windows</button>
</div>
</div>
<ul class="pf__list">
<li class="pf__row">
<span class="pf__act">저장하기</span>
<span class="pf__keys"><kbd class="pf__mod"><span class="pf__mac">⌘</span><span class="pf__win">Ctrl</span></kbd><kbd class="pf__key">S</kbd></span>
</li>
<li class="pf__row">
<span class="pf__act">되돌리기</span>
<span class="pf__keys"><kbd class="pf__mod"><span class="pf__mac">⌘</span><span class="pf__win">Ctrl</span></kbd><kbd class="pf__key">Z</kbd></span>
</li>
<li class="pf__row">
<span class="pf__act">새 창 열기</span>
<span class="pf__keys"><kbd class="pf__mod"><span class="pf__mac">⌘</span><span class="pf__win">Ctrl</span></kbd><kbd class="pf__key">N</kbd></span>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 플랫폼 자동 전환 키캡 — 같은 단축키를 맥에서는 ⌘, 윈도우에서는 Ctrl 로 적는다.
// 두 표기는 같은 격자 칸(grid-area: 1 / 1)에 겹쳐 두고 visibility 로만 고른다.
// 폭이 넓은 Ctrl 쪽에 min-width 를 맞춰 두어 표기가 바뀌어도 줄이 흔들리지 않는다.
// 격자 미리보기(.is-demo)는 1초마다 두 표기를 steps(1, end) 로 컷 교체하고, 바뀌는 키캡만 살짝 튀어오른다.
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-yellow);
.pf { display: flex; flex-direction: column; gap: $sp-3; width: min(100%, 340px); }
.pf__head { display: flex; flex-direction: column; gap: $sp-2; }
.pf__title {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(11px, 3vw, 13px); color: $subject-ink;
}
.pf__ico { width: 16px; height: 16px; color: $subject-ink; }
.pf__switch { display: grid; grid-template-columns: repeat(2, 1fr); gap: $sp-1; padding: $sp-1; background: rgba(23, 20, 26, .12); border-radius: $r-pill; }
.pf__opt {
border: 0; cursor: pointer; background: transparent; color: rgba(23, 20, 26, .72);
border-radius: $r-pill; padding: $sp-1 $sp-2; font-family: inherit;
font-weight: 800; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4;
transition: background $duration $ease-spring, color $duration $ease-spring;
}
.pf__opt[aria-pressed="true"] { background: $color; color: $stage-yellow; }
.pf__opt:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.pf__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-2; }
.pf__row {
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
background: $stage-paper; border-radius: $r-sm; padding: $sp-2 $sp-3;
}
.pf__act { font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-ink; }
.pf__keys { display: flex; align-items: center; gap: $sp-1; }
.pf__mod, .pf__key {
display: grid; place-items: center; box-sizing: border-box; height: 22px;
border-radius: $r-xs; border: 1px solid rgba(23, 20, 26, .22);
background: rgba(23, 20, 26, .06); color: $subject-ink;
font-family: inherit; font-weight: 800; font-size: 11px; line-height: 1;
}
.pf__key { min-width: 22px; padding: 0 $sp-1; }
// Ctrl(넓은 쪽) 폭으로 고정 — 표기가 바뀌어도 줄 전체가 밀리지 않는다
.pf__mod { position: relative; min-width: 34px; padding: 0 $sp-1; }
.pf__mod span { grid-area: 1 / 1; }
.pf[data-platform="win"] .pf__mac { visibility: hidden; }
.pf[data-platform="mac"] .pf__win { visibility: hidden; }
// ── 격자 미리보기 루프: Windows(0~1s) → macOS(1~2s). 컷만 두면 움직인 프레임이 모자라
// 바뀐 키캡이 곧바로 4px 튀어오르는 보조 움직임을 붙인다 (252 실측: 컷 + transform).
.pf.is-demo .pf__mac,
.pf.is-demo .pf__win {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.pf.is-demo .pf__mac { animation-name: pfShowLate; }
.pf.is-demo .pf__win { animation-name: pfShowEarly; }
// 복귀(mac → win)도 루프 안에서 보이게 90% 지점에 둔다 — 24프레임의 마지막 장은 95.8% 지점이라
// 96% 뒤에 둔 복귀는 gif 에도 측정에도 안 잡힌다 (242 실측)
@keyframes pfShowEarly {
0%, 44% { visibility: visible; }
45%, 89% { visibility: hidden; }
90%, 100% { visibility: visible; }
}
@keyframes pfShowLate {
0%, 44% { visibility: hidden; }
45%, 89% { visibility: visible; }
90%, 100% { visibility: hidden; }
}
.pf.is-demo .pf__mod {
animation-name: pfPop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.pf.is-demo .pf__row:nth-child(1) .pf__mod { animation-delay: 0ms; }
.pf.is-demo .pf__row:nth-child(2) .pf__mod { animation-delay: 80ms; }
.pf.is-demo .pf__row:nth-child(3) .pf__mod { animation-delay: 160ms; }
@keyframes pfPop {
0%, 37% { transform: translateY(0); }
45% { transform: translateY(-4px); }
53%, 82% { transform: translateY(0); }
90% { transform: translateY(-4px); }
98%, 100% { transform: translateY(0); }
}
// 두 토글 버튼도 같은 박자로 눌린 쪽이 바뀐다
.pf.is-demo .pf__opt {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.pf.is-demo .pf__opt[data-os="win"] { animation-name: pfOptEarly; }
.pf.is-demo .pf__opt[data-os="mac"] { animation-name: pfOptLate; }
@keyframes pfOptEarly {
0%, 44% { background: $color; color: $stage-yellow; }
45%, 89% { background: transparent; color: rgba(23, 20, 26, .72); }
90%, 100% { background: $color; color: $stage-yellow; }
}
@keyframes pfOptLate {
0%, 44% { background: transparent; color: rgba(23, 20, 26, .72); }
45%, 89% { background: $color; color: $stage-yellow; }
90%, 100% { background: transparent; color: rgba(23, 20, 26, .72); }
}
@media (max-width: 340px) {
.pf { gap: $sp-2; }
.pf__row { padding: $sp-1 $sp-2; }
.pf__list { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.pf.is-demo .pf__mac,
.pf.is-demo .pf__win,
.pf.is-demo .pf__mod,
.pf.is-demo .pf__opt { animation: none !important; }
.pf.is-demo .pf__mac { visibility: hidden !important; }
.pf__opt { transition: none !important; }
}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);
}
.pf {
display: flex;
flex-direction: column;
gap: 12px;
width: min(100%, 340px);
}
.pf__head {
display: flex;
flex-direction: column;
gap: 8px;
}
.pf__title {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 13px);
color: #17141a;
}
.pf__ico {
width: 16px;
height: 16px;
color: #17141a;
}
.pf__switch {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
padding: 4px;
background: rgba(23, 20, 26, 0.12);
border-radius: 999px;
}
.pf__opt {
border: 0;
cursor: pointer;
background: transparent;
color: rgba(23, 20, 26, 0.72);
border-radius: 999px;
padding: 4px 8px;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pf__opt[aria-pressed=true] {
background: #17141a;
color: #ffd23f;
}
.pf__opt:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.pf__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.pf__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: #ffffff;
border-radius: 8px;
padding: 8px 12px;
}
.pf__act {
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.pf__keys {
display: flex;
align-items: center;
gap: 4px;
}
.pf__mod, .pf__key {
display: grid;
place-items: center;
box-sizing: border-box;
height: 22px;
border-radius: 4px;
border: 1px solid rgba(23, 20, 26, 0.22);
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-family: inherit;
font-weight: 800;
font-size: 11px;
line-height: 1;
}
.pf__key {
min-width: 22px;
padding: 0 4px;
}
.pf__mod {
position: relative;
min-width: 34px;
padding: 0 4px;
}
.pf__mod span {
grid-area: 1/1;
}
.pf[data-platform=win] .pf__mac {
visibility: hidden;
}
.pf[data-platform=mac] .pf__win {
visibility: hidden;
}
.pf.is-demo .pf__mac,
.pf.is-demo .pf__win {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pf.is-demo .pf__mac {
animation-name: pfShowLate;
}
.pf.is-demo .pf__win {
animation-name: pfShowEarly;
}
@keyframes pfShowEarly {
0%, 44% {
visibility: visible;
}
45%, 89% {
visibility: hidden;
}
90%, 100% {
visibility: visible;
}
}
@keyframes pfShowLate {
0%, 44% {
visibility: hidden;
}
45%, 89% {
visibility: visible;
}
90%, 100% {
visibility: hidden;
}
}
.pf.is-demo .pf__mod {
animation-name: pfPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pf.is-demo .pf__row:nth-child(1) .pf__mod {
animation-delay: 0ms;
}
.pf.is-demo .pf__row:nth-child(2) .pf__mod {
animation-delay: 80ms;
}
.pf.is-demo .pf__row:nth-child(3) .pf__mod {
animation-delay: 160ms;
}
@keyframes pfPop {
0%, 37% {
transform: translateY(0);
}
45% {
transform: translateY(-4px);
}
53%, 82% {
transform: translateY(0);
}
90% {
transform: translateY(-4px);
}
98%, 100% {
transform: translateY(0);
}
}
.pf.is-demo .pf__opt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pf.is-demo .pf__opt[data-os=win] {
animation-name: pfOptEarly;
}
.pf.is-demo .pf__opt[data-os=mac] {
animation-name: pfOptLate;
}
@keyframes pfOptEarly {
0%, 44% {
background: #17141a;
color: #ffd23f;
}
45%, 89% {
background: transparent;
color: rgba(23, 20, 26, 0.72);
}
90%, 100% {
background: #17141a;
color: #ffd23f;
}
}
@keyframes pfOptLate {
0%, 44% {
background: transparent;
color: rgba(23, 20, 26, 0.72);
}
45%, 89% {
background: #17141a;
color: #ffd23f;
}
90%, 100% {
background: transparent;
color: rgba(23, 20, 26, 0.72);
}
}
@media (max-width: 340px) {
.pf {
gap: 8px;
}
.pf__row {
padding: 4px 8px;
}
.pf__list {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pf.is-demo .pf__mac,
.pf.is-demo .pf__win,
.pf.is-demo .pf__mod,
.pf.is-demo .pf__opt {
animation: none !important;
}
.pf.is-demo .pf__mac {
visibility: hidden !important;
}
.pf__opt {
transition: none !important;
}
}var pf = document.getElementById('pf');
var opts = Array.prototype.slice.call(document.querySelectorAll('.pf__opt'));
function applyPlatform(os) {
pf.setAttribute('data-platform', os);
for (var i = 0; i < opts.length; i++) {
opts[i].setAttribute('aria-pressed', opts[i].getAttribute('data-os') === os ? 'true' : 'false');
}
}
// userAgentData.platform 이 있으면 그걸 먼저 보고, 없는 브라우저에서는 예전 navigator.platform 으로 내려간다
function detectPlatform() {
var data = navigator.userAgentData;
var name = (data && data.platform) || navigator.platform || '';
return /mac|iphone|ipad|ipod/i.test(name) ? 'mac' : 'win';
}
opts.forEach(function (btn) {
btn.addEventListener('click', function () {
pf.classList.remove('is-demo');
applyPlatform(btn.getAttribute('data-os'));
});
});
applyPlatform(detectPlatform());// PlatformKeycap — vanilla 판(run/288/demo/02-kbd-platform-switch/index.html) 의 React 포트.
// userAgentData.platform 을 먼저 보고, 없는 브라우저에서는 예전 navigator.platform 으로 내려간다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PlatformKeycap.module.scss";
export type PlatformKeycapProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 토글을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** navigator.userAgentData 는 아직 타입이 없는 브라우저가 있어 여기서 직접 좁혀 쓴다 */
type UaData = { platform?: string };
const ROWS: { action: string; letter: string }[] = [
{ action: "저장하기", letter: "S" },
{ action: "되돌리기", letter: "Z" },
{ action: "새 창 열기", letter: "N" },
];
function detectPlatform(): string {
const data = (navigator as unknown as { userAgentData?: UaData }).userAgentData;
const name = (data && data.platform) || navigator.platform || "";
return /mac|iphone|ipad|ipod/i.test(name) ? "mac" : "win";
}
export default function PlatformKeycap({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#17141a",
}: PlatformKeycapProps) {
const [live, setLive] = useState(demo);
const [platform, setPlatform] = useState("win");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
setPlatform(detectPlatform());
}, []);
function applyPlatform(os: string) {
setLive(false);
setPlatform(os);
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="command" viewBox="0 0 256 256" fill="currentColor">
<path d="M208,76h0a28,28,0,0,1-28,28H152V76a28,28,0,0,1,28-28h0A28,28,0,0,1,208,76ZM76,48h0A28,28,0,0,0,48,76h0a28,28,0,0,0,28,28h28V76A28,28,0,0,0,76,48ZM180,152H152v28a28,28,0,0,0,28,28h0a28,28,0,0,0,28-28h0A28,28,0,0,0,180,152ZM48,180h0a28,28,0,0,0,28,28h0a28,28,0,0,0,28-28V152H76A28,28,0,0,0,48,180Z" opacity="0.2" />
<path d="M180,144H160V112h20a36,36,0,1,0-36-36V96H112V76a36,36,0,1,0-36,36H96v32H76a36,36,0,1,0,36,36V160h32v20a36,36,0,1,0,36-36ZM160,76a20,20,0,1,1,20,20H160ZM56,76a20,20,0,0,1,40,0V96H76A20,20,0,0,1,56,76ZM96,180a20,20,0,1,1-20-20H96Zm16-68h32v32H112Zm68,88a20,20,0,0,1-20-20V160h20a20,20,0,0,1,0,40Z" />
</symbol>
</defs>
</svg>
<div className={cls} data-platform={platform}>
<div className={styles.head}>
<p className={styles.title}>
<svg className={styles.ico} aria-hidden="true"><use href="#command" /></svg>
<span>표기가 OS 를 따라갑니다</span>
</p>
<div className={styles.switch} role="group" aria-label="표기할 운영체제">
<button type="button" className={styles.opt} data-os="mac" aria-pressed={platform === "mac"} onClick={() => applyPlatform("mac")}>
macOS
</button>
<button type="button" className={styles.opt} data-os="win" aria-pressed={platform === "win"} onClick={() => applyPlatform("win")}>
Windows
</button>
</div>
</div>
<ul className={styles.list}>
{ROWS.map((row) => (
<li className={styles.row} key={row.letter}>
<span className={styles.act}>{row.action}</span>
<span className={styles.keys}>
<kbd className={styles.mod}>
<span className={styles.mac}>{"⌘"}</span>
<span className={styles.win}>Ctrl</span>
</kbd>
<kbd className={styles.key}>{row.letter}</kbd>
</span>
</li>
))}
</ul>
</div>
</main>
);
}02 · 플랫폼 자동 전환 키캡 — 측정 누적면적 6.392% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
표기가 바뀌는 컷마다 키캡이 튀어오르는 보조 움직임을 붙여 누적면적 6.392%·움직인 프레임 11/23 을 기록했습니다.
// userAgentData.platform 이 있으면 그걸 먼저 보고, 없는 브라우저에서는 예전 속성으로 내려간다
function detectPlatform() {
var data = navigator.userAgentData;
var name = (data && data.platform) || navigator.platform || '';
return /mac|iphone|ipad|ipod/i.test(name) ? 'mac' : 'win';
}
03단축키 힌트 툴팁
단축키 안내는 어디에 붙이나
버튼에 마우스를 올리면 그 버튼의 단축키가 작은 키캡으로 떠오릅니다. 같은 값을 aria-keyshortcuts 에도 적어 스크린리더가 버튼 이름과 함께 읽어 주고, 실제로 Ctrl+F 를 누르면 찾기 버튼이 한 번 눌립니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="enter" viewBox="0 0 256 256" fill="currentColor"><path d="M96,128v96L48,176Z" opacity="0.2"/><path d="M192,24a8,8,0,0,0-8,8V168H104V128a8,8,0,0,0-13.66-5.66l-48,48a8,8,0,0,0,0,11.32l48,48A8,8,0,0,0,104,224V184h88a8,8,0,0,0,8-8V32A8,8,0,0,0,192,24ZM88,204.69,59.31,176,88,147.31Z"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="ht is-demo" id="ht">
<p class="ht__cap">버튼에 마우스를 올려 보세요</p>
<div class="ht__bar" role="toolbar" aria-label="편집 도구">
<button type="button" class="ht__btn" data-combo="s" aria-keyshortcuts="Control+S">
<span class="ht__label">저장</span>
<span class="ht__tip" role="tooltip"><kbd class="ht__cap1">Ctrl</kbd><kbd class="ht__cap1">S</kbd></span>
</button>
<button type="button" class="ht__btn" data-combo="f" aria-keyshortcuts="Control+F">
<span class="ht__label">찾기</span>
<span class="ht__tip" role="tooltip"><kbd class="ht__cap1">Ctrl</kbd><kbd class="ht__cap1">F</kbd></span>
</button>
<button type="button" class="ht__btn" data-combo="enter" aria-keyshortcuts="Control+Enter">
<span class="ht__label">실행</span>
<span class="ht__tip" role="tooltip"><kbd class="ht__cap1">Ctrl</kbd><kbd class="ht__cap1"><svg class="ht__ico" aria-hidden="true"><use href="#enter"/></svg></kbd></span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 단축키 힌트 툴팁 — 버튼 아래에 그 버튼의 단축키를 키캡으로 띄운다.
// 같은 값을 HTML 의 aria-keyshortcuts 에도 적어 스크린리더가 버튼 이름과 함께 읽는다.
// 절대배치 툴팁은 부모 높이에서 빠지므로 .ht__bar 에 padding-bottom 으로 자리를 미리 잡는다 (237 실측).
// 나타남은 visibility 컷 + translateY 이동으로 — opacity 페이드는 poster 에 반투명 유령을 남긴다 (248 실측).
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-orange);
.ht { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: 100%; }
.ht__cap {
margin: 0; align-self: flex-start; border-radius: $r-pill; padding: $sp-1 $sp-3;
// 주황 무대 위 크림 글자는 3.11:1 이라 잉크 알약 배경 위로 옮겼다 (실측)
background: $subject-ink; font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1.4; color: $subject-cream;
}
.ht__bar {
display: flex; align-items: flex-start; justify-content: center; gap: $sp-3;
padding-bottom: 40px;
}
.ht__btn {
position: relative; border: 0; cursor: pointer;
background: $subject-cream; color: $color; border-radius: $r-control;
padding: $sp-2 $sp-4; font-family: inherit;
font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1.4;
transition: transform $duration $easing;
}
.ht__btn:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.ht__btn.is-hit { transform: translateY(2px); }
.ht__label { pointer-events: none; }
.ht__tip {
position: absolute; top: calc(100% + 8px); left: 50%; margin-left: -36px;
display: flex; align-items: center; justify-content: center; gap: $sp-1;
width: 72px; box-sizing: border-box;
background: $color; border-radius: $r-sm; padding: $sp-1 $sp-2;
visibility: hidden; transform: translateY(4px);
transition: transform $duration $easing;
}
.ht__cap1 {
display: grid; place-items: center; box-sizing: border-box; height: 18px; min-width: 18px;
border-radius: $r-xs; border: 1px solid rgba(255, 247, 230, .36);
background: rgba(255, 247, 230, .12); color: $subject-cream;
font-family: inherit; font-weight: 800; font-size: 10px; line-height: 1; padding: 0 $sp-1;
}
.ht__ico { width: 12px; height: 12px; }
.ht__btn:hover .ht__tip,
.ht__btn:focus-visible .ht__tip { visibility: visible; transform: translateY(0); }
// ── 격자 미리보기 루프: 세 툴팁이 0.66s 씩 이어 달린다 — 항상 한 개는 떠 있다.
// 툴팁이 전부 닫히는 순간을 루프에 두면 그 빈 화면이 최대 변화 프레임이 되어 poster 가 된다 (255 실측).
.ht.is-demo .ht__tip {
animation-name: htTip; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ht.is-demo .ht__btn:nth-child(1) .ht__tip { animation-delay: 0ms; }
.ht.is-demo .ht__btn:nth-child(2) .ht__tip { animation-delay: 660ms; }
.ht.is-demo .ht__btn:nth-child(3) .ht__tip { animation-delay: 1320ms; }
@keyframes htTip {
0% { visibility: visible; transform: translateY(4px); }
8%, 32% { visibility: visible; transform: translateY(0); }
33%, 100% { visibility: hidden; transform: translateY(0); }
}
.ht.is-demo .ht__btn {
animation-name: htLift; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ht.is-demo .ht__btn:nth-child(1) { animation-delay: 0ms; }
.ht.is-demo .ht__btn:nth-child(2) { animation-delay: 660ms; }
.ht.is-demo .ht__btn:nth-child(3) { animation-delay: 1320ms; }
@keyframes htLift {
0% { transform: translateY(0); }
8%, 30% { transform: translateY(-2px); }
33%, 100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.ht__bar { gap: $sp-1; padding-bottom: 36px; }
.ht__btn { padding: $sp-2 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ht.is-demo .ht__tip,
.ht.is-demo .ht__btn { animation: none !important; }
.ht__tip, .ht__btn { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ht {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
}
.ht__cap {
margin: 0;
align-self: flex-start;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
color: #fff7e6;
}
.ht__bar {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 12px;
padding-bottom: 40px;
}
.ht__btn {
position: relative;
border: 0;
cursor: pointer;
background: #fff7e6;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-family: inherit;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ht__btn:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.ht__btn.is-hit {
transform: translateY(2px);
}
.ht__label {
pointer-events: none;
}
.ht__tip {
position: absolute;
top: calc(100% + 8px);
left: 50%;
margin-left: -36px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 72px;
box-sizing: border-box;
background: #17141a;
border-radius: 8px;
padding: 4px 8px;
visibility: hidden;
transform: translateY(4px);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ht__cap1 {
display: grid;
place-items: center;
box-sizing: border-box;
height: 18px;
min-width: 18px;
border-radius: 4px;
border: 1px solid rgba(255, 247, 230, 0.36);
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-family: inherit;
font-weight: 800;
font-size: 10px;
line-height: 1;
padding: 0 4px;
}
.ht__ico {
width: 12px;
height: 12px;
}
.ht__btn:hover .ht__tip,
.ht__btn:focus-visible .ht__tip {
visibility: visible;
transform: translateY(0);
}
.ht.is-demo .ht__tip {
animation-name: htTip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ht.is-demo .ht__btn:nth-child(1) .ht__tip {
animation-delay: 0ms;
}
.ht.is-demo .ht__btn:nth-child(2) .ht__tip {
animation-delay: 660ms;
}
.ht.is-demo .ht__btn:nth-child(3) .ht__tip {
animation-delay: 1320ms;
}
@keyframes htTip {
0% {
visibility: visible;
transform: translateY(4px);
}
8%, 32% {
visibility: visible;
transform: translateY(0);
}
33%, 100% {
visibility: hidden;
transform: translateY(0);
}
}
.ht.is-demo .ht__btn {
animation-name: htLift;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ht.is-demo .ht__btn:nth-child(1) {
animation-delay: 0ms;
}
.ht.is-demo .ht__btn:nth-child(2) {
animation-delay: 660ms;
}
.ht.is-demo .ht__btn:nth-child(3) {
animation-delay: 1320ms;
}
@keyframes htLift {
0% {
transform: translateY(0);
}
8%, 30% {
transform: translateY(-2px);
}
33%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.ht__bar {
gap: 4px;
padding-bottom: 36px;
}
.ht__btn {
padding: 8px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ht.is-demo .ht__tip,
.ht.is-demo .ht__btn {
animation: none !important;
}
.ht__tip, .ht__btn {
transition: none !important;
}
}var ht = document.getElementById('ht');
var buttons = Array.prototype.slice.call(document.querySelectorAll('.ht__btn'));
function flash(btn) {
btn.classList.add('is-hit');
setTimeout(function () { btn.classList.remove('is-hit'); }, 240);
}
buttons.forEach(function (btn) {
btn.addEventListener('pointerenter', function () { ht.classList.remove('is-demo'); });
btn.addEventListener('focus', function () { ht.classList.remove('is-demo'); });
});
// aria-keyshortcuts 에 적어 둔 조합을 실제로 눌러도 같은 버튼이 반응한다
window.addEventListener('keydown', function (e) {
if (!e.ctrlKey && !e.metaKey) { return; }
var want = e.key === 'Enter' ? 'enter' : String(e.key).toLowerCase();
for (var i = 0; i < buttons.length; i++) {
if (buttons[i].getAttribute('data-combo') === want) {
e.preventDefault();
ht.classList.remove('is-demo');
flash(buttons[i]);
return;
}
}
});// ShortcutHintTooltip — vanilla 판(run/288/demo/03-shortcut-hint-tooltip/index.html) 의 React 포트.
// 버튼마다 aria-keyshortcuts 를 달아 두고, 같은 조합을 실제로 누르면 그 버튼이 한 번 눌린다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShortcutHintTooltip.module.scss";
export type ShortcutHintTooltipProps = {
/** 격자 미리보기용 2초 자동 루프. 마우스를 올리거나 초점이 들어오면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Tool = { combo: string; label: string; shortcut: string; cap: string };
const TOOLS: Tool[] = [
{ combo: "s", label: "저장", shortcut: "Control+S", cap: "S" },
{ combo: "f", label: "찾기", shortcut: "Control+F", cap: "F" },
{ combo: "enter", label: "실행", shortcut: "Control+Enter", cap: "" },
];
export default function ShortcutHintTooltip({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: ShortcutHintTooltipProps) {
const [live, setLive] = useState(demo);
const [hit, setHit] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
let timer = 0;
function handleDown(e: KeyboardEvent) {
if (!e.ctrlKey && !e.metaKey) {
return;
}
const want = e.key === "Enter" ? "enter" : e.key.toLowerCase();
if (!TOOLS.some((tool) => tool.combo === want)) {
return;
}
e.preventDefault();
setLive(false);
setHit(want);
window.clearTimeout(timer);
timer = window.setTimeout(() => setHit(""), 240);
}
window.addEventListener("keydown", handleDown);
return () => {
window.clearTimeout(timer);
window.removeEventListener("keydown", handleDown);
};
}, []);
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="enter" viewBox="0 0 256 256" fill="currentColor">
<path d="M96,128v96L48,176Z" opacity="0.2" />
<path d="M192,24a8,8,0,0,0-8,8V168H104V128a8,8,0,0,0-13.66-5.66l-48,48a8,8,0,0,0,0,11.32l48,48A8,8,0,0,0,104,224V184h88a8,8,0,0,0,8-8V32A8,8,0,0,0,192,24ZM88,204.69,59.31,176,88,147.31Z" />
</symbol>
</defs>
</svg>
<div className={cls}>
<p className={styles.cap}>버튼에 마우스를 올려 보세요</p>
<div className={styles.bar} role="toolbar" aria-label="편집 도구">
{TOOLS.map((tool) => (
<button
type="button"
key={tool.combo}
className={[styles.btn, hit === tool.combo ? styles.isHit : ""].filter(Boolean).join(" ")}
aria-keyshortcuts={tool.shortcut}
onPointerEnter={() => setLive(false)}
onFocus={() => setLive(false)}
>
<span className={styles.label}>{tool.label}</span>
<span className={styles.tip} role="tooltip">
<kbd className={styles.cap1}>Ctrl</kbd>
<kbd className={styles.cap1}>
{tool.cap === "" ? <svg className={styles.ico} aria-hidden="true"><use href="#enter" /></svg> : tool.cap}
</kbd>
</span>
</button>
))}
</div>
</div>
</main>
);
}03 · 단축키 힌트 툴팁 — 측정 누적면적 5.105% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
나타남은 visibility 컷과 translateY 이동으로만 — opacity 페이드는 정지 이미지에 반투명 유령을 남겨 쓰지 않았고, 툴팁이 이어 떠오르는 루프 기준 움직인 프레임은 8/23·누적면적 5.105% 입니다.
.ht__tip {
position: absolute; top: calc(100% + 8px); left: 50%; margin-left: -36px;
display: flex; align-items: center; justify-content: center; gap: $sp-1;
width: 72px; box-sizing: border-box;
background: $color; border-radius: $r-sm; padding: $sp-1 $sp-2;
visibility: hidden; transform: translateY(4px);
transition: transform $duration $easing;
}
.ht__btn:hover .ht__tip,
.ht__btn:focus-visible .ht__tip { visibility: visible; transform: translateY(0); }
04J·K 목록 이동
무엇이 보이나
메일함처럼 J 로 내려가고 K 로 올라가며 Enter 로 여는 목록입니다. 초점은 로빙 tabindex — 한 줄만 tabindex 0 을 가져 Tab 한 번이면 목록 전체를 지나가고, 선택 표시는 줄마다 다시 칠하지 않고 막대 하나를 translateY 로 옮깁니다.

<main class="stage">
<div class="rv is-demo" id="rv">
<p class="rv__cap">J 아래 · K 위 · Enter 열기</p>
<ul class="rv__list" id="list" role="listbox" aria-label="받은 메일" style="--i: 0">
<li class="rv__marker" aria-hidden="true"></li>
<li class="rv__row" role="option" tabindex="0" aria-selected="true"><span class="rv__who">김하늘</span><span class="rv__what">결제 영수증</span></li>
<li class="rv__row" role="option" tabindex="-1" aria-selected="false"><span class="rv__who">배송 알림</span><span class="rv__what">오늘 도착</span></li>
<li class="rv__row" role="option" tabindex="-1" aria-selected="false"><span class="rv__who">팀 노트</span><span class="rv__what">회의 정리</span></li>
<li class="rv__row" role="option" tabindex="-1" aria-selected="false"><span class="rv__who">이든</span><span class="rv__what">주말 일정</span></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// J·K 목록 이동 — 선택 표시는 줄마다 다시 칠하지 않고 하나짜리 막대(.rv__marker)를 옮긴다.
// 옮김이 transform 이라 줄이 늘어나도 레이아웃을 다시 계산하지 않고, 텔레포트 대신 미끄러진다.
// 초점은 로빙 tabindex — 한 줄만 tabindex 0 이라 Tab 한 번에 목록 전체를 지나간다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
// 줄 높이 32px + 줄 간격 4px = 한 칸 36px. 막대 이동 거리와 같은 값이라 한 곳에서만 바꾼다
$row-h: 32px;
$row-step: 36px;
@include stage($stage-paper);
.rv { display: flex; flex-direction: column; gap: $sp-2; width: min(100%, 320px); }
.rv__cap { margin: 0; font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: rgba(23, 20, 26, .72); }
.rv__list {
position: relative; list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: $sp-1;
}
.rv__marker {
position: absolute; left: 0; top: 0; width: 100%; height: $row-h;
border-radius: $r-sm; background: rgba(47, 109, 246, .16);
border-left: 3px solid $color; box-sizing: border-box;
transform: translateY(calc(var(--i, 0) * #{$row-step}));
transition: transform $duration $easing;
}
.rv__row {
position: relative; display: flex; align-items: center; gap: $sp-2;
height: $row-h; box-sizing: border-box; padding: 0 $sp-3;
border-radius: $r-sm; cursor: pointer;
}
.rv__row:focus-visible { outline: 2px solid $subject-ink; outline-offset: 0; }
.rv__who { font-weight: 800; font-size: clamp(11px, 3vw, 12px); color: $subject-ink; }
.rv__what { font-weight: 600; font-size: clamp(11px, 3vw, 12px); color: rgba(23, 20, 26, .66); }
.rv__row.is-open .rv__what { text-decoration: underline; }
// ── 격자 미리보기 루프: 막대가 네 줄을 훑고 90% 안에 첫 줄로 미끄러져 돌아온다 (242 실측: 96% 뒤는 샘플 없음)
.rv.is-demo .rv__marker {
animation-name: rvMove; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes rvMove {
0%, 16% { transform: translateY(0); }
22%, 38% { transform: translateY($row-step); }
44%, 60% { transform: translateY($row-step * 2); }
66%, 84% { transform: translateY($row-step * 3); }
92%, 100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.rv__row { padding: 0 $sp-2; }
.rv__list { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rv.is-demo .rv__marker { animation: none !important; }
.rv__marker { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rv {
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 320px);
}
.rv__cap {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: rgba(23, 20, 26, 0.72);
}
.rv__list {
position: relative;
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.rv__marker {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 32px;
border-radius: 8px;
background: rgba(47, 109, 246, 0.16);
border-left: 3px solid #2f6df6;
box-sizing: border-box;
transform: translateY(calc(var(--i, 0) * 36px));
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rv__row {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 32px;
box-sizing: border-box;
padding: 0 12px;
border-radius: 8px;
cursor: pointer;
}
.rv__row:focus-visible {
outline: 2px solid #17141a;
outline-offset: 0;
}
.rv__who {
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.rv__what {
font-weight: 600;
font-size: clamp(11px, 3vw, 12px);
color: rgba(23, 20, 26, 0.66);
}
.rv__row.is-open .rv__what {
text-decoration: underline;
}
.rv.is-demo .rv__marker {
animation-name: rvMove;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rvMove {
0%, 16% {
transform: translateY(0);
}
22%, 38% {
transform: translateY(36px);
}
44%, 60% {
transform: translateY(72px);
}
66%, 84% {
transform: translateY(108px);
}
92%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.rv__row {
padding: 0 8px;
}
.rv__list {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rv.is-demo .rv__marker {
animation: none !important;
}
.rv__marker {
transition: none !important;
}
}var rv = document.getElementById('rv');
var list = document.getElementById('list');
var rows = Array.prototype.slice.call(document.querySelectorAll('.rv__row'));
var index = 0;
// 로빙 tabindex — 목록 전체가 아니라 한 줄만 tabindex 0 을 갖는다. Tab 한 번이면 목록을 지나간다
function applySelection(next) {
index = Math.max(0, Math.min(rows.length - 1, next));
list.style.setProperty('--i', String(index));
rows.forEach(function (row, i) {
row.setAttribute('tabindex', i === index ? '0' : '-1');
row.setAttribute('aria-selected', i === index ? 'true' : 'false');
row.classList.toggle('is-open', false);
});
// iframe 안에서 preventScroll 없이 부르면 부모 페이지가 이 줄로 끌려 내려간다 (BD18)
rows[index].focus({ preventScroll: true });
}
list.addEventListener('keydown', function (e) {
var key = String(e.key).toLowerCase();
if (key !== 'j' && key !== 'k' && e.key !== 'Enter') { return; }
e.preventDefault();
rv.classList.remove('is-demo');
if (key === 'j') { applySelection(index + 1); }
if (key === 'k') { applySelection(index - 1); }
if (e.key === 'Enter') { rows[index].classList.add('is-open'); }
});
rows.forEach(function (row, i) {
row.addEventListener('click', function () { rv.classList.remove('is-demo'); applySelection(i); });
});// RovingListNav — vanilla 판(run/288/demo/04-kbd-roving-list-nav/index.html) 의 React 포트.
// 로빙 tabindex: 한 줄만 tabindex 0 을 갖고, 초점 이동은 preventScroll 과 함께 부른다 (BD18).
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./RovingListNav.module.scss";
export type RovingListNavProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 키를 누르거나 줄을 클릭하면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string; "--i"?: string };
/** useRef 배열은 T | null 을 돌려주므로 쓰기 전에 null 을 걸러 낸다 */
type RowBox = { current: (HTMLLIElement | null)[] };
/** React 이벤트 제네릭이 ambient 에 없어 쓰는 필드만 지역 타입으로 선언한다 */
type KeyEvt = { key: string; preventDefault(): void };
const ROWS: { who: string; what: string }[] = [
{ who: "김하늘", what: "결제 영수증" },
{ who: "배송 알림", what: "오늘 도착" },
{ who: "팀 노트", what: "회의 정리" },
{ who: "이든", what: "주말 일정" },
];
export default function RovingListNav({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: RovingListNavProps) {
const [live, setLive] = useState(demo);
const [index, setIndex] = useState(0);
const [opened, setOpened] = useState(-1);
const rowRefs = useRef<(HTMLLIElement | null)[]>([]) as RowBox;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color, "--i": String(index) };
function applySelection(next: number) {
const clamped = Math.max(0, Math.min(ROWS.length - 1, next));
setIndex(clamped);
setOpened(-1);
const row = rowRefs.current[clamped];
if (row) {
// iframe 안에서 preventScroll 없이 부르면 부모 페이지가 이 줄로 끌려 내려간다 (BD18)
row.focus({ preventScroll: true });
}
}
function handleKeyDown(e: KeyEvt) {
const key = e.key.toLowerCase();
if (key !== "j" && key !== "k" && e.key !== "Enter") {
return;
}
e.preventDefault();
setLive(false);
if (key === "j") { applySelection(index + 1); }
if (key === "k") { applySelection(index - 1); }
if (e.key === "Enter") { setOpened(index); }
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<p className={styles.cap}>J 아래 · K 위 · Enter 열기</p>
<ul className={styles.list} role="listbox" aria-label="받은 메일" onKeyDown={handleKeyDown}>
<li className={styles.marker} aria-hidden="true" />
{ROWS.map((row, i) => (
<li
key={row.who}
ref={(node: HTMLLIElement | null) => { rowRefs.current[i] = node; }}
className={[styles.row, opened === i ? styles.isOpen : ""].filter(Boolean).join(" ")}
role="option"
tabIndex={i === index ? 0 : -1}
aria-selected={i === index}
onClick={() => { setLive(false); applySelection(i); }}
>
<span className={styles.who}>{row.who}</span>
<span className={styles.what}>{row.what}</span>
</li>
))}
</ul>
</div>
</main>
);
}04 · J·K 목록 이동 — 측정 누적면적 29.468% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
막대가 네 줄을 훑고 돌아오는 루프라 누적면적 29.468% 로 아홉 개 중 두 번째로 크게 움직이고 움직인 프레임은 9/23 입니다.
// 로빙 tabindex — 목록 전체가 아니라 한 줄만 tabindex 0 을 갖는다. Tab 한 번이면 목록을 지나간다
function applySelection(next) {
index = Math.max(0, Math.min(rows.length - 1, next));
list.style.setProperty('--i', String(index));
rows.forEach(function (row, i) {
row.setAttribute('tabindex', i === index ? '0' : '-1');
row.setAttribute('aria-selected', i === index ? 'true' : 'false');
});
// iframe 안에서 preventScroll 없이 부르면 부모 페이지가 이 줄로 끌려 내려간다
rows[index].focus({ preventScroll: true });
}
05두 키 연속 입력
화면에서 일어나는 일
G 를 누르고 이어서 I 를 누르면 받은 편지함으로 갑니다. 첫 키가 들어온 순간 남은 시간이 scaleX 막대로 줄고, 시간이 다 되면 setTimeout 이 대기 상태를 스스로 풀어 쌓인 첫 키가 영영 남지 않게 합니다.

<main class="stage">
<div class="cs is-demo" id="cs">
<p class="cs__cap">G 를 누르고 이어서 I</p>
<div class="cs__keys">
<kbd class="cs__key cs__key--first">G</kbd>
<span class="cs__then" aria-hidden="true">›</span>
<kbd class="cs__key cs__key--second">I</kbd>
</div>
<div class="cs__meter"><span class="cs__fill"></span></div>
<p class="cs__dest">
<span class="cs__destLabel">이동한 곳</span>
<b class="cs__destBox"><span class="cs__destIdle">대기 중</span><span class="cs__destDone">받은 편지함</span></b>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 두 키 연속 입력 — G 다음 I. 남은 시간은 scaleX 로 줄어드는 막대 하나로 보여준다.
// 막대 칸은 grid 한 칸으로 짠다 — flex: 1 1 0 위에 scaleX 를 얹으면 BD19 가 탭 인디케이터로 오인한다 (254 실측).
// 격자 미리보기(.is-demo)는 G 누름 → 막대 줄어듦 → I 누름 → 도착지 컷 → 복귀까지 2초 한 바퀴.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-ink);
.cs { display: flex; flex-direction: column; align-items: center; gap: $sp-3; width: min(100%, 260px); }
.cs__cap { margin: 0; font-weight: 700; font-size: clamp(11px, 3vw, 12px); color: $subject-cream; }
.cs__keys { display: flex; align-items: center; gap: $sp-2; }
.cs__then { font-weight: 800; font-size: 16px; line-height: 1; color: rgba(255, 247, 230, .56); }
.cs__key {
display: grid; place-items: center; box-sizing: border-box;
width: 40px; height: 40px; border-radius: $r-control;
background: rgba(255, 247, 230, .10); border: 2px solid rgba(255, 247, 230, .28); color: $subject-cream;
font-family: inherit; font-weight: 800; font-size: 17px; line-height: 1;
transition: transform $dur-instant $easing, background $dur-instant $easing, border-color $dur-instant $easing;
}
.cs.is-pending .cs__key--first,
.cs.is-done .cs__key--second { background: $color; border-color: $color; color: $subject-ink; transform: translateY(3px); }
.cs__meter {
display: grid; grid-template-columns: 1fr;
width: 100%; height: 6px; border-radius: $r-pill;
background: rgba(255, 247, 230, .14); overflow: hidden;
}
// 왼쪽 변에 붙어 줄어드는 막대라 transform-origin 은 중심이 아니라 left (blog-demo 규칙 8 의 예외)
.cs__fill {
border-radius: $r-pill; background: $color;
transform-origin: left center; transform: scaleX(0);
}
.cs.is-pending .cs__fill { animation: csDrain 1200ms linear both; }
@keyframes csDrain { 0% { transform: scaleX(1); } 100% { transform: scaleX(0); } }
.cs__dest { margin: 0; display: flex; align-items: center; gap: $sp-2; }
.cs__destLabel { font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: rgba(255, 247, 230, .78); }
.cs__destBox {
position: relative; display: grid; place-items: center;
min-width: 88px; height: 22px; border-radius: $r-xs; padding: 0 $sp-2;
background: rgba(255, 247, 230, .12); color: $subject-cream;
font-weight: 800; font-size: clamp(10px, 2.8vw, 11px); line-height: 1;
}
.cs__destBox span { grid-area: 1 / 1; }
.cs__destDone { visibility: hidden; color: $color; }
.cs.is-done .cs__destIdle { visibility: hidden; }
.cs.is-done .cs__destDone { visibility: visible; }
// ── 격자 미리보기 루프 (2초 한 바퀴)
// 0~8% 대기 · 8~28% G 눌림 · 8~56% 막대 줄어듦 · 56~66% I 눌림 · 60% 도착지 컷 · 88% 복귀
.cs.is-demo .cs__key--first {
animation-name: csFirst; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes csFirst {
0%, 7% { transform: translateY(0); background: rgba(255, 247, 230, .10); border-color: rgba(255, 247, 230, .28); color: $subject-cream; }
12%, 84% { transform: translateY(3px); background: $color; border-color: $color; color: $subject-ink; }
90%, 100% { transform: translateY(0); background: rgba(255, 247, 230, .10); border-color: rgba(255, 247, 230, .28); color: $subject-cream; }
}
.cs.is-demo .cs__key--second {
animation-name: csSecond; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes csSecond {
0%, 55% { transform: translateY(0); background: rgba(255, 247, 230, .10); border-color: rgba(255, 247, 230, .28); color: $subject-cream; }
60%, 84% { transform: translateY(3px); background: $color; border-color: $color; color: $subject-ink; }
90%, 100% { transform: translateY(0); background: rgba(255, 247, 230, .10); border-color: rgba(255, 247, 230, .28); color: $subject-cream; }
}
.cs.is-demo .cs__fill {
animation-name: csDrainLoop; animation-duration: $dur-loop; animation-timing-function: linear;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes csDrainLoop {
0%, 8% { transform: scaleX(0); }
9% { transform: scaleX(1); }
56%, 100% { transform: scaleX(.42); }
}
// 도착지 글자는 steps(1, end) 로 한 프레임에 컷 — opacity 교차는 poster 에 두 글자를 겹쳐 남긴다 (237 실측)
.cs.is-demo .cs__destIdle,
.cs.is-demo .cs__destDone {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.cs.is-demo .cs__destIdle { animation-name: csIdleText; }
.cs.is-demo .cs__destDone { animation-name: csDoneText; }
@keyframes csIdleText {
0%, 59% { visibility: visible; }
60%, 87% { visibility: hidden; }
88%, 100% { visibility: visible; }
}
@keyframes csDoneText {
0%, 59% { visibility: hidden; }
60%, 87% { visibility: visible; }
88%, 100% { visibility: hidden; }
}
@media (max-width: 340px) {
.cs { gap: $sp-2; }
.cs__key { width: 36px; height: 36px; font-size: 15px; }
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__key--first,
.cs.is-demo .cs__key--second,
.cs.is-demo .cs__fill,
.cs.is-demo .cs__destIdle,
.cs.is-demo .cs__destDone,
.cs.is-pending .cs__fill { animation: none !important; }
.cs.is-demo .cs__fill { transform: scaleX(.42) !important; }
.cs__key { transition: 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);
}
.cs {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(100%, 260px);
}
.cs__cap {
margin: 0;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
color: #fff7e6;
}
.cs__keys {
display: flex;
align-items: center;
gap: 8px;
}
.cs__then {
font-weight: 800;
font-size: 16px;
line-height: 1;
color: rgba(255, 247, 230, 0.56);
}
.cs__key {
display: grid;
place-items: center;
box-sizing: border-box;
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.1);
border: 2px solid rgba(255, 247, 230, 0.28);
color: #fff7e6;
font-family: inherit;
font-weight: 800;
font-size: 17px;
line-height: 1;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1), background 140ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cs.is-pending .cs__key--first,
.cs.is-done .cs__key--second {
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
transform: translateY(3px);
}
.cs__meter {
display: grid;
grid-template-columns: 1fr;
width: 100%;
height: 6px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.14);
overflow: hidden;
}
.cs__fill {
border-radius: 999px;
background: #4cd4a6;
transform-origin: left center;
transform: scaleX(0);
}
.cs.is-pending .cs__fill {
animation: csDrain 1200ms linear both;
}
@keyframes csDrain {
0% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
.cs__dest {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.cs__destLabel {
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: rgba(255, 247, 230, 0.78);
}
.cs__destBox {
position: relative;
display: grid;
place-items: center;
min-width: 88px;
height: 22px;
border-radius: 4px;
padding: 0 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1;
}
.cs__destBox span {
grid-area: 1/1;
}
.cs__destDone {
visibility: hidden;
color: #4cd4a6;
}
.cs.is-done .cs__destIdle {
visibility: hidden;
}
.cs.is-done .cs__destDone {
visibility: visible;
}
.cs.is-demo .cs__key--first {
animation-name: csFirst;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes csFirst {
0%, 7% {
transform: translateY(0);
background: rgba(255, 247, 230, 0.1);
border-color: rgba(255, 247, 230, 0.28);
color: #fff7e6;
}
12%, 84% {
transform: translateY(3px);
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
90%, 100% {
transform: translateY(0);
background: rgba(255, 247, 230, 0.1);
border-color: rgba(255, 247, 230, 0.28);
color: #fff7e6;
}
}
.cs.is-demo .cs__key--second {
animation-name: csSecond;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes csSecond {
0%, 55% {
transform: translateY(0);
background: rgba(255, 247, 230, 0.1);
border-color: rgba(255, 247, 230, 0.28);
color: #fff7e6;
}
60%, 84% {
transform: translateY(3px);
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
90%, 100% {
transform: translateY(0);
background: rgba(255, 247, 230, 0.1);
border-color: rgba(255, 247, 230, 0.28);
color: #fff7e6;
}
}
.cs.is-demo .cs__fill {
animation-name: csDrainLoop;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes csDrainLoop {
0%, 8% {
transform: scaleX(0);
}
9% {
transform: scaleX(1);
}
56%, 100% {
transform: scaleX(0.42);
}
}
.cs.is-demo .cs__destIdle,
.cs.is-demo .cs__destDone {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cs.is-demo .cs__destIdle {
animation-name: csIdleText;
}
.cs.is-demo .cs__destDone {
animation-name: csDoneText;
}
@keyframes csIdleText {
0%, 59% {
visibility: visible;
}
60%, 87% {
visibility: hidden;
}
88%, 100% {
visibility: visible;
}
}
@keyframes csDoneText {
0%, 59% {
visibility: hidden;
}
60%, 87% {
visibility: visible;
}
88%, 100% {
visibility: hidden;
}
}
@media (max-width: 340px) {
.cs {
gap: 8px;
}
.cs__key {
width: 36px;
height: 36px;
font-size: 15px;
}
}
@media (prefers-reduced-motion: reduce) {
.cs.is-demo .cs__key--first,
.cs.is-demo .cs__key--second,
.cs.is-demo .cs__fill,
.cs.is-demo .cs__destIdle,
.cs.is-demo .cs__destDone,
.cs.is-pending .cs__fill {
animation: none !important;
}
.cs.is-demo .cs__fill {
transform: scaleX(0.42) !important;
}
.cs__key {
transition: none !important;
}
}var cs = document.getElementById('cs');
var pending = false;
var timer = 0;
var WINDOW_MS = 1200;
function startChord() {
pending = true;
cs.classList.add('is-pending');
cs.classList.remove('is-done');
clearTimeout(timer);
// 시간 안에 두 번째 키가 안 오면 대기를 스스로 푼다 — 쌓인 첫 키가 영영 남지 않게
timer = setTimeout(cancelChord, WINDOW_MS);
}
function finishChord() {
clearTimeout(timer);
pending = false;
cs.classList.remove('is-pending');
cs.classList.add('is-done');
}
function cancelChord() {
pending = false;
cs.classList.remove('is-pending');
}
window.addEventListener('keydown', function (e) {
if (e.isComposing || e.ctrlKey || e.metaKey || e.altKey) { return; }
var key = String(e.key).toLowerCase();
if (!pending && key === 'g') { cs.classList.remove('is-demo'); startChord(); return; }
if (pending && key === 'i') { finishChord(); }
});// ChordSequence — vanilla 판(run/288/demo/05-shortcut-chord-sequence/index.html) 의 React 포트.
// G 다음 I. 첫 키가 들어온 뒤 WINDOW_MS 안에 두 번째 키가 안 오면 setTimeout 이 대기를 스스로 푼다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChordSequence.module.scss";
export type ChordSequenceProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 G 를 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** useRef<T> 는 T | null 을 돌려주므로 항상 값이 있는 상자로 좁혀 쓴다 */
type Box<T> = { current: T };
const WINDOW_MS = 1200;
export default function ChordSequence({
demo = true,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#4cd4a6",
}: ChordSequenceProps) {
const [live, setLive] = useState(demo);
const [pending, setPending] = useState(false);
const [done, setDone] = useState(false);
const pendingRef = useRef<boolean>(false) as Box<boolean>;
const timerRef = useRef<number>(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function cancelChord() {
pendingRef.current = false;
setPending(false);
}
function startChord() {
pendingRef.current = true;
setPending(true);
setDone(false);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(cancelChord, WINDOW_MS);
}
function finishChord() {
window.clearTimeout(timerRef.current);
pendingRef.current = false;
setPending(false);
setDone(true);
}
function handleDown(e: KeyboardEvent) {
if (e.isComposing || e.ctrlKey || e.metaKey || e.altKey) {
return;
}
const key = e.key.toLowerCase();
if (!pendingRef.current && key === "g") {
setLive(false);
startChord();
return;
}
if (pendingRef.current && key === "i") {
finishChord();
}
}
window.addEventListener("keydown", handleDown);
return () => {
window.clearTimeout(timerRef.current);
window.removeEventListener("keydown", handleDown);
};
}, []);
const cls = [styles.root, live ? styles.isDemo : "", pending ? styles.isPending : "", done ? styles.isDone : ""]
.filter(Boolean)
.join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<p className={styles.cap}>G 를 누르고 이어서 I</p>
<div className={styles.keys}>
<kbd className={[styles.key, styles.keyFirst].join(" ")}>G</kbd>
<span className={styles.then} aria-hidden="true">{"›"}</span>
<kbd className={[styles.key, styles.keySecond].join(" ")}>I</kbd>
</div>
<div className={styles.meter}>
<span className={styles.fill} />
</div>
<p className={styles.dest}>
<span className={styles.destLabel}>이동한 곳</span>
<b className={styles.destBox}>
<span className={styles.destIdle}>대기 중</span>
<span className={styles.destDone}>받은 편지함</span>
</b>
</p>
</div>
</main>
);
}05 · 두 키 연속 입력 — 측정 누적면적 3.294% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
키가 눌리는 내려앉음과 막대가 줄어드는 구간이 길게 이어져 누적면적 3.647%·움직인 프레임 16/23 이고, 강도 평균 137.8 은 아홉 개 중 가장 높습니다.
var WINDOW_MS = 1200;
function startChord() {
pending = true;
cs.classList.add('is-pending');
cs.classList.remove('is-done');
clearTimeout(timer);
// 시간 안에 두 번째 키가 안 오면 대기를 스스로 푼다 — 쌓인 첫 키가 영영 남지 않게
timer = setTimeout(cancelChord, WINDOW_MS);
}
06단축키 녹화 입력칸
어떤 움직임인가
칸에 초점을 두고 조합을 그대로 누르면 Ctrl+Shift+K 처럼 적힙니다. 칸이 초점을 가진 동안에는 preventDefault 로 브라우저 기본 동작을 막고, 한글·일본어 입력기가 조합 중일 때 오는 키는 isComposing 을 보고 건너뛰며 이미 쓰는 조합이면 테두리와 경고 문장이 붙습니다.

<main class="stage">
<div class="rc is-demo" id="rc">
<p class="rc__label" id="fieldLabel">새 단축키</p>
<div class="rc__field" id="field" tabindex="0" role="textbox" aria-readonly="true" aria-labelledby="fieldLabel">
<span class="rc__slot">
<span class="rc__hint">조합을 눌러 주세요</span>
<span class="rc__combo" id="combo" aria-live="polite"><kbd class="rc__key">Ctrl</kbd><kbd class="rc__key">Shift</kbd><kbd class="rc__key">K</kbd></span>
</span>
<span class="rc__rec" aria-hidden="true"></span>
</div>
<p class="rc__warn">이미 쓰고 있는 조합입니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 단축키 녹화 입력칸 — 칸이 초점을 가진 동안 누른 조합을 그대로 받아 적는다.
// 안내 문구와 키캡 줄은 같은 격자 칸(grid-area: 1 / 1)에 겹쳐 두어 글자가 바뀌어도 칸 높이가 흔들리지 않는다.
// 격자 미리보기(.is-demo)는 Ctrl → Shift → K 가 차례로 찍히고 충돌 경고가 떴다가 90% 안에 되돌아오는 2초 루프.
$duration: $dur-instant;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-yellow);
.rc { display: flex; flex-direction: column; gap: $sp-2; width: min(100%, 300px); }
.rc__label { margin: 0; font-weight: 800; font-size: clamp(11px, 3vw, 12px); color: $subject-ink; }
.rc__field {
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
box-sizing: border-box; min-height: 44px; padding: $sp-2 $sp-3;
background: $stage-paper; border: 2px solid rgba(23, 20, 26, .18); border-radius: $r-control;
transition: border-color $duration $ease-spring;
}
.rc__field:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.rc.is-conflict .rc__field { border-color: $error; }
.rc__slot { display: grid; align-items: center; justify-items: start; min-width: 0; }
.rc__slot > * { grid-area: 1 / 1; }
.rc__hint { font-weight: 600; font-size: clamp(10px, 2.8vw, 11px); color: rgba(23, 20, 26, .72); }
.rc__combo { display: flex; align-items: center; gap: $sp-1; visibility: hidden; }
.rc.has-combo .rc__hint { visibility: hidden; }
.rc.has-combo .rc__combo { visibility: visible; }
.rc__key {
display: grid; place-items: center; box-sizing: border-box; height: 22px; min-width: 22px;
border-radius: $r-xs; border: 1px solid rgba(23, 20, 26, .24);
background: rgba(23, 20, 26, .06); color: $color;
font-family: inherit; font-weight: 800; font-size: 11px; line-height: 1; padding: 0 $sp-1;
}
// 녹화 중임을 알리는 점 — 스스로 완결한 동그라미라 중심에서 커진다 (blog-demo 규칙 8)
.rc__rec {
flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%;
background: $error; transform-origin: 50% 50%;
}
.rc__warn {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: #905347; visibility: hidden;
}
.rc.is-conflict .rc__warn { visibility: visible; }
// ── 격자 미리보기 루프: 0~15% 안내 문구 · 16/34/52% 에 키캡이 하나씩 · 64% 충돌 경고까지 쌓고 멈춘다.
// 루프 안에서 키캡을 다시 지우면 그 빈 칸이 최대 변화 프레임이 되어 poster 로 뽑힌다 (255 실측) —
// 되돌림은 0% 경계에서만 일어나고, poster 는 경고가 켜지는 장면이 된다
.rc.is-demo .rc__hint {
animation-name: rcHint; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes rcHint {
0%, 15% { visibility: visible; }
16%, 100% { visibility: hidden; }
}
.rc.is-demo .rc__combo { visibility: visible; }
.rc.is-demo .rc__key {
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.rc.is-demo .rc__key:nth-child(1) { animation-name: rcKey1; }
.rc.is-demo .rc__key:nth-child(2) { animation-name: rcKey2; }
.rc.is-demo .rc__key:nth-child(3) { animation-name: rcKey3; }
// 등장 시각은 animation-delay 가 아니라 키프레임 퍼센트로 준다 — delay 를 쓰면 세 키캡의 주기가
// 서로 어긋나 복귀 프레임에서 안내 문구와 키캡이 겹친다 (이 편 실측)
// 키캡은 제자리에서 나타난다 — 커지는 중심은 자기 자신 (blog-demo 규칙 8)
@keyframes rcKey1 {
0%, 15% { visibility: hidden; transform: scale(.6); }
16% { visibility: visible; transform: scale(.6); }
22%, 100% { visibility: visible; transform: scale(1); }
}
@keyframes rcKey2 {
0%, 33% { visibility: hidden; transform: scale(.6); }
34% { visibility: visible; transform: scale(.6); }
40%, 100% { visibility: visible; transform: scale(1); }
}
@keyframes rcKey3 {
0%, 51% { visibility: hidden; transform: scale(.6); }
52% { visibility: visible; transform: scale(.6); }
58%, 100% { visibility: visible; transform: scale(1); }
}
.rc.is-demo .rc__rec {
animation-name: rcBlink; animation-duration: $dur-loop; animation-timing-function: $ease-out;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes rcBlink {
0%, 44% { transform: scale(1); }
50% { transform: scale(1.75); }
56%, 100% { transform: scale(1); }
}
.rc.is-demo .rc__warn {
animation-name: rcWarn; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes rcWarn {
0%, 63% { visibility: hidden; }
64%, 100% { visibility: visible; }
}
.rc.is-demo .rc__field {
animation-name: rcBorder; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes rcBorder {
0%, 63% { border-color: rgba(23, 20, 26, .18); }
64%, 100% { border-color: $error; }
}
@media (max-width: 340px) {
.rc__field { min-height: 40px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__hint,
.rc.is-demo .rc__key,
.rc.is-demo .rc__rec,
.rc.is-demo .rc__warn,
.rc.is-demo .rc__field { animation: none !important; }
.rc.is-demo .rc__combo { visibility: hidden !important; }
.rc__field { transition: none !important; }
}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);
}
.rc {
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 300px);
}
.rc__label {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.rc__field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
box-sizing: border-box;
min-height: 44px;
padding: 8px 12px;
background: #ffffff;
border: 2px solid rgba(23, 20, 26, 0.18);
border-radius: 12px;
transition: border-color 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rc__field:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.rc.is-conflict .rc__field {
border-color: #c2361a;
}
.rc__slot {
display: grid;
align-items: center;
justify-items: start;
min-width: 0;
}
.rc__slot > * {
grid-area: 1/1;
}
.rc__hint {
font-weight: 600;
font-size: clamp(10px, 2.8vw, 11px);
color: rgba(23, 20, 26, 0.72);
}
.rc__combo {
display: flex;
align-items: center;
gap: 4px;
visibility: hidden;
}
.rc.has-combo .rc__hint {
visibility: hidden;
}
.rc.has-combo .rc__combo {
visibility: visible;
}
.rc__key {
display: grid;
place-items: center;
box-sizing: border-box;
height: 22px;
min-width: 22px;
border-radius: 4px;
border: 1px solid rgba(23, 20, 26, 0.24);
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-family: inherit;
font-weight: 800;
font-size: 11px;
line-height: 1;
padding: 0 4px;
}
.rc__rec {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
background: #c2361a;
transform-origin: 50% 50%;
}
.rc__warn {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: #905347;
visibility: hidden;
}
.rc.is-conflict .rc__warn {
visibility: visible;
}
.rc.is-demo .rc__hint {
animation-name: rcHint;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rcHint {
0%, 15% {
visibility: visible;
}
16%, 100% {
visibility: hidden;
}
}
.rc.is-demo .rc__combo {
visibility: visible;
}
.rc.is-demo .rc__key {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rc.is-demo .rc__key:nth-child(1) {
animation-name: rcKey1;
}
.rc.is-demo .rc__key:nth-child(2) {
animation-name: rcKey2;
}
.rc.is-demo .rc__key:nth-child(3) {
animation-name: rcKey3;
}
@keyframes rcKey1 {
0%, 15% {
visibility: hidden;
transform: scale(0.6);
}
16% {
visibility: visible;
transform: scale(0.6);
}
22%, 100% {
visibility: visible;
transform: scale(1);
}
}
@keyframes rcKey2 {
0%, 33% {
visibility: hidden;
transform: scale(0.6);
}
34% {
visibility: visible;
transform: scale(0.6);
}
40%, 100% {
visibility: visible;
transform: scale(1);
}
}
@keyframes rcKey3 {
0%, 51% {
visibility: hidden;
transform: scale(0.6);
}
52% {
visibility: visible;
transform: scale(0.6);
}
58%, 100% {
visibility: visible;
transform: scale(1);
}
}
.rc.is-demo .rc__rec {
animation-name: rcBlink;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rcBlink {
0%, 44% {
transform: scale(1);
}
50% {
transform: scale(1.75);
}
56%, 100% {
transform: scale(1);
}
}
.rc.is-demo .rc__warn {
animation-name: rcWarn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rcWarn {
0%, 63% {
visibility: hidden;
}
64%, 100% {
visibility: visible;
}
}
.rc.is-demo .rc__field {
animation-name: rcBorder;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rcBorder {
0%, 63% {
border-color: rgba(23, 20, 26, 0.18);
}
64%, 100% {
border-color: #c2361a;
}
}
@media (max-width: 340px) {
.rc__field {
min-height: 40px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rc.is-demo .rc__hint,
.rc.is-demo .rc__key,
.rc.is-demo .rc__rec,
.rc.is-demo .rc__warn,
.rc.is-demo .rc__field {
animation: none !important;
}
.rc.is-demo .rc__combo {
visibility: hidden !important;
}
.rc__field {
transition: none !important;
}
}var rc = document.getElementById('rc');
var field = document.getElementById('field');
var combo = document.getElementById('combo');
var TAKEN = ['Ctrl+Shift+K'];
var MODIFIER_KEYS = ['Control', 'Alt', 'Shift', 'Meta'];
function renderCombo(parts) {
combo.textContent = '';
parts.forEach(function (part) {
var cap = document.createElement('kbd');
cap.className = 'rc__key';
cap.textContent = part;
combo.appendChild(cap);
});
rc.classList.toggle('has-combo', parts.length > 1);
rc.classList.toggle('is-conflict', TAKEN.indexOf(parts.join('+')) >= 0);
}
field.addEventListener('keydown', function (e) {
// 한글·일본어 입력기가 조합 중일 때 오는 키는 글자를 만드는 중이라 단축키가 아니다
if (e.isComposing) { return; }
if (e.key === 'Tab') { return; }
// 칸이 초점을 가진 동안에는 Ctrl+S 같은 브라우저 기본 동작을 막고 조합만 받아 적는다
e.preventDefault();
rc.classList.remove('is-demo');
var parts = [];
if (e.ctrlKey) { parts.push('Ctrl'); }
if (e.altKey) { parts.push('Alt'); }
if (e.shiftKey) { parts.push('Shift'); }
if (e.metaKey) { parts.push('Meta'); }
if (MODIFIER_KEYS.indexOf(e.key) < 0) {
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
}
renderCombo(parts);
});// ShortcutRecorder — vanilla 판(run/288/demo/06-shortcut-recorder-input/index.html) 의 React 포트.
// 칸이 초점을 가진 동안 누른 조합을 그대로 받아 적는다. IME 조합 중(isComposing)은 건너뛴다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShortcutRecorder.module.scss";
export type ShortcutRecorderProps = {
/** 격자 미리보기용 2초 자동 루프. 칸에서 키를 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** React 이벤트 제네릭이 ambient 에 없어 쓰는 필드만 지역 타입으로 선언한다 */
type KeyEvt = {
key: string;
isComposing: boolean;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
preventDefault(): void;
};
const TAKEN = ["Ctrl+Shift+K"];
const MODIFIER_KEYS = ["Control", "Alt", "Shift", "Meta"];
/** 미리보기 루프가 보여 주는 조합 — 실제 입력이 들어오면 그 조합으로 갈린다 */
const PREVIEW = ["Ctrl", "Shift", "K"];
export default function ShortcutRecorder({
demo = true,
duration = 140,
easing = "cubic-bezier(.2, 1.4, .4, 1)",
color = "#17141a",
}: ShortcutRecorderProps) {
const [live, setLive] = useState(demo);
const [parts, setParts] = useState<string[]>(PREVIEW);
const [recorded, setRecorded] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function handleKeyDown(e: KeyEvt) {
// 한글·일본어 입력기가 조합 중일 때 오는 키는 글자를 만드는 중이라 단축키가 아니다
if (e.isComposing) { return; }
if (e.key === "Tab") { return; }
// 칸이 초점을 가진 동안에는 Ctrl+S 같은 브라우저 기본 동작을 막고 조합만 받아 적는다
e.preventDefault();
setLive(false);
const next: string[] = [];
if (e.ctrlKey) { next.push("Ctrl"); }
if (e.altKey) { next.push("Alt"); }
if (e.shiftKey) { next.push("Shift"); }
if (e.metaKey) { next.push("Meta"); }
if (MODIFIER_KEYS.indexOf(e.key) < 0) {
next.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
}
setParts(next);
setRecorded(next.length > 1);
}
const conflict = recorded && TAKEN.indexOf(parts.join("+")) >= 0;
const cls = [styles.root, live ? styles.isDemo : "", recorded ? styles.hasCombo : "", conflict ? styles.isConflict : ""]
.filter(Boolean)
.join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<p className={styles.label} id="fieldLabel">새 단축키</p>
<div className={styles.field} tabIndex={0} role="textbox" aria-readonly="true" aria-labelledby="fieldLabel" onKeyDown={handleKeyDown}>
<span className={styles.slot}>
<span className={styles.hint}>조합을 눌러 주세요</span>
<span className={styles.combo} aria-live="polite">
{parts.map((part) => (
<kbd className={styles.key} key={part}>{part}</kbd>
))}
</span>
</span>
<span className={styles.rec} aria-hidden="true" />
</div>
<p className={styles.warn}>이미 쓰고 있는 조합입니다</p>
</div>
</main>
);
}06 · 단축키 녹화 입력칸 — 측정 누적면적 2.931% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
키캡이 차례로 찍히고 경고까지 이어지는 루프라 누적면적 2.951%·움직인 프레임 11/23 입니다.
field.addEventListener('keydown', function (e) {
// 한글·일본어 입력기가 조합 중일 때 오는 키는 글자를 만드는 중이라 단축키가 아니다
if (e.isComposing) { return; }
if (e.key === 'Tab') { return; }
// 칸이 초점을 가진 동안에는 Ctrl+S 같은 브라우저 기본 동작을 막고 조합만 받아 적는다
e.preventDefault();
var parts = [];
if (e.ctrlKey) { parts.push('Ctrl'); }
if (e.altKey) { parts.push('Alt'); }
if (e.shiftKey) { parts.push('Shift'); }
if (e.metaKey) { parts.push('Meta'); }
if (MODIFIER_KEYS.indexOf(e.key) < 0) {
parts.push(e.key.length === 1 ? e.key.toUpperCase() : e.key);
}
renderCombo(parts);
});
07충돌 감지 표
무엇이 보이나
같은 조합에 두 가지 동작이 걸리면 그 두 줄에 경고 배지와 윤곽선이 붙는 표입니다. 조합마다 몇 줄이 쓰는지 세어 두 줄 이상이면 충돌로 보고, 경고 문장은 aria-live 로 한 번 읽히며 한쪽을 바꾸면 배지가 「충돌」에서 「확인」으로 바뀝니다.

<main class="stage">
<div class="cf is-demo" id="cf">
<p class="cf__cap">단축키 목록</p>
<div class="cf__table" role="table" aria-label="단축키 목록">
<div class="cf__row" role="row">
<span class="cf__act" role="cell">명령 실행</span>
<span class="cf__bind" role="cell"><kbd class="cf__key">Ctrl</kbd><kbd class="cf__key"><span class="cf__letterText">K</span></kbd></span>
<span class="cf__state" role="cell">
<span class="cf__warn">충돌</span>
<span class="cf__ok">확인</span>
</span>
</div>
<div class="cf__row" role="row">
<span class="cf__act" role="cell">줄 복제</span>
<span class="cf__bind" role="cell"><kbd class="cf__key">Ctrl</kbd><kbd class="cf__key"><span class="cf__letterText">D</span></kbd></span>
<span class="cf__state" role="cell">
<span class="cf__warn">충돌</span>
<span class="cf__ok">확인</span>
</span>
</div>
<div class="cf__row" role="row">
<span class="cf__act" role="cell">빠른 열기</span>
<span class="cf__bind" role="cell"><kbd class="cf__key">Ctrl</kbd><kbd class="cf__key"><span class="cf__letterText" id="lastLetter">K</span><span class="cf__letterAlt" aria-hidden="true">J</span></kbd></span>
<span class="cf__state" role="cell">
<span class="cf__warn">충돌</span>
<span class="cf__ok">확인</span>
</span>
</div>
</div>
<p class="cf__live" aria-live="polite">같은 조합이 두 줄에 있습니다</p>
<button type="button" class="cf__swap" id="swap">마지막 줄을 Ctrl+J 로</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 충돌 감지 표 — 같은 조합이 두 줄에 걸리면 그 줄에 경고 배지와 outline 이 붙는다.
// 표는 <td> 대신 div 격자로 짠다 — <td> 에 직접 grid 를 주면 Chrome 이 한 행의 칸을 익명 셀로 묶는다 (259 실측).
// 상태 배지는 「충돌」과 「확인」 두 장을 같은 칸에 겹쳐 두고 visibility 로만 고른다 — 칸 폭이 흔들리지 않는다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
.cf { display: flex; flex-direction: column; gap: $sp-2; width: min(100%, 320px); }
.cf__cap {
margin: 0; align-self: flex-start; border-radius: $r-pill; padding: $sp-1 $sp-3;
// 주황 무대 위 크림 글자는 3.11:1 이라 잉크 알약 배경 위로 옮겼다 (실측)
background: $subject-ink; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1.4; color: $subject-cream;
}
.cf__table { display: flex; flex-direction: column; gap: $sp-1; }
.cf__row {
display: grid; grid-template-columns: 1fr auto 44px; align-items: center; gap: $sp-2;
box-sizing: border-box; min-height: 30px; padding: 0 $sp-2;
background: $subject-cream; border-radius: $r-sm;
outline: 2px solid transparent; outline-offset: 0;
}
.cf__row.is-conflict { outline-color: #905347; }
.cf__act { font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: $subject-ink; }
.cf__bind { display: flex; align-items: center; gap: $sp-1; }
.cf__key {
position: relative; display: grid; place-items: center; box-sizing: border-box;
height: 20px; min-width: 20px; padding: 0 $sp-1;
border-radius: $r-xs; border: 1px solid rgba(23, 20, 26, .24); background: rgba(23, 20, 26, .06);
color: $subject-ink; font-family: inherit; font-weight: 800; font-size: 10px; line-height: 1;
}
.cf__key > span { grid-area: 1 / 1; }
.cf__letterAlt { visibility: hidden; }
.cf__state { display: grid; place-items: center; }
.cf__state > span {
grid-area: 1 / 1; border-radius: $r-pill; padding: $sp-1 $sp-2;
font-weight: 800; font-size: 10px; line-height: 1; visibility: hidden;
}
.cf__warn { background: #905347; color: $color; }
.cf__ok { background: rgba(23, 20, 26, .10); color: $subject-ink; }
.cf__row.is-conflict .cf__warn { visibility: visible; }
.cf__row.is-ok .cf__ok { visibility: visible; }
.cf__live {
margin: 0; align-self: flex-start; border-radius: $r-pill; padding: $sp-1 $sp-3;
background: $subject-ink; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); line-height: 1.4; color: $subject-cream; visibility: hidden;
}
.cf.has-conflict .cf__live { visibility: visible; }
.cf__swap {
align-self: flex-start; border: 0; cursor: pointer;
background: $subject-ink; color: $subject-cream; border-radius: $r-pill;
padding: $sp-1 $sp-3; font-family: inherit; font-weight: 800; font-size: 10px; line-height: 1.4;
transition: transform $duration $easing;
}
.cf__swap:active { transform: translateY(2px); }
.cf__swap:focus-visible { outline: 3px solid $subject-cream; outline-offset: 2px; }
.cf__swap:disabled { cursor: default; background: rgba(23, 20, 26, .34); }
// ── 격자 미리보기 루프: 0~15% 깨끗한 표 · 16% 충돌 배지 + outline · 64% 마지막 줄이 Ctrl+J 로 바뀌며 해소.
// 배지는 사라지지 않고 「충돌」→「확인」으로 바뀐다 — 루프에서 주인공을 지우면 그 빈 칸이 poster 가 된다 (255 실측).
.cf.is-demo .cf__row,
.cf.is-demo .cf__warn,
.cf.is-demo .cf__ok,
.cf.is-demo .cf__live,
.cf.is-demo .cf__row:nth-child(3) .cf__letterText,
.cf.is-demo .cf__letterAlt {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.cf.is-demo .cf__row:nth-child(1),
.cf.is-demo .cf__row:nth-child(3) { animation-name: cfOutline; }
@keyframes cfOutline {
0%, 15% { outline-color: transparent; }
16%, 63% { outline-color: #905347; }
64%, 100% { outline-color: transparent; }
}
.cf.is-demo .cf__row:nth-child(1) .cf__warn,
.cf.is-demo .cf__row:nth-child(3) .cf__warn { animation-name: cfWarnBadge; }
@keyframes cfWarnBadge {
0%, 15% { visibility: hidden; }
16%, 63% { visibility: visible; }
64%, 100% { visibility: hidden; }
}
.cf.is-demo .cf__row:nth-child(1) .cf__ok,
.cf.is-demo .cf__row:nth-child(3) .cf__ok { animation-name: cfOkBadge; }
@keyframes cfOkBadge {
0%, 63% { visibility: hidden; }
64%, 100% { visibility: visible; }
}
.cf.is-demo .cf__row:nth-child(2) .cf__ok { animation-name: cfOkAlways; }
@keyframes cfOkAlways { 0%, 100% { visibility: visible; } }
.cf.is-demo .cf__live { animation-name: cfLive; }
@keyframes cfLive {
0%, 15% { visibility: hidden; }
16%, 63% { visibility: visible; }
64%, 100% { visibility: hidden; }
}
// 글자가 바뀌는 것은 마지막 줄뿐이다 — 선택자를 그 줄로 좁히지 않으면 다른 줄의 K·D 까지 사라진다
.cf.is-demo .cf__row:nth-child(3) .cf__letterText { animation-name: cfLetterOld; }
.cf.is-demo .cf__letterAlt { animation-name: cfLetterNew; }
@keyframes cfLetterOld { 0%, 63% { visibility: visible; } 64%, 100% { visibility: hidden; } }
@keyframes cfLetterNew { 0%, 63% { visibility: hidden; } 64%, 100% { visibility: visible; } }
// 컷만 두면 움직인 프레임이 모자라 바뀌는 줄이 한 번 튀어오르는 보조 움직임을 붙인다 (252 실측)
.cf.is-demo .cf__row:nth-child(3) .cf__bind {
animation-name: cfBindPop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes cfBindPop {
0%, 10% { transform: translateX(0); }
16% { transform: translateX(-4px); }
24%, 58% { transform: translateX(0); }
64% { transform: translateX(-4px); }
72%, 100% { transform: translateX(0); }
}
.cf.is-demo .cf__swap {
animation-name: cfSwapState; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes cfSwapState {
0%, 63% { background: $subject-ink; }
64%, 100% { background: rgba(23, 20, 26, .34); }
}
@media (max-width: 340px) {
// 알약 배경 두 개가 세로 여백을 더 쓰게 되어 320×200 에서 15px 넘쳤다 — 좁은 화면에서는
// 올리지 않고 줄이는 쪽으로만 푼다 (BD17↔BD9 상쇄 규칙)
.cf { gap: $sp-1; }
.cf__row { grid-template-columns: 1fr auto 40px; gap: $sp-1; padding: 0 $sp-1; min-height: 26px; }
.cf__state > span { padding: 0 $sp-1; }
.cf__cap { padding: 0 $sp-2; }
.cf__live { padding: 0 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cf.is-demo .cf__row,
.cf.is-demo .cf__warn,
.cf.is-demo .cf__ok,
.cf.is-demo .cf__live,
.cf.is-demo .cf__letterText,
.cf.is-demo .cf__letterAlt,
.cf.is-demo .cf__bind,
.cf.is-demo .cf__swap { animation: none !important; }
.cf__swap { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
display: flex;
flex-direction: column;
gap: 8px;
width: min(100%, 320px);
}
.cf__cap {
margin: 0;
align-self: flex-start;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
color: #fff7e6;
}
.cf__table {
display: flex;
flex-direction: column;
gap: 4px;
}
.cf__row {
display: grid;
grid-template-columns: 1fr auto 44px;
align-items: center;
gap: 8px;
box-sizing: border-box;
min-height: 30px;
padding: 0 8px;
background: #fff7e6;
border-radius: 8px;
outline: 2px solid transparent;
outline-offset: 0;
}
.cf__row.is-conflict {
outline-color: #905347;
}
.cf__act {
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: #17141a;
}
.cf__bind {
display: flex;
align-items: center;
gap: 4px;
}
.cf__key {
position: relative;
display: grid;
place-items: center;
box-sizing: border-box;
height: 20px;
min-width: 20px;
padding: 0 4px;
border-radius: 4px;
border: 1px solid rgba(23, 20, 26, 0.24);
background: rgba(23, 20, 26, 0.06);
color: #17141a;
font-family: inherit;
font-weight: 800;
font-size: 10px;
line-height: 1;
}
.cf__key > span {
grid-area: 1/1;
}
.cf__letterAlt {
visibility: hidden;
}
.cf__state {
display: grid;
place-items: center;
}
.cf__state > span {
grid-area: 1/1;
border-radius: 999px;
padding: 4px 8px;
font-weight: 800;
font-size: 10px;
line-height: 1;
visibility: hidden;
}
.cf__warn {
background: #905347;
color: #fff7e6;
}
.cf__ok {
background: rgba(23, 20, 26, 0.1);
color: #17141a;
}
.cf__row.is-conflict .cf__warn {
visibility: visible;
}
.cf__row.is-ok .cf__ok {
visibility: visible;
}
.cf__live {
margin: 0;
align-self: flex-start;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1.4;
color: #fff7e6;
visibility: hidden;
}
.cf.has-conflict .cf__live {
visibility: visible;
}
.cf__swap {
align-self: flex-start;
border: 0;
cursor: pointer;
background: #17141a;
color: #fff7e6;
border-radius: 999px;
padding: 4px 12px;
font-family: inherit;
font-weight: 800;
font-size: 10px;
line-height: 1.4;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__swap:active {
transform: translateY(2px);
}
.cf__swap:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.cf__swap:disabled {
cursor: default;
background: rgba(23, 20, 26, 0.34);
}
.cf.is-demo .cf__row,
.cf.is-demo .cf__warn,
.cf.is-demo .cf__ok,
.cf.is-demo .cf__live,
.cf.is-demo .cf__row:nth-child(3) .cf__letterText,
.cf.is-demo .cf__letterAlt {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cf.is-demo .cf__row:nth-child(1),
.cf.is-demo .cf__row:nth-child(3) {
animation-name: cfOutline;
}
@keyframes cfOutline {
0%, 15% {
outline-color: transparent;
}
16%, 63% {
outline-color: #905347;
}
64%, 100% {
outline-color: transparent;
}
}
.cf.is-demo .cf__row:nth-child(1) .cf__warn,
.cf.is-demo .cf__row:nth-child(3) .cf__warn {
animation-name: cfWarnBadge;
}
@keyframes cfWarnBadge {
0%, 15% {
visibility: hidden;
}
16%, 63% {
visibility: visible;
}
64%, 100% {
visibility: hidden;
}
}
.cf.is-demo .cf__row:nth-child(1) .cf__ok,
.cf.is-demo .cf__row:nth-child(3) .cf__ok {
animation-name: cfOkBadge;
}
@keyframes cfOkBadge {
0%, 63% {
visibility: hidden;
}
64%, 100% {
visibility: visible;
}
}
.cf.is-demo .cf__row:nth-child(2) .cf__ok {
animation-name: cfOkAlways;
}
@keyframes cfOkAlways {
0%, 100% {
visibility: visible;
}
}
.cf.is-demo .cf__live {
animation-name: cfLive;
}
@keyframes cfLive {
0%, 15% {
visibility: hidden;
}
16%, 63% {
visibility: visible;
}
64%, 100% {
visibility: hidden;
}
}
.cf.is-demo .cf__row:nth-child(3) .cf__letterText {
animation-name: cfLetterOld;
}
.cf.is-demo .cf__letterAlt {
animation-name: cfLetterNew;
}
@keyframes cfLetterOld {
0%, 63% {
visibility: visible;
}
64%, 100% {
visibility: hidden;
}
}
@keyframes cfLetterNew {
0%, 63% {
visibility: hidden;
}
64%, 100% {
visibility: visible;
}
}
.cf.is-demo .cf__row:nth-child(3) .cf__bind {
animation-name: cfBindPop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes cfBindPop {
0%, 10% {
transform: translateX(0);
}
16% {
transform: translateX(-4px);
}
24%, 58% {
transform: translateX(0);
}
64% {
transform: translateX(-4px);
}
72%, 100% {
transform: translateX(0);
}
}
.cf.is-demo .cf__swap {
animation-name: cfSwapState;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes cfSwapState {
0%, 63% {
background: #17141a;
}
64%, 100% {
background: rgba(23, 20, 26, 0.34);
}
}
@media (max-width: 340px) {
.cf {
gap: 4px;
}
.cf__row {
grid-template-columns: 1fr auto 40px;
gap: 4px;
padding: 0 4px;
min-height: 26px;
}
.cf__state > span {
padding: 0 4px;
}
.cf__cap {
padding: 0 8px;
}
.cf__live {
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf.is-demo .cf__row,
.cf.is-demo .cf__warn,
.cf.is-demo .cf__ok,
.cf.is-demo .cf__live,
.cf.is-demo .cf__letterText,
.cf.is-demo .cf__letterAlt,
.cf.is-demo .cf__bind,
.cf.is-demo .cf__swap {
animation: none !important;
}
.cf__swap {
transition: none !important;
}
}var cf = document.getElementById('cf');
var swap = document.getElementById('swap');
var lastLetter = document.getElementById('lastLetter');
var rows = Array.prototype.slice.call(document.querySelectorAll('.cf__row'));
function bindingOf(row) {
return 'Ctrl+' + row.querySelector('.cf__letterText').textContent;
}
// 조합마다 몇 줄이 쓰는지 세어, 두 줄 이상이면 그 줄을 충돌로 본다
function applyConflicts() {
var counts = new Map();
rows.forEach(function (row) {
var key = bindingOf(row);
counts.set(key, (counts.get(key) || 0) + 1);
});
var hit = 0;
rows.forEach(function (row) {
var dup = counts.get(bindingOf(row)) > 1;
row.classList.toggle('is-conflict', dup);
row.classList.toggle('is-ok', !dup);
if (dup) { hit = hit + 1; }
});
cf.classList.toggle('has-conflict', hit > 0);
swap.disabled = hit === 0;
}
swap.addEventListener('click', function () {
cf.classList.remove('is-demo');
lastLetter.textContent = 'J';
applyConflicts();
});
applyConflicts();// ConflictTable — vanilla 판(run/288/demo/07-shortcut-conflict-table/index.html) 의 React 포트.
// 같은 조합이 두 줄에 걸리면 그 줄에 경고 배지와 outline 이 붙고, 경고문은 aria-live 로 한 번 읽힌다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ConflictTable.module.scss";
export type ConflictTableProps = {
/** 격자 미리보기용 2초 자동 루프. 바꾸기 버튼을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ACTIONS = ["명령 실행", "줄 복제", "빠른 열기"];
/** 조합마다 몇 줄이 쓰는지 세어, 두 줄 이상이면 그 줄을 충돌로 본다 */
function conflictFlags(letters: string[]): boolean[] {
const counts = new Map<string, number>();
letters.forEach((letter) => {
const key = "Ctrl+" + letter;
counts.set(key, (counts.get(key) || 0) + 1);
});
return letters.map((letter) => (counts.get("Ctrl+" + letter) || 0) > 1);
}
export default function ConflictTable({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#fff7e6",
}: ConflictTableProps) {
const [live, setLive] = useState(demo);
const [letters, setLetters] = useState<string[]>(["K", "D", "K"]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const flags = conflictFlags(letters);
const hasConflict = flags.some(Boolean);
function applySwap() {
setLive(false);
setLetters(["K", "D", "J"]);
}
const cls = [styles.root, live ? styles.isDemo : "", hasConflict ? styles.hasConflict : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<p className={styles.cap}>단축키 목록</p>
<div className={styles.table} role="table" aria-label="단축키 목록">
{ACTIONS.map((action, i) => (
<div
className={[styles.row, flags[i] ? styles.isConflict : styles.isOk].join(" ")}
role="row"
key={action}
>
<span className={styles.act} role="cell">{action}</span>
<span className={styles.bind} role="cell">
<kbd className={styles.key}>Ctrl</kbd>
<kbd className={styles.key}>
<span className={styles.letterText}>{letters[i]}</span>
{i === 2 ? <span className={styles.letterAlt} aria-hidden="true">J</span> : null}
</kbd>
</span>
<span className={styles.state} role="cell">
<span className={styles.warn}>충돌</span>
<span className={styles.ok}>확인</span>
</span>
</div>
))}
</div>
<p className={styles.live} aria-live="polite">같은 조합이 두 줄에 있습니다</p>
<button type="button" className={styles.swap} disabled={!hasConflict} onClick={applySwap}>
마지막 줄을 Ctrl+J 로
</button>
</div>
</main>
);
}07 · 충돌 감지 표 — 측정 누적면적 7.233% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
배지 두 장을 같은 칸에 겹쳐 visibility 로만 고르므로 칸 폭이 흔들리지 않고, 누적면적 7.233%·움직인 프레임 8/23 입니다.
// 조합마다 몇 줄이 쓰는지 세어, 두 줄 이상이면 그 줄을 충돌로 본다
function applyConflicts() {
var counts = new Map();
rows.forEach(function (row) {
var key = bindingOf(row);
counts.set(key, (counts.get(key) || 0) + 1);
});
rows.forEach(function (row) {
var dup = counts.get(bindingOf(row)) > 1;
row.classList.toggle('is-conflict', dup);
row.classList.toggle('is-ok', !dup);
});
}
08물음표 도움말 시트
화면에서 일어나는 일
물음표를 누르면 지금 화면에서 쓸 수 있는 단축키만 묶음별로 펼쳐집니다. 위쪽 검색칸에 글자를 넣으면 목록이 그 자리에서 걸러지고 Esc 로 닫히며, 시트는 role="dialog" 에 aria-modal 을 단 판이라 초점이 밖으로 새지 않습니다.

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="question" viewBox="0 0 256 256" fill="currentColor"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"/></symbol>
</defs>
</svg>
<main class="stage">
<div class="hs is-demo" id="hs">
<div class="hs__app">
<div class="hs__bar">
<span class="hs__appName">문서 편집</span>
<button type="button" class="hs__ask" id="ask" aria-expanded="false" aria-controls="sheet" aria-keyshortcuts="Shift+?">
<svg class="hs__ico" aria-hidden="true"><use href="#question"/></svg>
</button>
</div>
<div class="hs__paper" aria-hidden="true"><i></i><i></i><i></i></div>
<div class="hs__sheet" id="sheet" role="dialog" aria-modal="true" aria-label="이 화면의 단축키">
<input class="hs__search" id="search" type="search" placeholder="단축키 찾기" aria-label="단축키 찾기">
<div class="hs__cols">
<section class="hs__group">
<p class="hs__groupName">편집</p>
<ul class="hs__list">
<li class="hs__item"><span class="hs__what">굵게</span><span class="hs__keys"><kbd class="hs__key">Ctrl</kbd><kbd class="hs__key">B</kbd></span></li>
<li class="hs__item"><span class="hs__what">되돌리기</span><span class="hs__keys"><kbd class="hs__key">Ctrl</kbd><kbd class="hs__key">Z</kbd></span></li>
</ul>
</section>
<section class="hs__group">
<p class="hs__groupName">이동</p>
<ul class="hs__list">
<li class="hs__item"><span class="hs__what">다음 항목</span><span class="hs__keys"><kbd class="hs__key">J</kbd></span></li>
<li class="hs__item"><span class="hs__what">맨 위로</span><span class="hs__keys"><kbd class="hs__key">G</kbd><kbd class="hs__key">G</kbd></span></li>
</ul>
</section>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 물음표 도움말 시트 — ? 를 누르면 지금 화면에서 쓸 수 있는 단축키만 묶음별로 펼쳐진다.
// 실제 조작(클릭·? 입력)에서 열림은 clip-path 위프(transition, ease-out)이고 안쪽 줄은 translateY 로 뒤따른다.
// 시트는 앱 카드 안(inset: 0)에 갇힌 절대배치라 무대 높이를 넘기지 않는다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-ink;
@include stage($stage-paper);
.hs { display: flex; justify-content: center; width: 100%; }
.hs__app {
position: relative; overflow: hidden; box-sizing: border-box;
width: min(100%, 330px); height: 148px;
background: rgba(23, 20, 26, .05); border-radius: $r-card; padding: $sp-3;
}
.hs__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.hs__appName { font-weight: 800; font-size: clamp(11px, 3vw, 12px); color: $color; }
.hs__ask {
display: grid; place-items: center; border: 0; cursor: pointer;
width: 24px; height: 24px; border-radius: 50%;
background: rgba(23, 20, 26, .10); color: $color;
transition: transform $dur-instant $easing, background $dur-instant $easing;
}
.hs__ask:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.hs__ico { width: 16px; height: 16px; }
.hs__paper { display: flex; flex-direction: column; gap: $sp-2; margin-top: $sp-4; }
.hs__paper i { height: 6px; border-radius: $r-pill; background: rgba(23, 20, 26, .10); }
.hs__paper i:nth-child(2) { width: 76%; }
.hs__paper i:nth-child(3) { width: 54%; }
.hs__sheet {
position: absolute; inset: 0; box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
background: $color; padding: $sp-3;
clip-path: inset(0 0 100% 0);
transition: clip-path $duration $ease-out;
}
.hs.is-open .hs__sheet { clip-path: inset(0 0 0 0); }
.hs__search {
box-sizing: border-box; width: 100%; height: 26px;
border: 1px solid rgba(255, 247, 230, .24); border-radius: $r-sm;
background: rgba(255, 247, 230, .08); color: $subject-cream;
font-family: inherit; font-weight: 600; font-size: 11px; line-height: 1; padding: 0 $sp-2;
}
.hs__search::placeholder { color: rgba(255, 247, 230, .56); }
.hs__cols { display: grid; grid-template-columns: repeat(2, 1fr); gap: $sp-3; }
.hs__group { min-width: 0; }
.hs__groupName {
margin: 0 0 $sp-1; font-weight: 800; font-size: 10px; line-height: 1.4;
color: $stage-yellow; letter-spacing: .04em;
}
.hs__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $sp-1; }
.hs__item {
display: flex; align-items: center; justify-content: space-between; gap: $sp-1;
min-height: 20px;
}
.hs__item.is-hidden { visibility: hidden; }
.hs__what { font-weight: 600; font-size: 10px; color: rgba(255, 247, 230, .88); }
.hs__keys { display: flex; align-items: center; gap: $sp-1; }
.hs__key {
display: grid; place-items: center; box-sizing: border-box; height: 18px; min-width: 18px;
border-radius: $r-xs; border: 1px solid rgba(255, 247, 230, .30);
background: rgba(255, 247, 230, .12); color: $subject-cream;
font-family: inherit; font-weight: 800; font-size: 9px; line-height: 1; padding: 0 $sp-1;
}
// ── 격자 미리보기 루프: 0~14% 닫힌 문서 · 15% 에 시트가 한 프레임에 열리고 그 뒤로는 열린 채로 머문다.
// 위프를 그대로 미리보기에 쓰면 24프레임 중 한 장이 **반쯤 열린** 장면이 되고, 그 장이 최대 변화라
// poster 로 뽑힌다 (이 편 실측). 시트처럼 면적이 큰 판은 미리보기에서만 steps(1, end) 로 컷한다 —
// 실제 클릭·? 입력은 위의 transition: clip-path 그대로 부드럽게 열린다 (252 와 같은 계열).
// 닫히는 장면은 루프에 두지 않는다 — 빈 문서 화면이 최대 변화가 되어 버린다 (255 실측).
.hs.is-demo .hs__sheet {
animation-name: hsOpen; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes hsOpen {
0%, 14% { clip-path: inset(0 0 100% 0); }
15%, 100% { clip-path: inset(0 0 0 0); }
}
.hs.is-demo .hs__ask {
animation-name: hsAsk; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes hsAsk {
0%, 8% { transform: scale(1); background: rgba(23, 20, 26, .10); }
14% { transform: scale(.86); background: rgba(23, 20, 26, .20); }
22%, 100% { transform: scale(1); background: rgba(23, 20, 26, .10); }
}
// 묶음 안 줄은 위프가 지나간 뒤 차례로 올라온다 — 퍼센트로 시차를 주고 delay 는 쓰지 않는다
.hs.is-demo .hs__item {
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.hs.is-demo .hs__group:nth-child(1) .hs__item:nth-child(1) { animation-name: hsRise1; }
.hs.is-demo .hs__group:nth-child(1) .hs__item:nth-child(2) { animation-name: hsRise2; }
.hs.is-demo .hs__group:nth-child(2) .hs__item:nth-child(1) { animation-name: hsRise3; }
.hs.is-demo .hs__group:nth-child(2) .hs__item:nth-child(2) { animation-name: hsRise4; }
@keyframes hsRise1 { 0%, 16% { transform: translateY(8px); } 28%, 100% { transform: translateY(0); } }
@keyframes hsRise2 { 0%, 21% { transform: translateY(8px); } 33%, 100% { transform: translateY(0); } }
@keyframes hsRise3 { 0%, 26% { transform: translateY(8px); } 38%, 100% { transform: translateY(0); } }
@keyframes hsRise4 { 0%, 31% { transform: translateY(8px); } 43%, 100% { transform: translateY(0); } }
@media (max-width: 340px) {
.hs__app { padding: $sp-2; height: 144px; }
.hs__sheet { padding: $sp-2; gap: $sp-1; }
.hs__cols { gap: $sp-2; }
.hs__paper { margin-top: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.hs.is-demo .hs__sheet,
.hs.is-demo .hs__ask,
.hs.is-demo .hs__item { animation: none !important; }
.hs.is-demo .hs__sheet { clip-path: inset(0 0 0 0) !important; }
.hs__sheet, .hs__ask { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
display: flex;
justify-content: center;
width: 100%;
}
.hs__app {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: min(100%, 330px);
height: 148px;
background: rgba(23, 20, 26, 0.05);
border-radius: 16px;
padding: 12px;
}
.hs__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.hs__appName {
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
color: #17141a;
}
.hs__ask {
display: grid;
place-items: center;
border: 0;
cursor: pointer;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.1);
color: #17141a;
transition: transform 140ms cubic-bezier(0.2, 0.8, 0.2, 1), background 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.hs__ask:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.hs__ico {
width: 16px;
height: 16px;
}
.hs__paper {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.hs__paper i {
height: 6px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
}
.hs__paper i:nth-child(2) {
width: 76%;
}
.hs__paper i:nth-child(3) {
width: 54%;
}
.hs__sheet {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
background: #17141a;
padding: 12px;
clip-path: inset(0 0 100% 0);
transition: clip-path 300ms ease-out;
}
.hs.is-open .hs__sheet {
clip-path: inset(0 0 0 0);
}
.hs__search {
box-sizing: border-box;
width: 100%;
height: 26px;
border: 1px solid rgba(255, 247, 230, 0.24);
border-radius: 8px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: 11px;
line-height: 1;
padding: 0 8px;
}
.hs__search::placeholder {
color: rgba(255, 247, 230, 0.56);
}
.hs__cols {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.hs__group {
min-width: 0;
}
.hs__groupName {
margin: 0 0 4px;
font-weight: 800;
font-size: 10px;
line-height: 1.4;
color: #ffd23f;
letter-spacing: 0.04em;
}
.hs__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.hs__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
min-height: 20px;
}
.hs__item.is-hidden {
visibility: hidden;
}
.hs__what {
font-weight: 600;
font-size: 10px;
color: rgba(255, 247, 230, 0.88);
}
.hs__keys {
display: flex;
align-items: center;
gap: 4px;
}
.hs__key {
display: grid;
place-items: center;
box-sizing: border-box;
height: 18px;
min-width: 18px;
border-radius: 4px;
border: 1px solid rgba(255, 247, 230, 0.3);
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-family: inherit;
font-weight: 800;
font-size: 9px;
line-height: 1;
padding: 0 4px;
}
.hs.is-demo .hs__sheet {
animation-name: hsOpen;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes hsOpen {
0%, 14% {
clip-path: inset(0 0 100% 0);
}
15%, 100% {
clip-path: inset(0 0 0 0);
}
}
.hs.is-demo .hs__ask {
animation-name: hsAsk;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes hsAsk {
0%, 8% {
transform: scale(1);
background: rgba(23, 20, 26, 0.1);
}
14% {
transform: scale(0.86);
background: rgba(23, 20, 26, 0.2);
}
22%, 100% {
transform: scale(1);
background: rgba(23, 20, 26, 0.1);
}
}
.hs.is-demo .hs__item {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.hs.is-demo .hs__group:nth-child(1) .hs__item:nth-child(1) {
animation-name: hsRise1;
}
.hs.is-demo .hs__group:nth-child(1) .hs__item:nth-child(2) {
animation-name: hsRise2;
}
.hs.is-demo .hs__group:nth-child(2) .hs__item:nth-child(1) {
animation-name: hsRise3;
}
.hs.is-demo .hs__group:nth-child(2) .hs__item:nth-child(2) {
animation-name: hsRise4;
}
@keyframes hsRise1 {
0%, 16% {
transform: translateY(8px);
}
28%, 100% {
transform: translateY(0);
}
}
@keyframes hsRise2 {
0%, 21% {
transform: translateY(8px);
}
33%, 100% {
transform: translateY(0);
}
}
@keyframes hsRise3 {
0%, 26% {
transform: translateY(8px);
}
38%, 100% {
transform: translateY(0);
}
}
@keyframes hsRise4 {
0%, 31% {
transform: translateY(8px);
}
43%, 100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.hs__app {
padding: 8px;
height: 144px;
}
.hs__sheet {
padding: 8px;
gap: 4px;
}
.hs__cols {
gap: 8px;
}
.hs__paper {
margin-top: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs.is-demo .hs__sheet,
.hs.is-demo .hs__ask,
.hs.is-demo .hs__item {
animation: none !important;
}
.hs.is-demo .hs__sheet {
clip-path: inset(0 0 0 0) !important;
}
.hs__sheet, .hs__ask {
transition: none !important;
}
}var hs = document.getElementById('hs');
var ask = document.getElementById('ask');
var search = document.getElementById('search');
var items = Array.prototype.slice.call(document.querySelectorAll('.hs__item'));
function applySheet(open) {
hs.classList.remove('is-demo');
hs.classList.toggle('is-open', open);
ask.setAttribute('aria-expanded', open ? 'true' : 'false');
if (open) { search.focus({ preventScroll: true }); }
}
ask.addEventListener('click', function () {
applySheet(ask.getAttribute('aria-expanded') !== 'true');
});
window.addEventListener('keydown', function (e) {
if (e.isComposing) { return; }
if (e.key === '?') { e.preventDefault(); applySheet(true); return; }
if (e.key === 'Escape') { applySheet(false); }
});
search.addEventListener('input', function () {
var want = search.value.trim().toLowerCase();
items.forEach(function (item) {
var text = item.textContent.toLowerCase();
item.classList.toggle('is-hidden', want !== '' && text.indexOf(want) < 0);
});
});// HelpSheet — vanilla 판(run/288/demo/08-shortcut-help-sheet/index.html) 의 React 포트.
// ? 로 열고 Esc 로 닫는다. 검색칸에 글자를 넣으면 지금 화면의 단축키만 그 자리에서 걸러진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HelpSheet.module.scss";
export type HelpSheetProps = {
/** 격자 미리보기용 2초 자동 루프. 물음표를 누르거나 ? 를 입력하면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** useRef<T> 는 T | null 을 돌려주므로 쓰기 전에 null 을 걸러 낸다 */
type InputBox = { current: HTMLInputElement | null };
/** React 이벤트 제네릭이 ambient 에 없어 쓰는 필드만 지역 타입으로 선언한다 */
type ChangeEvt = { target: { value: string } };
type Group = { name: string; items: { what: string; keys: string[] }[] };
const GROUPS: Group[] = [
{ name: "편집", items: [{ what: "굵게", keys: ["Ctrl", "B"] }, { what: "되돌리기", keys: ["Ctrl", "Z"] }] },
{ name: "이동", items: [{ what: "다음 항목", keys: ["J"] }, { what: "맨 위로", keys: ["G", "G"] }] },
];
export default function HelpSheet({
demo = true,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#17141a",
}: HelpSheetProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null) as InputBox;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function applySheet(next: boolean) {
setLive(false);
setOpen(next);
if (next && searchRef.current) {
searchRef.current.focus({ preventScroll: true });
}
}
useEffect(() => {
function handleDown(e: KeyboardEvent) {
if (e.isComposing) { return; }
if (e.key === "?") { e.preventDefault(); applySheet(true); return; }
if (e.key === "Escape") { applySheet(false); }
}
window.addEventListener("keydown", handleDown);
return () => window.removeEventListener("keydown", handleDown);
}, []);
const want = query.trim().toLowerCase();
const cls = [styles.root, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="question" viewBox="0 0 256 256" fill="currentColor">
<path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2" />
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z" />
</symbol>
</defs>
</svg>
<div className={cls}>
<div className={styles.app}>
<div className={styles.bar}>
<span className={styles.appName}>문서 편집</span>
<button
type="button"
className={styles.ask}
aria-expanded={open}
aria-controls="sheet"
aria-keyshortcuts="Shift+?"
onClick={() => applySheet(!open)}
>
<svg className={styles.ico} aria-hidden="true"><use href="#question" /></svg>
</button>
</div>
<div className={styles.paper} aria-hidden="true"><i /><i /><i /></div>
<div className={styles.sheet} id="sheet" role="dialog" aria-modal="true" aria-label="이 화면의 단축키">
<input
ref={searchRef}
className={styles.search}
type="search"
placeholder="단축키 찾기"
aria-label="단축키 찾기"
value={query}
onChange={(e: ChangeEvt) => setQuery(e.target.value)}
/>
<div className={styles.cols}>
{GROUPS.map((group) => (
<section className={styles.group} key={group.name}>
<p className={styles.groupName}>{group.name}</p>
<ul className={styles.list}>
{group.items.map((item) => {
const text = (item.what + item.keys.join("")).toLowerCase();
const hidden = want !== "" && text.indexOf(want) < 0;
return (
<li className={[styles.item, hidden ? styles.isHidden : ""].filter(Boolean).join(" ")} key={item.what}>
<span className={styles.what}>{item.what}</span>
<span className={styles.keys}>
{item.keys.map((k, ki) => (
<kbd className={styles.key} key={String(ki) + k}>{k}</kbd>
))}
</span>
</li>
);
})}
</ul>
</section>
))}
</div>
</div>
</div>
</div>
</main>
);
}08 · 물음표 도움말 시트 — 측정 누적면적 33.781% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
시트가 카드를 통째로 덮는 만큼 누적면적 33.781% 로 아홉 개 중 가장 크고 움직인 프레임은 9/23 입니다.
window.addEventListener('keydown', function (e) {
if (e.isComposing) { return; }
if (e.key === '?') { e.preventDefault(); applySheet(true); return; }
if (e.key === 'Escape') { applySheet(false); }
});
search.addEventListener('input', function () {
var want = search.value.trim().toLowerCase();
items.forEach(function (item) {
var text = item.textContent.toLowerCase();
item.classList.toggle('is-hidden', want !== '' && text.indexOf(want) < 0);
});
});
09눌린 키 흘림 표시
어떤 움직임인가
방금 누른 키가 화면 구석에 쌓였다가 오래된 것부터 옅어지며 왼쪽으로 흘러 나갑니다. 옅어짐은 칸마다 불투명도를 매기는 대신 판 전체에 mask-image 그라디언트를 한 번 씌워 자리가 곧 나이가 되게 했고, 다섯 개를 넘으면 가장 오래된 것부터 빠집니다.

<main class="stage">
<div class="kt is-demo" id="kt">
<p class="kt__cap">방금 누른 키</p>
<div class="kt__panel">
<div class="kt__strip" id="strip" aria-live="polite" aria-label="최근 누른 키">
<kbd class="kt__chip">Ctrl</kbd>
<kbd class="kt__chip">S</kbd>
<kbd class="kt__chip">J</kbd>
<kbd class="kt__chip">G</kbd>
<kbd class="kt__chip">?</kbd>
<kbd class="kt__chip kt__dup" aria-hidden="true">Ctrl</kbd>
<kbd class="kt__chip kt__dup" aria-hidden="true">S</kbd>
<kbd class="kt__chip kt__dup" aria-hidden="true">J</kbd>
<kbd class="kt__chip kt__dup" aria-hidden="true">G</kbd>
<kbd class="kt__chip kt__dup" aria-hidden="true">?</kbd>
</div>
</div>
<p class="kt__note">오래된 것부터 옅어집니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 눌린 키 흘림 표시 — 새 키는 오른쪽에서 들어오고, 오래된 키일수록 왼쪽에서 옅어지다 사라진다.
// 옅어짐은 칸마다 opacity 를 매기는 대신 판 전체에 mask-image 그라디언트를 한 번 씌워 만든다.
// 오른쪽 끝 12% 도 함께 흐리게 두어 새 키가 판 밖에서 잘린 채 튀어나오지 않는다 —
// 자리(왼쪽일수록 오래됨)가 곧 나이라 JS 는 칩을 붙이고 다섯 개를 넘으면 지우기만 하면 된다.
// 격자 미리보기(.is-demo)는 같은 다섯 칸을 한 벌 더 두고 translateX(-50%) 로 흘린다.
// 두 벌 사이 간격은 gap 이 아니라 margin-right 로 준다 — gap 은 2N 개 사이에 2N-1 개만 생겨
// -50% 가 gap 절반만큼 모자라 한 바퀴마다 튄다 (BD15).
$duration: $dur-base;
$easing: $ease-out;
$color: $stage-yellow;
@include stage($stage-ink);
.kt { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.kt__cap { margin: 0; font-weight: 800; font-size: clamp(11px, 3vw, 12px); color: $subject-cream; }
.kt__panel {
// width 를 min(100%, …) 로 주면 흐름 트랙(width: max-content)이 이 칸의 max-content 기여로 새어
// 부모가 폰 폭보다 넓어진다 (이 편 320px 실측 369px — run/288/_수리전실측.json). 고정 폭 + max-width 로 기여를 못 박는다
box-sizing: border-box; width: 300px; max-width: 100%; overflow: hidden;
background: rgba(255, 247, 230, .08); border-radius: $r-card; padding: $sp-3;
mask-image: linear-gradient(to right, transparent 0, #000 42%, #000 88%, transparent 100%);
}
.kt__strip { display: flex; align-items: center; justify-content: flex-end; }
.kt__chip {
display: grid; place-items: center; box-sizing: border-box; flex: 0 0 auto;
height: 30px; min-width: 34px; padding: 0 $sp-2; margin-right: $sp-2;
border-radius: $r-sm; border: 1px solid rgba(255, 247, 230, .28);
background: rgba(255, 247, 230, .12); color: $color;
font-family: inherit; font-weight: 800; font-size: 13px; line-height: 1;
}
.kt:not(.is-demo) .kt__chip:last-child { margin-right: 0; }
.kt__chip.is-new { animation: ktEnter $duration $easing both; }
@keyframes ktEnter {
0% { transform: translateX(10px) scale(.85); }
100% { transform: translateX(0) scale(1); }
}
// 미리보기용 두 번째 벌은 사람이 키를 누르는 순간 JS 가 DOM 에서 지운다
.kt:not(.is-demo) .kt__dup { display: none; }
.kt__note { margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); color: rgba(255, 247, 230, .66); }
// ── 격자 미리보기 루프: 열 칸(다섯 칸 × 두 벌)이 2초에 정확히 한 벌만큼 왼쪽으로 흐른다.
// 두 벌이 같은 내용이라 -50% 지점의 그림이 0% 와 같고, 이음새가 보이지 않는다.
.kt.is-demo .kt__strip {
justify-content: flex-start; width: max-content;
animation: ktFlow $dur-loop linear infinite;
}
@keyframes ktFlow {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@media (max-width: 340px) {
.kt__panel { padding: $sp-2; }
.kt__chip { height: 26px; min-width: 30px; font-size: 11px; padding: 0 $sp-1; margin-right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.kt.is-demo .kt__strip,
.kt__chip.is-new { 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);
}
.kt {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.kt__cap {
margin: 0;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
color: #fff7e6;
}
.kt__panel {
box-sizing: border-box;
width: 300px;
max-width: 100%;
overflow: hidden;
background: rgba(255, 247, 230, 0.08);
border-radius: 16px;
padding: 12px;
mask-image: linear-gradient(to right, transparent 0, #000 42%, #000 88%, transparent 100%);
}
.kt__strip {
display: flex;
align-items: center;
justify-content: flex-end;
}
.kt__chip {
display: grid;
place-items: center;
box-sizing: border-box;
flex: 0 0 auto;
height: 30px;
min-width: 34px;
padding: 0 8px;
margin-right: 8px;
border-radius: 8px;
border: 1px solid rgba(255, 247, 230, 0.28);
background: rgba(255, 247, 230, 0.12);
color: #ffd23f;
font-family: inherit;
font-weight: 800;
font-size: 13px;
line-height: 1;
}
.kt:not(.is-demo) .kt__chip:last-child {
margin-right: 0;
}
.kt__chip.is-new {
animation: ktEnter 300ms ease-out both;
}
@keyframes ktEnter {
0% {
transform: translateX(10px) scale(0.85);
}
100% {
transform: translateX(0) scale(1);
}
}
.kt:not(.is-demo) .kt__dup {
display: none;
}
.kt__note {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
color: rgba(255, 247, 230, 0.66);
}
.kt.is-demo .kt__strip {
justify-content: flex-start;
width: max-content;
animation: ktFlow 2s linear infinite;
}
@keyframes ktFlow {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@media (max-width: 340px) {
.kt__panel {
padding: 8px;
}
.kt__chip {
height: 26px;
min-width: 30px;
font-size: 11px;
padding: 0 4px;
margin-right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.kt.is-demo .kt__strip,
.kt__chip.is-new {
animation: none !important;
}
}var kt = document.getElementById('kt');
var strip = document.getElementById('strip');
var MAX = 5;
function labelFor(e) {
if (e.key === ' ') { return 'Space'; }
return e.key.length === 1 ? e.key.toUpperCase() : e.key;
}
function pushKey(text) {
var chip = document.createElement('kbd');
chip.className = 'kt__chip is-new';
chip.textContent = text;
strip.appendChild(chip);
// 다섯 개를 넘으면 가장 오래된 것부터 지운다 — 화면 구석을 침범하지 않게
while (strip.children.length > MAX) {
strip.removeChild(strip.firstChild);
}
}
function dropPreviewChips() {
var dup = Array.prototype.slice.call(strip.querySelectorAll('.kt__dup'));
dup.forEach(function (node) { strip.removeChild(node); });
}
window.addEventListener('keydown', function (e) {
if (e.isComposing) { return; }
if (kt.classList.contains('is-demo')) {
kt.classList.remove('is-demo');
dropPreviewChips();
}
pushKey(labelFor(e));
});// KeycastStream — vanilla 판(run/288/demo/09-kbd-keycast-stream/index.html) 의 React 포트.
// 새 키는 오른쪽에서 들어오고 다섯 개를 넘으면 가장 오래된 것부터 빠진다. 옅어짐은 판의 mask 가 맡는다.
import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./KeycastStream.module.scss";
export type KeycastStreamProps = {
/** 격자 미리보기용 2초 자동 루프. 사람이 키를 누르면 꺼지고 미리보기용 두 번째 벌이 빠진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const MAX = 5;
/** 미리보기에서 보여 주는 다섯 칸 — 흐름이 이어지도록 한 벌을 더 붙인다 */
const SEED = ["Ctrl", "S", "J", "G", "?"];
function labelFor(e: KeyboardEvent): string {
if (e.key === " ") { return "Space"; }
return e.key.length === 1 ? e.key.toUpperCase() : e.key;
}
export default function KeycastStream({
demo = true,
duration = 300,
easing = "ease-out",
color = "#ffd23f",
}: KeycastStreamProps) {
const [live, setLive] = useState(demo);
const [keys, setKeys] = useState<string[]>(SEED);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function handleDown(e: KeyboardEvent) {
if (e.isComposing) { return; }
setLive(false);
const text = labelFor(e);
setKeys((prev) => prev.concat([text]).slice(-MAX));
}
window.addEventListener("keydown", handleDown);
return () => window.removeEventListener("keydown", handleDown);
}, []);
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<p className={styles.cap}>방금 누른 키</p>
<div className={styles.panel}>
<div className={styles.strip} aria-live="polite" aria-label="최근 누른 키">
{keys.map((text, i) => (
<kbd className={styles.chip} key={"live" + String(i) + text}>{text}</kbd>
))}
{live
? SEED.map((text, i) => (
<kbd className={[styles.chip, styles.dup].join(" ")} aria-hidden="true" key={"dup" + String(i) + text}>{text}</kbd>
))
: null}
</div>
</div>
<p className={styles.note}>오래된 것부터 옅어집니다</p>
</div>
</main>
);
}09 · 눌린 키 흘림 표시 — 측정 누적면적 5.389% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
미리보기는 같은 다섯 칸을 한 벌 더 두고 translateX(-50%) 로 흘려 이음새를 없앴습니다 — 매 프레임이 움직여 움직인 프레임 23/23, 누적면적 5.389% 입니다.
.kt__panel {
box-sizing: border-box; width: 300px; max-width: 100%; overflow: hidden;
background: rgba(255, 247, 230, .08); border-radius: $r-card; padding: $sp-3;
mask-image: linear-gradient(to right, transparent 0, #000 42%, #000 88%, transparent 100%);
}
.kt__chip {
display: grid; place-items: center; box-sizing: border-box; flex: 0 0 auto;
height: 30px; min-width: 34px; padding: 0 $sp-2; margin-right: $sp-2;
border-radius: $r-sm; border: 1px solid rgba(255, 247, 230, .28);
}
어디서 깨지는가 — 함정
이 아홉 개에서 가장 시간을 잡아먹은 것은 무한 흐름 트랙이 부모 폭을 넓히는 일이었습니다. 09 흘림 표시의 미리보기는 같은 칸 다섯 개를 두 벌 늘어놓고 translateX(-50%) 로 도는 트랙인데, 이 트랙에 width: max-content 를 준 순간 트랙의 본래 폭이 부모 레이아웃 계산에 그대로 새어 들어갔습니다. 판은 min(100%, 300px) 로 제한해 두었는데도 320px 폭에서 document.body.scrollWidth 가 369px 로 잡혀 좁은 화면 검사(BD9)에 걸렸습니다 — 숨긴 판의 overflow: hidden 은 보이는 칸만 자를 뿐 레이아웃 폭을 되돌려 주지 않습니다. 판 폭을 width: 300px + max-width: 100% 로 못 박아 기여를 차단하니 같은 측정에서 320px 로 들어왔습니다. 같은 트랙의 또 하나 — 두 벌 사이 간격을 gap 으로 주면 열 칸 사이에 아홉 개만 생겨 -50% 가 매 바퀴 반 칸씩 모자라 튑니다. 칸마다 margin-right 를 주는 무한 마퀴의 규칙을 그대로 따랐습니다.
두 번째는 정지 이미지가 항상 최대 변화 프레임에서 뽑힌다는 점입니다. 08 도움말 시트의 열림을 실사용 그대로 clip-path 위프로 보여 주려다 24프레임 중 한 장이 반쯤 열린 장면으로 잡혀, 그 장이 가장 크게 변한 프레임이라 대표 이미지가 늘 「반쯤 가린 문서」로 나왔습니다. 미리보기 루프에서만 열림을 steps(1, end) 컷으로 바꾸고 실제 클릭·? 입력은 위프 그대로 두니 열린 시트가 대표 이미지로 잡혔습니다. 닫히는 장면은 아예 루프에 넣지 않았습니다 — 닫히면 빈 문서가 최대 변화가 되어 그 빈 화면이 대표 이미지를 차지하기 때문입니다. 03 툴팁도 같은 이유로 세 툴팁이 이어 달리게 잡아 언제나 한 개는 떠 있게 했습니다.
세 번째는 주황 무대 위의 크림 글자였습니다. 03 안내 문구와 07 표 제목·경고 문장을 크림(#fff7e6)으로 쓰었더니 주황 무대(#ff4d1f) 위에서 3.11:1 로 본문 기준(4.5:1)에 못 미쳤습니다. 흰색으로 올려도 사정이 나아지지 않아 세 문장 모두 잉크(#17141a) 알약 배경 위로 옮겨 17.11:1 을 만들었고, 06 의 안내 문구는 잉크 농도 .56 에서 .72 로 올려 3.74:1 을 7.26:1 로 끌어올렸습니다. 여기서 07 이 320px 에서 15px 세로로 넘치게 되었는데, 알약 두 개가 세로 여백을 더 쓴 탓이라 좁은 화면에서는 값을 올리지 않고 반대로만 — 줄 높이와 알약 세로 여백을 줄여 — 맞췄습니다. 화면 글자 서른다섯 곳의 실측 대비표는 run/288/_probe/_대비실측.json 에, 수리 전 값은 run/288/_수리전실측.json 에 남겨 두었습니다. 09 의 흘림 표시처럼 누른 입력을 그림으로 바꾸는 재주는 오디오 비주얼라이저가 소리 쪽에서 같은 방식으로 씁니다. 이렇게 고친 자리까지 그대로 담긴 아홉 벌의 원본은 zip 안에 있고, 압축을 여는 비밀번호는 <span class="pw-inline" id="pw">pe8qtpqk</span> 입니다.
변형 3개
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 두꺼운 키캡 | 01 box-shadow: 0 6px 0 → 0 8px 0 |
키가 더 볼록해져 낡은 기계식 키보드 느낌이 납니다 |
| 넉넉한 대기창 | 05 WINDOW_MS = 1200 → 1800 |
두 키 사이 여유가 늘어 초보자도 순서를 놓치지 않습니다 |
| 세 칸 흘림 | 09 MAX = 5 → 3 |
화면 구석을 덜 차지해 시연 중 본문을 가리지 않습니다 |
AI 프롬프트
바이브코딩으로 단축키 부품을 하나 새로 만들 때는 보일 것·움직임·제약 셋을 한 번에 못 박는 편이 고쳐 쓰는 횟수를 줄입니다. 아래는 08 도움말 시트를 만들 때 쓴 조각입니다.
도움말 시트를 만든다.
보일 것: 330px 카드 안에 검색칸 하나와 단축키 묶음 두 개(편집·이동), 각 줄은 동작 이름과 kbd 키캡.
움직임: ? 키나 물음표 버튼으로 열 때 clip-path inset 위프 300ms, Esc 로 닫기.
제약: 시트는 카드 안 inset 0 으로 가두고, 검색은 목록을 다시 그리지 않고 그 자리에서 걸러라.
금지: opacity 페이드로 열기, 반쯤 열린 장면이 정지 이미지로 남겨지는 루프.
clip-path inset 위프 자리를 opacity 페이드 로 바꾸면 겹치는 글자가 반투명 유령으로 찍히니, 결과가 반대로 나올 때는 이 한 낱말부터 봅니다.
접근성
화면 움직임을 줄이는 prefers-reduced-motion: reduce 설정에서는 아홉 개의 애니메이션을 전부 끄고, 대신 어떤 상태인지 읽히는 한 장을 남깁니다. 01 은 세 키캡을 나란히 세워 두고 누른 키 칸의 미리보기 글자를 지우며, 02 는 감지한 플랫폼 표기로 멈춥니다. 03 은 툴팁을 접은 채 버튼만 두고, 04 는 첫 줄이 선택된 상태로 섭니다. 05 는 막대를 절반쯤 채운 대기 모습으로, 06 은 안내 문구가 보이는 빈 칸으로 멈춥니다. 07 은 충돌 배지가 붙은 표를 그대로 두고, 08 은 열린 시트를 보여 주며, 09 는 다섯 칸이 옅게 깔린 판으로 멈춥니다. 상태를 말로도 전하려고 02 토글은 aria-pressed, 04 목록은 로빙 tabindex 와 aria-selected, 06·07 은 aria-live, 08 시트는 aria-modal 을 달았고 03 버튼에는 aria-keyshortcuts 를 적어 스크린리더가 버튼 이름과 함께 조합을 읽게 했습니다. 단축키를 잡을 때 Alt 조합은 피했습니다 — 윈도우 메뉴 접근키·브라우저 창 명령과 겹쳐 우리가 막을 수 없는 동작이 먼저 일어나기 때문입니다. 한글·일본어 입력기 사용자를 위해 녹화기와 시트 열림은 isComposing 참을 때 무시합니다.
FAQ
userAgentData 는 어느 브라우저까지 되나요
caniuse 실측 기준 전역 77.83% — Chrome 90 이상과 엣지·오페라 등 크로미움 계열은 쓰지만 Safari 와 Firefox 는 아직 지원하지 않습니다. 그래서 02 는 userAgentData.platform 을 먼저 보고 없으면 navigator.platform 으로 내려가는 두 단계로 짰습니다.
단축키가 브라우저 기본 동작과 겹칠 때는 어떻게 하나요
06 녹화 칸처럼 그 화면이 초점을 가진 동안만 preventDefault 로 막는 범위를 좁힙니다. Ctrl+W·Ctrl+T 처럼 브라우저가 아예 페이지에 넘겨 주지 않는 조합은 막을 수 없으니 설계 단계에서 다른 조합으로 피하고, Alt 조합은 OS 메뉴와 겹쳐 역시 피하는 것이 정석입니다.
물리 키보드가 없는 기기에서 웹 단축키는 어떻게 하나요
01·04·05·09 는 키가 있어야 의미가 있으므로 (hover: hover) and (pointer: fine) 미디어쿼리로 힌트 문구만 노출 조절하고, 동작 자체는 04 의 줄 클릭·08 의 물음표 버튼처럼 터치 경로를 나란히 둡니다. 03 툴팁도 터치에서는 :focus-visible 로 초점이 갔을 때만 보이게 둘 수 있습니다.








