09키보드 단축키 안내 오버레이
? 버튼을 누르면 단축키 목록이 화면을 어둡게 깔고 뜨는데, 스페이스·좌우 화살표·M·F 가 목록에만 있는 게 아니라 그 자리에서 실제로 먹습니다.
쓰임키보드로만 쓰는 사람과 마우스만 쓰는 사람이 같이 오는 제품 소개 영상 자리
kbdrole=dialogfocus

<main class="stage">
<figure class="pl is-demo" id="pl">
<div class="pl__screen">
<div class="pl__stripes" aria-hidden="true"></div>
<div class="pl__world" aria-hidden="true"><span class="pl__sun"></span><span class="pl__hill pl__hill--a"></span><span class="pl__hill pl__hill--b"></span></div>
<span class="pl__clock"><span class="clk" id="clk">0:12</span><span class="clk clk--b" aria-hidden="true">0:07</span></span>
<span class="pl__mute" aria-hidden="true">음소거</span>
<div class="pl__bar">
<span class="pl__time"><b id="t-now">0:12</b> / 3:00</span>
<button class="qb" type="button" id="qb" aria-label="단축키 안내" aria-haspopup="dialog" aria-expanded="false">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="17" height="17"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-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.28c0-19.85-17.94-36-40-36m104 56A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
</button>
</div>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
<div class="ovl" id="ovl" role="dialog" aria-modal="true" aria-label="키보드 단축키 안내">
<div class="ovl__panel">
<div class="ovl__head">
<p class="ovl__title">단축키</p>
<button class="ovl__x" type="button" id="ovx" aria-label="닫기">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg></span>
</button>
</div>
<ul class="ovl__list">
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-space" data-k="space">Space</kbd></span><span class="ovl__what">재생·정지</span></li>
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-left" data-k="left">←</kbd><kbd class="k-right" data-k="right">→</kbd></span><span class="ovl__what">5초 이동</span></li>
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-m" data-k="m">M</kbd></span><span class="ovl__what">음소거</span></li>
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-f" data-k="f">F</kbd></span><span class="ovl__what">전체화면</span></li>
</ul>
</div>
</div>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// ── 플레이어 껍데기 (01 과 같은 무대 장치) ──
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
transform: scale(1);
transform-origin: center center;
transition: transform $dur-base $ease-spring;
}
.pl.is-full { transform: scale(1.06); }
.pl__screen {
position: relative;
height: clamp(124px, 34vw, 142px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .32), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.pl__world { position: absolute; inset: 0; }
.pl__sun {
position: absolute; right: 18%; top: 12%;
width: 26px; height: 26px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .25);
}
.pl__hill { position: absolute; bottom: -46%; height: 78%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(23, 20, 26, .15); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(23, 20, 26, .09); }
// 시계 칩 — 두 시각을 같은 칸에 겹쳐 두고 컷으로 갈아 끼운다 (← → 5초 이동)
.pl__clock {
position: absolute; left: $sp-2; top: $sp-2;
z-index: 2;
display: inline-grid;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 700;
}
.clk { grid-area: 1 / 1; }
.clk--b { opacity: 0; }
.pl.is-paused .pl__clock { background: $stage-yellow; color: $subject-ink; }
// 음소거 알약 — M 이 먹었다는 증거. 어두운 막 위에 뜬다(z-index)
.pl__mute {
position: absolute; right: $sp-2; top: $sp-2;
z-index: 2;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(194, 54, 26, .94);
color: #fff;
font-size: 10px; line-height: 18px; font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: scale(.7);
transform-origin: right center; // 규칙8: right 로 박아 둔 모서리 알약 — 중심이면 커지며 오른쪽 여백을 먹는다
transition: opacity $duration $easing, transform $duration $easing;
}
.pl.is-mute .pl__mute { opacity: 1; transform: none; }
.pl__bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-2 $sp-3;
}
.pl__time {
color: $subject-ink; // 화면 아래쪽은 크림이라 흰 글자가 1.35~2.10:1 이었다 — 잉크로 8.69~13.49:1
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, .6);
}
.pl__time b { font-weight: 700; }
.qb {
position: relative;
box-sizing: border-box;
width: 28px; height: 28px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: 50%;
background: $color;
color: #fff;
cursor: pointer;
box-shadow: 0 4px 10px rgba(47, 109, 246, .45);
transition: transform $duration $easing;
}
.qb:hover { transform: scale(1.08); }
.qb:active { transform: scale(.92); }
.qb:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.pl__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: rgba(255, 255, 255, .28); }
.pl__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.08);
}
// ── 단축키 다이얼로그 — 화면을 어둡게 깔고 목록이 뜬다. 화면 상자 안에 갇힌다(BD9) ──
.ovl {
position: absolute; inset: 0;
background: rgba(23, 20, 26, .84);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity $duration $easing;
}
.pl.is-open .ovl, .pl.is-demo .ovl { visibility: visible; }
.pl.is-open .ovl { opacity: 1; pointer-events: auto; }
.ovl__panel {
position: absolute; inset: 0;
box-sizing: border-box;
display: flex; flex-direction: column; justify-content: flex-start;
padding: $sp-8 $sp-3 $sp-3; // 위쪽 $sp-8 은 시계 칩(상단 30px)을 피하는 자리
transform: translateY(10px) scale(.94);
transition: transform $duration $easing;
}
.pl.is-open .ovl__panel { transform: none; }
.ovl__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: $sp-1; }
.ovl__title {
margin: 0;
color: $stage-yellow;
font-size: 10px; line-height: 18px; font-weight: 800;
letter-spacing: .08em;
}
.ovl__x {
box-sizing: border-box;
width: 18px; height: 18px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
padding: 0;
border: 0;
border-radius: $r-xs;
background: rgba(255, 255, 255, .16);
color: #fff;
cursor: pointer;
transition: background $duration $easing;
}
.ovl__x:hover { background: rgba(255, 255, 255, .32); }
.ovl__x:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
// 4줄을 세로로 세우면 무대(124px)를 넘는다 — 2×2 격자로 접는다 (BD9)
.ovl__list { display: grid; grid-template-columns: 1fr 1fr; gap: $sp-1 $sp-2; margin: 0; padding: 0; list-style: none; }
.ovl__row { display: grid; grid-template-columns: 56px 1fr; align-items: center; gap: $sp-2; }
.ovl__keys { display: flex; align-items: center; gap: $sp-1; }
.ovl__what {
color: rgba(255, 255, 255, .88);
font-size: 10px; line-height: 16px; font-weight: 600;
}
.ovl kbd {
box-sizing: border-box;
min-width: 22px;
padding: 0 $sp-1;
border: 1px solid rgba(255, 255, 255, .28);
border-radius: $r-xs;
background: rgba(255, 255, 255, .14);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 9px; line-height: 14px; font-weight: 700;
text-align: center;
}
// 사람이 진짜 키를 눌렀을 때 — 한 번만 반짝
.ovl kbd.is-hit {
animation-name: kbd-hit;
animation-duration: $dur-base;
animation-timing-function: $ease-pop;
animation-iteration-count: 1;
}
@keyframes kbd-hit {
0% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
30%, 60% { transform: translateY(2px); background: $stage-yellow; color: $subject-ink; border-color: $stage-yellow; }
100% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
}
// ── 자동 루프 (2s): 오버레이가 열리고 Space → ← → → → M → F 가 차례로 눌린다 ──
// animation- 낱개 속성만 쓴다. 단축을 쓰면 animation-delay 가 0 으로 리셋돼 kbd 다섯 개가 동시에 빛난다 (240 실측)
.pl.is-demo {
animation-name: sc-full;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl {
animation-name: sc-dim;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl__panel {
animation-name: sc-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pl.is-demo .pl__clock {
animation-name: sc-clock;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .clk {
animation-name: sc-clk-a;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .clk--b {
animation-name: sc-clk-b;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .pl__mute {
animation-name: sc-mute;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pl.is-demo .pl__fill {
animation-name: sc-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// kbd 다섯 개는 같은 keyframes 를 음수 delay 로 어긋나게 돌린다 (delay 는 반드시 낱개 속성)
.pl.is-demo .ovl kbd {
animation-name: kbd-cycle;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
// 2s 루프 중 220~420ms 구간만 눌린 모양 — 음수 delay 로 다섯 개를 어긋나게 놓는다
@keyframes kbd-cycle {
0%, 10% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
11%, 20% { transform: translateY(2px); background: $stage-yellow; color: $subject-ink; border-color: $stage-yellow; }
21%, 100% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
}
// 다섯 키를 73% 안에 끝낸다 — 오버레이가 닫히는 84~85% 프레임에서는 **어두운 막만** 바뀌어야
// poster 가 "열린 오버레이" 쪽으로 뽑힌다 (poster 는 변한 픽셀 '면적' 이 가장 큰 장면이라,
// 노란 시계·음소거 알약·확대가 닫힘과 같은 프레임에 되돌아오면 그쪽 면적이 더 커진다 — 실측 2026-09-12)
.pl.is-demo .ovl .k-space { animation-delay: -1980ms; }
.pl.is-demo .ovl .k-left { animation-delay: -1700ms; }
.pl.is-demo .ovl .k-right { animation-delay: -1420ms; }
.pl.is-demo .ovl .k-m { animation-delay: -1180ms; }
.pl.is-demo .ovl .k-f { animation-delay: -940ms; }
// 여닫는 opacity 는 한 프레임(24프레임 기준 4.17%) 안으로 좁힌다 — 페이드 중간 프레임이 poster 로 뽑혀
// 오버레이가 반투명하게 시간 글자와 겹쳐 보였다 (실측 2026-09-12). 부드러움은 rise 쪽에만 남긴다
// 페이드 구간이 **샘플 프레임 사이**에 오게 맞춘다. 24프레임은 4.1667% 간격(…8.33·12.5…83.33·87.5…)이라
// 9→10% 와 84→85% 에 두면 반투명한 프레임이 한 장도 안 뽑힌다. 86→88% 였을 때 87.5% 프레임(opacity .25)이
// poster 로 뽑혀 오버레이가 유령처럼 비쳤다 (실측 2026-09-12)
// 여는 쪽은 샘플 프레임 사이(9→10%)에서 **컷**, 닫는 쪽은 84→90% 로 **나눠서** 사라진다.
// 둘 다 컷으로 하면 닫는 프레임의 변화량이 더 커서(그때는 노란 시계·음소거 알약·확대까지 같이 되돌아온다)
// poster 가 "오버레이가 없는 장면" 으로 뽑혔다 — 주인공이 안 보이는 썸네일이 된다 (실측 2026-09-12)
@keyframes sc-dim {
0%, 9% { opacity: 0; }
10%, 84% { opacity: 1; }
85%, 100% { opacity: 0; }
}
@keyframes sc-rise {
0%, 9% { transform: translateY(10px) scale(.94); }
16%, 84% { transform: none; }
85%, 100% { transform: translateY(10px) scale(.94); }
}
// Space — 시계 칩이 노랗게 컷되며 "멈췄다"를 알린다
@keyframes sc-clock {
0%, 21% { background: rgba(23, 20, 26, .62); color: #fff; }
22%, 73% { background: $stage-yellow; color: $subject-ink; }
74%, 100% { background: rgba(23, 20, 26, .62); color: #fff; }
}
// ← → — 두 시각을 한 프레임에 갈아 끼운다
@keyframes sc-clk-a {
0%, 34% { opacity: 1; }
35%, 49% { opacity: 0; }
50%, 100% { opacity: 1; }
}
@keyframes sc-clk-b {
0%, 34% { opacity: 0; }
35%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
// M — 음소거 알약
@keyframes sc-mute {
0%, 53% { opacity: 0; transform: scale(.7); }
58%, 73% { opacity: 1; transform: none; }
74%, 100% { opacity: 0; transform: scale(.7); }
}
// F — 플레이어가 확장 상태로
@keyframes sc-full {
0%, 63% { transform: scale(1); }
69%, 73% { transform: scale(1.06); }
78%, 100% { transform: scale(1); }
}
@keyframes sc-fill {
0% { transform: scaleX(.08); }
22%, 34% { transform: scaleX(.2); }
35%, 49% { transform: scaleX(.14); }
50%, 73% { transform: scaleX(.2); }
78%, 100% { transform: scaleX(.08); }
}
// 좁은 화면 — 여기서는 간격을 올리지 않는다 (BD9 세로 174px)
@media (max-width: 340px) {
.ovl__panel { padding: $sp-6 $sp-2 $sp-2; }
.ovl__list { gap: 0 $sp-1; }
.ovl__row { grid-template-columns: 52px 1fr; gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo, .pl.is-demo .ovl, .pl.is-demo .ovl__panel, .pl.is-demo .pl__clock,
.pl.is-demo .clk, .pl.is-demo .clk--b, .pl.is-demo .pl__mute, .pl.is-demo .pl__fill,
.pl.is-demo .ovl kbd, .ovl kbd.is-hit, .pl__stripes { animation: none !important; }
.pl, .ovl, .ovl__panel, .pl__mute, .qb, .ovl__x { transition: none; }
.pl.is-demo .ovl { opacity: 1; }
}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);
}
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
transform: scale(1);
transform-origin: center center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl.is-full {
transform: scale(1.06);
}
.pl__screen {
position: relative;
height: clamp(124px, 34vw, 142px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.32), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.pl__world {
position: absolute;
inset: 0;
}
.pl__sun {
position: absolute;
right: 18%;
top: 12%;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.25);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 78%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(23, 20, 26, 0.15);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(23, 20, 26, 0.09);
}
.pl__clock {
position: absolute;
left: 8px;
top: 8px;
z-index: 2;
display: inline-grid;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.clk {
grid-area: 1/1;
}
.clk--b {
opacity: 0;
}
.pl.is-paused .pl__clock {
background: #ffd23f;
color: #17141a;
}
.pl__mute {
position: absolute;
right: 8px;
top: 8px;
z-index: 2;
padding: 0 8px;
border-radius: 999px;
background: rgba(194, 54, 26, 0.94);
color: #fff;
font-size: 10px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: scale(0.7);
transform-origin: right center;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pl.is-mute .pl__mute {
opacity: 1;
transform: none;
}
.pl__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 12px;
}
.pl__time {
color: #17141a;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, 0.6);
}
.pl__time b {
font-weight: 700;
}
.qb {
position: relative;
box-sizing: border-box;
width: 28px;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
background: #2f6df6;
color: #fff;
cursor: pointer;
box-shadow: 0 4px 10px rgba(47, 109, 246, 0.45);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.qb:hover {
transform: scale(1.08);
}
.qb:active {
transform: scale(0.92);
}
.qb:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pl__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: rgba(255, 255, 255, 0.28);
}
.pl__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.08);
}
.ovl {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.84);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pl.is-open .ovl, .pl.is-demo .ovl {
visibility: visible;
}
.pl.is-open .ovl {
opacity: 1;
pointer-events: auto;
}
.ovl__panel {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 32px 12px 12px;
transform: translateY(10px) scale(0.94);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pl.is-open .ovl__panel {
transform: none;
}
.ovl__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ovl__title {
margin: 0;
color: #ffd23f;
font-size: 10px;
line-height: 18px;
font-weight: 800;
letter-spacing: 0.08em;
}
.ovl__x {
box-sizing: border-box;
width: 18px;
height: 18px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 4px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ovl__x:hover {
background: rgba(255, 255, 255, 0.32);
}
.ovl__x:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.ovl__list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 8px;
margin: 0;
padding: 0;
list-style: none;
}
.ovl__row {
display: grid;
grid-template-columns: 56px 1fr;
align-items: center;
gap: 8px;
}
.ovl__keys {
display: flex;
align-items: center;
gap: 4px;
}
.ovl__what {
color: rgba(255, 255, 255, 0.88);
font-size: 10px;
line-height: 16px;
font-weight: 600;
}
.ovl kbd {
box-sizing: border-box;
min-width: 22px;
padding: 0 4px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 9px;
line-height: 14px;
font-weight: 700;
text-align: center;
}
.ovl kbd.is-hit {
animation-name: kbd-hit;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes kbd-hit {
0% {
transform: translateY(0);
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.28);
}
30%, 60% {
transform: translateY(2px);
background: #ffd23f;
color: #17141a;
border-color: #ffd23f;
}
100% {
transform: translateY(0);
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.28);
}
}
.pl.is-demo {
animation-name: sc-full;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .ovl {
animation-name: sc-dim;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl__panel {
animation-name: sc-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .pl__clock {
animation-name: sc-clock;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .clk {
animation-name: sc-clk-a;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .clk--b {
animation-name: sc-clk-b;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .pl__mute {
animation-name: sc-mute;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .pl__fill {
animation-name: sc-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl kbd {
animation-name: kbd-cycle;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes kbd-cycle {
0%, 10% {
transform: translateY(0);
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.28);
}
11%, 20% {
transform: translateY(2px);
background: #ffd23f;
color: #17141a;
border-color: #ffd23f;
}
21%, 100% {
transform: translateY(0);
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.28);
}
}
.pl.is-demo .ovl .k-space {
animation-delay: -1980ms;
}
.pl.is-demo .ovl .k-left {
animation-delay: -1700ms;
}
.pl.is-demo .ovl .k-right {
animation-delay: -1420ms;
}
.pl.is-demo .ovl .k-m {
animation-delay: -1180ms;
}
.pl.is-demo .ovl .k-f {
animation-delay: -940ms;
}
@keyframes sc-dim {
0%, 9% {
opacity: 0;
}
10%, 84% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes sc-rise {
0%, 9% {
transform: translateY(10px) scale(0.94);
}
16%, 84% {
transform: none;
}
85%, 100% {
transform: translateY(10px) scale(0.94);
}
}
@keyframes sc-clock {
0%, 21% {
background: rgba(23, 20, 26, 0.62);
color: #fff;
}
22%, 73% {
background: #ffd23f;
color: #17141a;
}
74%, 100% {
background: rgba(23, 20, 26, 0.62);
color: #fff;
}
}
@keyframes sc-clk-a {
0%, 34% {
opacity: 1;
}
35%, 49% {
opacity: 0;
}
50%, 100% {
opacity: 1;
}
}
@keyframes sc-clk-b {
0%, 34% {
opacity: 0;
}
35%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes sc-mute {
0%, 53% {
opacity: 0;
transform: scale(0.7);
}
58%, 73% {
opacity: 1;
transform: none;
}
74%, 100% {
opacity: 0;
transform: scale(0.7);
}
}
@keyframes sc-full {
0%, 63% {
transform: scale(1);
}
69%, 73% {
transform: scale(1.06);
}
78%, 100% {
transform: scale(1);
}
}
@keyframes sc-fill {
0% {
transform: scaleX(0.08);
}
22%, 34% {
transform: scaleX(0.2);
}
35%, 49% {
transform: scaleX(0.14);
}
50%, 73% {
transform: scaleX(0.2);
}
78%, 100% {
transform: scaleX(0.08);
}
}
@media (max-width: 340px) {
.ovl__panel {
padding: 24px 8px 8px;
}
.ovl__list {
gap: 0 4px;
}
.ovl__row {
grid-template-columns: 52px 1fr;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo, .pl.is-demo .ovl, .pl.is-demo .ovl__panel, .pl.is-demo .pl__clock,
.pl.is-demo .clk, .pl.is-demo .clk--b, .pl.is-demo .pl__mute, .pl.is-demo .pl__fill,
.pl.is-demo .ovl kbd, .ovl kbd.is-hit, .pl__stripes {
animation: none !important;
}
.pl, .ovl, .ovl__panel, .pl__mute, .qb, .ovl__x {
transition: none;
}
.pl.is-demo .ovl {
opacity: 1;
}
}// 실제 동작: ? 로 단축키 다이얼로그를 열고 Space·←·→·M·F 가 그 자리에서 먹는다.
var root = document.getElementById('pl'),
qbtn = document.getElementById('qb'),
ovx = document.getElementById('ovx'),
clk = document.getElementById('clk'),
now = document.getElementById('t-now'),
fill = document.getElementById('fill');
var DUR = 180, t = 12, playing = true, last = Date.now();
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function paint() {
clk.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}
paint();
setInterval(function () {
var n = Date.now();
if (playing) { t = (t + (n - last) / 1000) % DUR; paint(); }
last = n;
}, 250);
function wake() { root.classList.remove('is-demo'); }
function openOvl() {
wake(); root.classList.add('is-open');
qbtn.setAttribute('aria-expanded', 'true');
ovx.focus({ preventScroll: true });
}
function closeOvl() {
root.classList.remove('is-open');
qbtn.setAttribute('aria-expanded', 'false');
qbtn.focus({ preventScroll: true });
}
qbtn.addEventListener('click', openOvl);
ovx.addEventListener('click', closeOvl);
function flash(k) {
var el = document.querySelector('.ovl kbd[data-k="' + k + '"]');
if (!el) return;
el.classList.remove('is-hit'); void el.offsetWidth; el.classList.add('is-hit');
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { if (root.classList.contains('is-open')) closeOvl(); return; }
var k = e.key === ' ' ? 'space' : e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right'
: (e.key || '').toLowerCase() === 'm' ? 'm' : (e.key || '').toLowerCase() === 'f' ? 'f' : null;
if (!k) return;
e.preventDefault(); wake();
if (k === 'space') { playing = !playing; root.classList.toggle('is-paused', !playing); }
if (k === 'left') { t = Math.max(0, t - 5); paint(); }
if (k === 'right') { t = Math.min(DUR, t + 5); paint(); }
if (k === 'm') root.classList.toggle('is-mute');
if (k === 'f') root.classList.toggle('is-full');
flash(k);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShortcutOverlay.module.scss";
export type ShortcutOverlayProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type Hit = { id: string; n: number };
type Row = { keys: { id: string; label: string }[]; what: string };
const ROWS: Row[] = [
{ keys: [{ id: "space", label: "Space" }], what: "재생·정지" },
{ keys: [{ id: "left", label: "←" }, { id: "right", label: "→" }], what: "5초 이동" },
{ keys: [{ id: "m", label: "M" }], what: "음소거" },
{ keys: [{ id: "f", label: "F" }], what: "전체화면" },
];
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function QuestionIcon() {
return (
<svg viewBox="0 0 256 256" width="17" height="17" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-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.28c0-19.85-17.94-36-40-36m104 56A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" />
</g>
</svg>
);
}
export default function ShortcutOverlay({
total = 180,
start = 12,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ShortcutOverlayProps) {
const [open, setOpen] = useState(false);
const [playing, setPlaying] = useState(true);
const [mute, setMute] = useState(false);
const [full, setFull] = useState(false);
const [t, setT] = useState(start);
const [hit, setHit] = useState<Hit>({ id: "", n: 0 });
const qbRef = useRef<HTMLButtonElement>(null);
const xRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — Space 로 멈추면 같이 멈춘다 (바닐라의 setInterval(250ms))
useEffect(() => {
if (!playing) return;
let last = Date.now();
const id = setInterval(() => {
const n = Date.now();
const step = (n - last) / 1000;
last = n;
setT((p) => (p + step) % total);
}, 250);
return () => clearInterval(id);
}, [playing, total]);
// 열리면 닫기 버튼으로 초점을 옮긴다 (되돌리는 건 close 에서)
useEffect(() => {
if (open) xRef.current?.focus({ preventScroll: true });
}, [open]);
// 단축키는 오버레이가 닫혀 있어도 그 자리에서 먹는다. Escape 는 오버레이만 닫는다
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
if (open) close();
return;
}
const k =
e.key === " " ? "space"
: e.key === "ArrowLeft" ? "left"
: e.key === "ArrowRight" ? "right"
: (e.key || "").toLowerCase() === "m" ? "m"
: (e.key || "").toLowerCase() === "f" ? "f"
: "";
if (!k) return;
e.preventDefault();
if (k === "space") setPlaying((v) => !v);
if (k === "left") setT((v) => Math.max(0, v - 5));
if (k === "right") setT((v) => Math.min(total, v + 5));
if (k === "m") setMute((v) => !v);
if (k === "f") setFull((v) => !v);
setHit((h) => ({ id: k, n: h.n + 1 }));
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, total]);
function close() {
setOpen(false);
qbRef.current?.focus({ preventScroll: true });
}
const cls = [
styles.player,
open ? styles.isOpen : "",
playing ? "" : styles.isPaused,
mute ? styles.isMute : "",
full ? styles.isFull : "",
].join(" ");
return (
<figure className={cls} style={vars}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<span className={styles.mute} aria-hidden="true">음소거</span>
<div className={styles.bar}>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<button
ref={qbRef}
className={styles.qb}
type="button"
aria-label="단축키 안내"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<QuestionIcon />
</button>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
<div className={styles.ovl} role="dialog" aria-modal="true" aria-label="키보드 단축키 안내">
<div className={styles.panel}>
<div className={styles.head}>
<p className={styles.title}>단축키</p>
<button ref={xRef} className={styles.x} type="button" aria-label="닫기" onClick={close}>
<CloseIcon />
</button>
</div>
<ul className={styles.list}>
{ROWS.map((r) => (
<li key={r.what} className={styles.row}>
<span className={styles.keys}>
{r.keys.map((k) => (
// key 를 바꿔 다시 마운트시켜야 눌림 애니메이션이 매번 처음부터 돈다
<kbd
key={k.id + "-" + (hit.id === k.id ? hit.n : 0)}
className={`${styles.kbd} ${hit.id === k.id ? styles.isHit : ""}`}
>
{k.label}
</kbd>
))}
</span>
<span className={styles.what}>{r.what}</span>
</li>
))}
</ul>
</div>
</div>
</div>
</figure>
);
}09 키보드 단축키 안내 오버레이 — 측정 누적면적 43.807% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다
이 데모가 속한 글
동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다동영상 플레이어 만들기는 브라우저가 기본으로 주는 밋밋한 재생 막대를 버리고 재생 버튼·시간 바·음량·자막을 직접 그려 붙이는 일입니다. 여기 아홉 개는 영상 파일을 하나도 쓰지 않고, 화면 속 풍경까지 CSS 로 이 글의 데모 전부 보기


