05두 키 연속 입력
G 를 누르고 이어서 I 를 누르면 받은 편지함으로 갑니다. 첫 키가 들어온 순간부터 남은 시간이 막대로 줄고, 시간이 다 되면 setTimeout 이 대기 상태를 스스로 풉니다.
쓰임이슈 트래커의 화면 이동, 코드 호스팅 서비스의 탐색 단축키
setTimeoutscaleXtransform-origin

<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 포함 · 키보드 단축키 UI 9종 — 키캡부터 도움말 시트까지
이 데모가 속한 글
키보드 단축키 UI 9종 — 키캡부터 도움말 시트까지단축키 UI는 키보드로 화면을 다루게 하는 키캡·표시·안내를 짜는 일입니다. 여기 아홉 가지는 키캡 세 스타일부터 플랫폼 자동 전환·녹화·충돌 표·도움말 시트까지 CSS 와 약간의 JS 로 만들어 둔 것이라, 붙여 이 글의 데모 전부 보기


