동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다
동영상 플레이어 만들기는 브라우저가 기본으로 주는 밋밋한 재생 막대를 버리고 재생 버튼·시간 바·음량·자막을 직접 그려 붙이는 일입니다. 여기 아홉 개는 영상 파일을 하나도 쓰지 않고, 화면 속 풍경까지 CSS 로 그려 그 위에서 움직입니다.
01 video-player-play-pause-morph
02 video-player-seek-bar-tooltip
03 video-player-volume-popover
04 video-player-speed-menu
05 video-player-caption-toggle
06 video-player-chapter-markers
07 video-player-mini-pip
08 video-player-fullscreen-toggle
09 video-player-shortcut-overlay
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 재생·일시정지 모프 버튼
- 02 시간 툴팁 달린 시크 바
- 03 볼륨 슬라이더 팝오버
- 04 재생 속도 메뉴
- 05 자막 토글과 자막 스타일
- 06 챕터 마커 시크 바
- 07 미니 플레이어
- 08 풀스크린 토글
- 09 키보드 단축키 안내 오버레이
아홉 개를 늘어놓은 순서는 인기순이 아니라 사람 손이 비디오 플레이어 ui 를 만지는 차례입니다. 먼저 틀고(01), 지금 어디쯤인지 보며 옮기고(02), 소리를 줄이고(03), 재생 속도를 고릅니다(04). 그다음 셋은 화면 자체를 바꾸는 일입니다 — 자막을 켜고(05), 긴 영상을 챕터로 끊어 건너뛰고(06), 글을 계속 읽으려고 영상을 구석으로 치웁니다(07). 마지막 둘은 반대로 화면을 키우거나(08) 손을 아예 키보드로 옮겨 단축키 안내를 띄우는 경우(09)입니다. 아홉 개 다 <video> 태그도 mp4 파일도 쓰지 않습니다. 창밖처럼 흐르는 풍경은 줄무늬 한 겹과 해·언덕 세 덩이를 repeating-linear-gradient 로 밀어 만든 것이고, 시간 글자와 진행 바는 CSS 가 움직이는 값을 그대로 읽어 씁니다. 480×300 무대에서 2초 루프를 24프레임으로 재어 보니 아홉 개 다 20프레임 이상에서 화면이 실제로 바뀌었고(여덟 개는 23프레임 전부), 화면 폭을 320px 로 줄여도 삐져나오는 것이 하나도 없었습니다.
01재생·일시정지 모프 버튼
무엇이 보이나
버튼을 누르면 재생 삼각형이 돌면서 일시정지 두 막대로 갈아입고, 옆에 붙은 시간 표시가 실제로 흘러갑니다. 아이콘 두 장을 같은 자리에 겹쳐 두고 한 장은 rotate(-120deg) scale(.3) 에서, 다른 한 장은 rotate(120deg) scale(.3) 에서 돌아오게 해 한 덩어리가 모양을 바꾸는 것처럼 보이게 했습니다.

<main class="stage">
<figure class="pl is-demo is-playing" 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" id="clock">0:12</span>
<span class="pl__badge">3:00</span>
<div class="pl__bar">
<button class="pp" type="button" id="pp" aria-label="일시정지">
<span class="pp__ring" aria-hidden="true"></span>
<span class="pp__ic pp__ic--play" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></svg></span>
<span class="pp__ic pp__ic--pause" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></svg></span>
</button>
<span class="pl__time"><b id="t-now">0:12</b> / 3:00</span>
</div>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.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;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
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: 14%;
width: 28px; height: 28px; 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: 82%; 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); }
.pl__clock {
position: absolute; left: $sp-2; top: $sp-2;
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: 16px; font-weight: 700;
}
.pl__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
color: rgba(255, 255, 255, .85);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 600;
}
.pl__bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2 calc(#{$sp-2} + 8px);
}
.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; }
.pp {
position: relative;
box-sizing: border-box;
width: 38px; height: 38px;
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 6px 14px rgba(47, 109, 246, .45);
transition: transform $duration $easing;
}
.pp:hover { transform: scale(1.06); }
.pp:active { transform: scale(.94); }
.pp:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.pp__ic {
position: absolute; inset: 0;
display: inline-flex; align-items: center; justify-content: center;
transition: opacity $duration $easing, transform $duration $easing;
}
.pp__ic--pause { opacity: 0; transform: rotate(120deg) scale(.3); }
.pl.is-playing .pp__ic--play { opacity: 0; transform: rotate(-120deg) scale(.3); }
.pl.is-playing .pp__ic--pause { opacity: 1; transform: none; }
.pp__ring {
position: absolute; inset: 0;
border: 2px solid rgba(255, 255, 255, .8);
border-radius: 50%;
opacity: 0;
transform: scale(1);
}
.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(.07);
}
// ── 자동 루프: 재생 중(일시정지 아이콘) → 멈춤(재생 아이콘) → 다시 재생. 원은 상태가 바뀌는 순간마다 한 번 퍼진다 ──
.pl.is-demo .pp__ic--play {
animation-name: pp-play; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.pl.is-demo .pp__ic--pause {
animation-name: pp-pause; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.pl.is-demo .pp__ring {
animation-name: pp-ring; animation-duration: $dur-loop;
animation-timing-function: ease-out; animation-iteration-count: infinite;
}
.pl.is-demo .pl__fill {
animation-name: pp-fill; animation-duration: $dur-loop;
animation-timing-function: linear; animation-iteration-count: infinite;
}
@keyframes pp-play {
0%, 38% { opacity: 0; transform: rotate(-120deg) scale(.3); }
50%, 78% { opacity: 1; transform: none; }
90%, 100% { opacity: 0; transform: rotate(-120deg) scale(.3); }
}
@keyframes pp-pause {
0%, 38% { opacity: 1; transform: none; }
50%, 78% { opacity: 0; transform: rotate(120deg) scale(.3); }
90%, 100% { opacity: 1; transform: none; }
}
@keyframes pp-ring {
0%, 36% { opacity: 0; transform: scale(.9); }
42% { opacity: .9; }
56% { opacity: 0; transform: scale(1.9); }
76% { opacity: 0; transform: scale(.9); }
82% { opacity: .9; }
96%, 100% { opacity: 0; transform: scale(1.9); }
}
@keyframes pp-fill {
0% { transform: scaleX(.07); }
38% { transform: scaleX(.3); }
50%, 78% { transform: scaleX(.3); }
90%, 100% { transform: scaleX(.07); }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .pp__ic--play, .pl.is-demo .pp__ic--pause, .pl.is-demo .pp__ring,
.pl.is-demo .pl__fill, .pl__stripes { animation: none !important; }
.pp, .pp__ic { transition: none; }
}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;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
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: 14%;
width: 28px;
height: 28px;
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: 82%;
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;
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: 16px;
font-weight: 700;
}
.pl__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
color: rgba(255, 255, 255, 0.85);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 600;
}
.pl__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px calc(8px + 8px);
}
.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;
}
.pp {
position: relative;
box-sizing: border-box;
width: 38px;
height: 38px;
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 6px 14px rgba(47, 109, 246, 0.45);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pp:hover {
transform: scale(1.06);
}
.pp:active {
transform: scale(0.94);
}
.pp:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pp__ic {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pp__ic--pause {
opacity: 0;
transform: rotate(120deg) scale(0.3);
}
.pl.is-playing .pp__ic--play {
opacity: 0;
transform: rotate(-120deg) scale(0.3);
}
.pl.is-playing .pp__ic--pause {
opacity: 1;
transform: none;
}
.pp__ring {
position: absolute;
inset: 0;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: 50%;
opacity: 0;
transform: scale(1);
}
.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.07);
}
.pl.is-demo .pp__ic--play {
animation-name: pp-play;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .pp__ic--pause {
animation-name: pp-pause;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .pp__ring {
animation-name: pp-ring;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.pl.is-demo .pl__fill {
animation-name: pp-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pp-play {
0%, 38% {
opacity: 0;
transform: rotate(-120deg) scale(0.3);
}
50%, 78% {
opacity: 1;
transform: none;
}
90%, 100% {
opacity: 0;
transform: rotate(-120deg) scale(0.3);
}
}
@keyframes pp-pause {
0%, 38% {
opacity: 1;
transform: none;
}
50%, 78% {
opacity: 0;
transform: rotate(120deg) scale(0.3);
}
90%, 100% {
opacity: 1;
transform: none;
}
}
@keyframes pp-ring {
0%, 36% {
opacity: 0;
transform: scale(0.9);
}
42% {
opacity: 0.9;
}
56% {
opacity: 0;
transform: scale(1.9);
}
76% {
opacity: 0;
transform: scale(0.9);
}
82% {
opacity: 0.9;
}
96%, 100% {
opacity: 0;
transform: scale(1.9);
}
}
@keyframes pp-fill {
0% {
transform: scaleX(0.07);
}
38% {
transform: scaleX(0.3);
}
50%, 78% {
transform: scaleX(0.3);
}
90%, 100% {
transform: scaleX(0.07);
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .pp__ic--play, .pl.is-demo .pp__ic--pause, .pl.is-demo .pp__ring,
.pl.is-demo .pl__fill, .pl__stripes {
animation: none !important;
}
.pp, .pp__ic {
transition: none;
}
}// 실제 동작: 버튼으로 재생/일시정지를 바꾸고 더미 재생 시계를 걷는다.
var root = document.getElementById('pl'),
btn = document.getElementById('pp'),
clock = document.getElementById('clock'),
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() {
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}
setInterval(function () {
var n = Date.now();
if (playing) t = (t + (n - last) / 1000) % DUR;
last = n;
paint();
}, 250);
function toggle() {
root.classList.remove('is-demo');
playing = !playing;
root.classList.toggle('is-playing', playing);
btn.setAttribute('aria-label', playing ? '일시정지' : '재생');
}
btn.addEventListener('click', toggle);import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PlayPauseMorph.module.scss";
export type PlayPauseMorphProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function PlayIcon() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2" />
<path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z" />
</g>
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2" />
<path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z" />
</g>
</svg>
);
}
export default function PlayPauseMorph({
total = 180,
start = 12,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: PlayPauseMorphProps) {
const [playing, setPlaying] = useState(true);
const [t, setT] = useState(start);
const [pulse, setPulse] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 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]);
function toggle() {
setPlaying((p) => !p);
setPulse((p) => p + 1);
}
return (
<figure className={`${styles.player} ${playing ? styles.isPlaying : ""}`} 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>
<div className={styles.bar}>
<button
className={styles.pp}
type="button"
aria-label={playing ? "일시정지" : "재생"}
onClick={toggle}
>
{pulse > 0 ? <span key={pulse} className={styles.ring} aria-hidden="true" /> : null}
<span className={`${styles.ic} ${styles.icPlay}`}>
<PlayIcon />
</span>
<span className={`${styles.ic} ${styles.icPause}`}>
<PauseIcon />
</span>
</button>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
</figure>
);
}01 · 재생·일시정지 모프 버튼 — 측정 누적면적 31.303% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
자동 시연에서는 상태가 바뀔 때마다 흰 테두리 원이 한 번 퍼지고, 직접 누를 때는 버튼이 살짝 눌려 들어가며 반응을 돌려줍니다.
.pp__ic {
position: absolute; inset: 0;
display: inline-flex; align-items: center; justify-content: center;
transition: opacity $duration $easing, transform $duration $easing;
}
.pp__ic--pause { opacity: 0; transform: rotate(120deg) scale(.3); }
.pl.is-playing .pp__ic--play { opacity: 0; transform: rotate(-120deg) scale(.3); }
.pl.is-playing .pp__ic--pause { opacity: 1; transform: none; }
02시간 툴팁 달린 시크 바
화면에서 일어나는 일
마우스를 올린 자리에 시간 툴팁이 따라붙고, 재생한 구간은 파랗게 차오르며 읽어 온 버퍼는 그보다 18% 앞까지 옅게 깔립니다.

<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" id="clock">0:32</span>
<span class="pl__badge">3:00</span>
</div>
<div class="sk__row">
<span class="sk__t"><b id="t-now">0:32</b> / 3:00</span>
<div class="sk is-hover" id="sk" role="slider" tabindex="0" aria-label="재생 위치" aria-valuemin="0" aria-valuemax="180" aria-valuenow="32" aria-valuetext="0:32 / 3:00">
<span class="sk__track" aria-hidden="true">
<span class="sk__buf" id="buf"></span>
<span class="sk__fill"></span>
<span class="sk__thumbwrap"><span class="sk__thumb"></span></span>
</span>
<span class="sk__tip" aria-hidden="true">
<span class="sk__prev"><span class="sk__pat"></span></span>
<b class="sk__tt" id="tt">0:32</b>
</span>
</div>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// --p 는 등록된 사용자 지정 속성 — 키프레임에서 보간된다 (미등록이면 steps 처럼 튄다)
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.18; }
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(84px, 22vw, 116px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute; inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .13) 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: 24px; height: 24px; 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: 82%; 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); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
.sk__row {
display: flex; align-items: center; gap: $sp-3;
padding: $sp-3;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, .72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
}
.sk {
--p: 0.18;
--h: 0.18;
position: relative;
flex: 1 1 auto;
height: 44px;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-sm; }
.sk__track {
position: absolute; left: 0; right: 0; top: 50%;
height: 10px;
margin-top: -5px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .14);
}
.sk__buf, .sk__fill {
position: absolute; inset: 0;
border-radius: $r-pill;
transform-origin: left center; // 규칙8: 좌변서 차오름
}
// 채움과 버퍼는 둘 다 --p 하나에서 나온다. 채움을 고정값(scaleX(.18))으로 두면 재생 머리만 가고
// 파란 구간은 제자리에 남는다 (실측 2026-09-12: 머리 54% · 채움 20%)
.sk__buf { background: rgba(23, 20, 26, .16); transform: scaleX(min(1, calc(var(--p) + .18))); }
.sk__fill { background: $color; transform: scaleX(var(--p)); }
.sk__thumbwrap {
position: absolute; left: 0; right: 0; top: 0; bottom: 0;
pointer-events: none;
transform: translateX(calc((100% - 14px) * var(--p)));
}
.sk__thumb {
position: absolute; left: 0; top: 50%;
width: 14px; height: 14px;
margin-top: -7px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(23, 20, 26, .4);
}
.sk__tip {
position: absolute; bottom: 34px; left: 0;
width: 76px;
padding: $sp-1;
border-radius: $r-sm;
background: $subject-ink;
box-shadow: $shadow-press;
opacity: 0;
transform: translateX(calc((100% - 76px) * var(--h, var(--p))));
transition: opacity $duration $easing;
pointer-events: none;
}
.sk.is-hover .sk__tip, .sk:hover .sk__tip, .sk:focus-visible .sk__tip { opacity: 1; }
.sk__prev {
position: relative;
display: block;
height: 26px;
border-radius: $r-xs;
overflow: hidden;
background: linear-gradient(180deg, rgba(111, 169, 255, .5), rgba(255, 247, 230, .9));
}
.sk__pat {
position: absolute; inset: 0 -80px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .16) 0 10px, transparent 10px 40px);
transform: translateX(calc(-40px + var(--h, var(--p)) * -80px));
}
.sk__tt {
display: block;
padding-top: $sp-1;
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 12px; font-weight: 700;
text-align: center;
}
// ── 자동 루프: 툴팁이 바를 쓸며 시간을 재고 되돌아온다. --p 하나가 채움·엄지·툴팁·무늬를 함께 움직인다 ──
.pl.is-demo .sk {
--h: var(--p);
animation-name: sk-p;
animation-duration: $dur-loop;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes sk-p {
0% { --p: 0.18; }
78% { --p: 0.92; }
88% { --p: 0.92; }
100% { --p: 0.18; }
}
.pl.is-demo .sk__tip { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .sk, .pl__stripes { animation: none !important; }
.sk__tip { transition: none; }
}@property --p {
syntax: "<number>";
inherits: true;
initial-value: 0.18;
}
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);
}
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(84px, 22vw, 116px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.13) 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: 24px;
height: 24px;
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: 82%;
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, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.sk__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, 0.72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
}
.sk {
--p: 0.18;
--h: 0.18;
position: relative;
flex: 1 1 auto;
height: 44px;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 8px;
}
.sk__track {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 10px;
margin-top: -5px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.sk__buf, .sk__fill {
position: absolute;
inset: 0;
border-radius: 999px;
transform-origin: left center;
}
.sk__buf {
background: rgba(23, 20, 26, 0.16);
transform: scaleX(min(1, var(--p) + 0.18));
}
.sk__fill {
background: #2f6df6;
transform: scaleX(var(--p));
}
.sk__thumbwrap {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
pointer-events: none;
transform: translateX(calc((100% - 14px) * var(--p)));
}
.sk__thumb {
position: absolute;
left: 0;
top: 50%;
width: 14px;
height: 14px;
margin-top: -7px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.4);
}
.sk__tip {
position: absolute;
bottom: 34px;
left: 0;
width: 76px;
padding: 4px;
border-radius: 8px;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateX(calc((100% - 76px) * var(--h, var(--p))));
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.sk.is-hover .sk__tip, .sk:hover .sk__tip, .sk:focus-visible .sk__tip {
opacity: 1;
}
.sk__prev {
position: relative;
display: block;
height: 26px;
border-radius: 4px;
overflow: hidden;
background: linear-gradient(180deg, rgba(111, 169, 255, 0.5), rgba(255, 247, 230, 0.9));
}
.sk__pat {
position: absolute;
inset: 0 -80px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.16) 0 10px, transparent 10px 40px);
transform: translateX(calc(-40px + var(--h, var(--p)) * -80px));
}
.sk__tt {
display: block;
padding-top: 4px;
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 12px;
font-weight: 700;
text-align: center;
}
.pl.is-demo .sk {
--h: var(--p);
animation-name: sk-p;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes sk-p {
0% {
--p: 0.18;
}
78% {
--p: 0.92;
}
88% {
--p: 0.92;
}
100% {
--p: 0.18;
}
}
.pl.is-demo .sk__tip {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .sk, .pl__stripes {
animation: none !important;
}
.sk__tip {
transition: none;
}
}// 실제 동작: 마우스를 올리면 그 지점 시간을 보여 주고, 누르거나 끌면 그곳으로 건너뛴다.
var root = document.getElementById('pl'),
sk = document.getElementById('sk'),
tt = document.getElementById('tt'),
now = document.getElementById('t-now'),
clock = document.getElementById('clock');
var DUR = 180, t = 32;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function paint() {
var p = t / DUR;
sk.style.setProperty('--p', p.toFixed(4));
sk.setAttribute('aria-valuenow', Math.floor(t));
sk.setAttribute('aria-valuetext', fmt(t) + ' / 3:00');
now.textContent = fmt(t);
clock.textContent = fmt(t);
tt.textContent = fmt(p * DUR);
}
// 자동 루프 중엔 CSS 가 --p 를 움직이므로, 글자는 그리기 직전(rAF)에 그 값을 읽어 같은 프레임에 맞춘다.
// 따로 세면 시계와 재생 머리가 어긋난다 (실측 2026-09-12, 06 에서 같은 병)
var prev = 0;
requestAnimationFrame(function tick(ms) {
if (root.classList.contains('is-demo')) {
var v = parseFloat(getComputedStyle(sk).getPropertyValue('--p'));
if (v === v) {
t = v * DUR;
sk.setAttribute('aria-valuenow', Math.floor(t));
sk.setAttribute('aria-valuetext', fmt(t) + ' / 3:00');
now.textContent = fmt(t);
clock.textContent = fmt(t);
tt.textContent = fmt(t);
}
} else if (ms - prev > 500) {
prev = ms; t = (t + .5) % DUR; paint();
}
requestAnimationFrame(tick);
});
function pos(e) {
var r = sk.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
}
sk.addEventListener('mousemove', function (e) {
root.classList.remove('is-demo');
var p = pos(e);
tt.textContent = fmt(p * DUR);
sk.style.setProperty('--h', p.toFixed(4));
});
sk.addEventListener('mouseleave', function () { sk.style.setProperty('--h', ''); });
function seek(e) { root.classList.remove('is-demo'); t = pos(e) * DUR; paint(); }
sk.addEventListener('pointerdown', function (e) { sk.setPointerCapture(e.pointerId); seek(e); });
sk.addEventListener('pointermove', function (e) { if (e.buttons) seek(e); });
sk.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { t = Math.min(DUR, t + 5); paint(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { t = Math.max(0, t - 5); paint(); e.preventDefault(); }
else if (e.key === 'Home') { t = 0; paint(); e.preventDefault(); }
else if (e.key === 'End') { t = DUR - 1; paint(); e.preventDefault(); }
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SeekBarTooltip.module.scss";
export type SeekBarTooltipProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SeekVars = CSSProperties & { "--p"?: string; "--h"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type Rect = { left: number; width: number };
type MoveEvt = { clientX: number; currentTarget: { getBoundingClientRect: () => Rect } };
type PtrEvt = MoveEvt & {
buttons: number;
pointerId: number;
currentTarget: { getBoundingClientRect: () => Rect; setPointerCapture: (id: number) => void };
};
type KeyEvt = { key: string; preventDefault: () => void };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function pos(e: MoveEvt) {
const r = e.currentTarget.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
}
export default function SeekBarTooltip({
total = 180,
start = 32,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SeekBarTooltipProps) {
const [t, setT] = useState(start);
const [hover, setHover] = useState(-1);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 setInterval(500ms, +0.5초) 을 그대로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.5) % total), 500);
return () => clearInterval(id);
}, [total]);
const p = t / total;
const h = hover >= 0 ? hover : p;
const seekVars: SeekVars = { "--p": p.toFixed(4), "--h": h.toFixed(4) };
function seek(e: MoveEvt) {
setT(pos(e) * total);
}
return (
<figure className={styles.player} 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>
</div>
<div className={styles.row}>
<span className={styles.rowTime}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<div
className={`${styles.seek} ${hover >= 0 ? styles.isHover : ""}`}
style={seekVars}
role="slider"
tabIndex={0}
aria-label="재생 위치"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={Math.floor(t)}
aria-valuetext={`${fmt(t)} / ${fmt(total)}`}
onMouseMove={(e: MoveEvt) => setHover(pos(e))}
onMouseLeave={() => setHover(-1)}
onPointerDown={(e: PtrEvt) => {
e.currentTarget.setPointerCapture(e.pointerId);
seek(e);
}}
onPointerMove={(e: PtrEvt) => {
if (e.buttons) seek(e);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowRight") { setT((v) => Math.min(total, v + 5)); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setT((v) => Math.max(0, v - 5)); e.preventDefault(); }
else if (e.key === "Home") { setT(0); e.preventDefault(); }
else if (e.key === "End") { setT(total - 1); e.preventDefault(); }
}}
>
<span className={styles.track} aria-hidden="true">
<span className={styles.buf} style={{ transform: `scaleX(${Math.min(1, p + 0.18).toFixed(4)})` }} />
<span className={styles.fill} style={{ transform: `scaleX(${p.toFixed(4)})` }} />
<span className={styles.thumbwrap}>
<span className={styles.thumb} />
</span>
</span>
<span className={styles.tip} aria-hidden="true">
<span className={styles.prev}>
<span className={styles.pat} />
</span>
<b className={styles.tt}>{fmt(h * total)}</b>
</span>
</div>
</div>
</figure>
);
}02 · 시간 툴팁 달린 시크 바 — 측정 누적면적 30.392% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
채움·버퍼·손잡이가 --p 숫자 하나에서 나오고 툴팁도 마우스가 올라오기 전에는 같은 값을 읽기 때문에, 이 값을 0.18 에서 0.92 로 밀면 네 가지가 한 몸처럼 따라갑니다.
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.18; }
.sk__buf, .sk__fill {
position: absolute; inset: 0;
border-radius: $r-pill;
transform-origin: left center;
}
.sk__buf { background: rgba(23, 20, 26, .16); transform: scaleX(min(1, calc(var(--p) + .18))); }
.sk__fill { background: $color; transform: scaleX(var(--p)); }
.sk__thumbwrap {
position: absolute; left: 0; right: 0; top: 0; bottom: 0;
pointer-events: none;
transform: translateX(calc((100% - 14px) * var(--p)));
}
@keyframes sk-p {
0% { --p: 0.18; }
78% { --p: 0.92; }
88% { --p: 0.92; }
100% { --p: 0.18; }
}
03볼륨 슬라이더 팝오버
어떤 움직임인가
스피커 버튼을 누르면 슬라이더가 버튼 오른쪽으로 튀어나와 오르내리고, 음량이 0 에 닿으면 아이콘이 뮤트 모양으로 바뀝니다.

<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" id="clock">1:24</span>
<span class="pl__badge">3:00</span>
<div class="vol">
<button class="vol__btn" type="button" id="vbtn" aria-label="음량 조절" aria-expanded="true" aria-controls="pop">
<span class="vol__ic vol__ic--high" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/></g></svg></span>
<span class="vol__ic vol__ic--low" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55ZM208 128a39.93 39.93 0 0 1-10 26.46a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 1 1 12-10.58A40 40 0 0 1 208 128"/></g></svg></span>
<span class="vol__ic vol__ic--mute" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.57V91.94l56-43.58Zm101.66-61.3a8 8 0 0 1-11.32 11.32L216 139.31l-18.34 18.35a8 8 0 0 1-11.32-11.32L204.69 128l-18.35-18.34a8 8 0 0 1 11.32-11.32L216 116.69l18.34-18.35a8 8 0 0 1 11.32 11.32L227.31 128Z"/></g></svg></span>
</button>
<div class="vol__pop" id="pop">
<div class="vol__slider" id="vs" role="slider" tabindex="0" aria-label="음량" aria-valuemin="0" aria-valuemax="100" aria-valuenow="80" aria-valuetext="80%">
<span class="vol__track" aria-hidden="true">
<span class="vol__fill"></span>
<span class="vol__thumbwrap"><span class="vol__thumb"></span></span>
</span>
</div>
<span class="vol__pct" id="pct">80</span>
</div>
</div>
<span class="pl__time"><b id="t-now">1:24</b> / 3:00</span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
@property --v { syntax: "<number>"; inherits: true; initial-value: 0.8; }
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.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;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute; inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .13) 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: 20%; top: 14%;
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: 82%; 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); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
.pl__time {
position: absolute; right: $sp-2; bottom: $sp-2;
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);
}
.vol {
position: absolute; left: $sp-2; bottom: $sp-2;
display: flex; align-items: flex-end; gap: $sp-1;
}
.vol__pop {
// 팝오버는 버튼 옆으로 절대 배치한다 — 흐름에 두면 닫혀 있을 때도 152px 자리를 잡아 버튼이 화면 한가운데로 밀린다
position: absolute; left: calc(100% + #{$sp-1}); bottom: 0;
display: flex; align-items: center; gap: $sp-2;
box-sizing: border-box;
width: 152px; height: 36px;
padding: 0 $sp-2;
border-radius: $r-control;
background: rgba(23, 20, 26, .88);
transform: translateX(-6px) scale(.6);
transform-origin: left bottom; // 규칙8: 버튼 옆 변서 펼침
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.pl.is-open .vol__pop { transform: none; opacity: 1; pointer-events: auto; }
.vol__slider {
--v: 0.8;
position: relative;
flex: 1 1 auto;
height: 32px;
cursor: pointer;
touch-action: none;
}
.vol__slider:focus-visible { outline: 2px solid $stage-yellow; outline-offset: -2px; border-radius: $r-sm; }
.vol__track {
position: absolute; left: 0; right: 0; top: 50%;
height: 6px;
margin-top: -3px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .22);
}
.vol__fill {
position: absolute; inset: 0;
border-radius: $r-pill;
background: rgba(255, 210, 63, .95);
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(var(--v));
}
.vol__thumbwrap {
position: absolute; inset: 0;
pointer-events: none;
transform: translateX(calc((100% - 12px) * var(--v)));
}
.vol__thumb {
position: absolute; left: 0; top: 50%;
width: 12px; height: 12px;
margin-top: -6px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(23, 20, 26, .5);
}
.vol__pct {
flex: 0 0 auto;
min-width: 20px;
color: rgba(255, 255, 255, .9);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 12px; font-weight: 700;
text-align: right;
}
.vol__btn {
position: relative;
box-sizing: border-box;
width: 36px; height: 36px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: $r-control;
background: rgba(23, 20, 26, .72);
color: #fff;
cursor: pointer;
transition: background $duration $easing;
}
.vol__btn:hover { background: rgba(23, 20, 26, .9); }
.vol__btn:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.vol__ic { position: absolute; display: inline-flex; }
.vol__ic--low, .vol__ic--mute { display: none; }
.pl.is-low .vol__ic--high { display: none; }
.pl.is-low .vol__ic--low { display: inline-flex; }
.pl.is-mute .vol__ic--high, .pl.is-low .vol__ic--low { display: none; }
.pl.is-mute .vol__ic--mute { display: inline-flex; }
// ── 자동 루프: 팝오버가 열리고 음량이 내려가 0(뮤트)을 지나 다시 오른다. 아이콘 교체는 한 프레임 컷 ──
.pl.is-demo .vol__pop {
animation-name: vol-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pl.is-demo .vol__slider {
animation-name: vol-v;
animation-duration: $dur-loop;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--high {
animation-name: vol-i-high;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--low {
display: inline-flex;
animation-name: vol-i-low;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--mute {
display: inline-flex;
animation-name: vol-i-mute;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes vol-pop {
0%, 6% { opacity: 0; transform: translateX(-6px) scale(.6); }
16%, 80% { opacity: 1; transform: none; }
88%, 100% { opacity: 0; transform: translateX(-6px) scale(.6); }
}
@keyframes vol-v {
0%, 8% { --v: 0.8; }
40% { --v: 0.22; }
50%, 58% { --v: 0; }
68% { --v: 0.3; }
80%, 100% { --v: 0.8; }
}
@keyframes vol-i-high {
0%, 36% { opacity: 1; }
37%, 63% { opacity: 0; }
64%, 100% { opacity: 1; }
}
@keyframes vol-i-low {
0%, 36% { opacity: 0; }
37%, 44% { opacity: 1; }
45%, 66% { opacity: 0; }
67%, 74% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes vol-i-mute {
0%, 44% { opacity: 0; }
45%, 58% { opacity: 1; }
59%, 100% { opacity: 0; }
}
.pl.is-demo .vol__pct {
animation-name: vol-pct;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes vol-pct {
0%, 8% { opacity: 1; }
40% { opacity: 1; }
50%, 58% { opacity: 1; }
68% { opacity: 1; }
80%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .vol__pop, .pl.is-demo .vol__slider, .pl.is-demo .vol__ic--high,
.pl.is-demo .vol__ic--low, .pl.is-demo .vol__ic--mute, .pl.is-demo .vol__pct,
.pl__stripes { animation: none !important; }
.vol__pop { transition: none; }
}@property --v {
syntax: "<number>";
inherits: true;
initial-value: 0.8;
}
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);
}
.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;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.13) 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: 20%;
top: 14%;
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: 82%;
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, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.pl__time {
position: absolute;
right: 8px;
bottom: 8px;
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);
}
.vol {
position: absolute;
left: 8px;
bottom: 8px;
display: flex;
align-items: flex-end;
gap: 4px;
}
.vol__pop {
position: absolute;
left: calc(100% + 4px);
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 152px;
height: 36px;
padding: 0 8px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.88);
transform: translateX(-6px) scale(0.6);
transform-origin: left bottom;
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pl.is-open .vol__pop {
transform: none;
opacity: 1;
pointer-events: auto;
}
.vol__slider {
--v: 0.8;
position: relative;
flex: 1 1 auto;
height: 32px;
cursor: pointer;
touch-action: none;
}
.vol__slider:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: -2px;
border-radius: 8px;
}
.vol__track {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 6px;
margin-top: -3px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
}
.vol__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(255, 210, 63, 0.95);
transform-origin: left center;
transform: scaleX(var(--v));
}
.vol__thumbwrap {
position: absolute;
inset: 0;
pointer-events: none;
transform: translateX(calc((100% - 12px) * var(--v)));
}
.vol__thumb {
position: absolute;
left: 0;
top: 50%;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.5);
}
.vol__pct {
flex: 0 0 auto;
min-width: 20px;
color: rgba(255, 255, 255, 0.9);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 12px;
font-weight: 700;
text-align: right;
}
.vol__btn {
position: relative;
box-sizing: border-box;
width: 36px;
height: 36px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.72);
color: #fff;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.vol__btn:hover {
background: rgba(23, 20, 26, 0.9);
}
.vol__btn:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.vol__ic {
position: absolute;
display: inline-flex;
}
.vol__ic--low, .vol__ic--mute {
display: none;
}
.pl.is-low .vol__ic--high {
display: none;
}
.pl.is-low .vol__ic--low {
display: inline-flex;
}
.pl.is-mute .vol__ic--high, .pl.is-low .vol__ic--low {
display: none;
}
.pl.is-mute .vol__ic--mute {
display: inline-flex;
}
.pl.is-demo .vol__pop {
animation-name: vol-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__slider {
animation-name: vol-v;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--high {
animation-name: vol-i-high;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--low {
display: inline-flex;
animation-name: vol-i-low;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--mute {
display: inline-flex;
animation-name: vol-i-mute;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes vol-pop {
0%, 6% {
opacity: 0;
transform: translateX(-6px) scale(0.6);
}
16%, 80% {
opacity: 1;
transform: none;
}
88%, 100% {
opacity: 0;
transform: translateX(-6px) scale(0.6);
}
}
@keyframes vol-v {
0%, 8% {
--v: 0.8;
}
40% {
--v: 0.22;
}
50%, 58% {
--v: 0;
}
68% {
--v: 0.3;
}
80%, 100% {
--v: 0.8;
}
}
@keyframes vol-i-high {
0%, 36% {
opacity: 1;
}
37%, 63% {
opacity: 0;
}
64%, 100% {
opacity: 1;
}
}
@keyframes vol-i-low {
0%, 36% {
opacity: 0;
}
37%, 44% {
opacity: 1;
}
45%, 66% {
opacity: 0;
}
67%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
@keyframes vol-i-mute {
0%, 44% {
opacity: 0;
}
45%, 58% {
opacity: 1;
}
59%, 100% {
opacity: 0;
}
}
.pl.is-demo .vol__pct {
animation-name: vol-pct;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes vol-pct {
0%, 8% {
opacity: 1;
}
40% {
opacity: 1;
}
50%, 58% {
opacity: 1;
}
68% {
opacity: 1;
}
80%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .vol__pop, .pl.is-demo .vol__slider, .pl.is-demo .vol__ic--high,
.pl.is-demo .vol__ic--low, .pl.is-demo .vol__ic--mute, .pl.is-demo .vol__pct,
.pl__stripes {
animation: none !important;
}
.vol__pop {
transition: none;
}
}// 실제 동작: 버튼으로 슬라이더를 여닫고, 끌거나 화살표로 음량을 바꾸면 아이콘이 바뀐다.
var root = document.getElementById('pl'),
btn = document.getElementById('vbtn'),
pop = document.getElementById('pop'),
vs = document.getElementById('vs'),
pct = document.getElementById('pct'),
clock = document.getElementById('clock'),
now = document.getElementById('t-now');
var DUR = 180, t = 84, v = 0.8;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
setInterval(function () {
t = (t + .5) % DUR;
clock.textContent = fmt(t);
now.textContent = fmt(t);
}, 500);
function paint() {
vs.style.setProperty('--v', v.toFixed(3));
vs.setAttribute('aria-valuenow', Math.round(v * 100));
vs.setAttribute('aria-valuetext', Math.round(v * 100) + '%');
pct.textContent = Math.round(v * 100);
root.classList.toggle('is-mute', v === 0);
root.classList.toggle('is-low', v > 0 && v < 0.5);
}
function open(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
if (on) vs.focus({ preventScroll: true });
}
btn.addEventListener('click', function () { open(!root.classList.contains('is-open')); });
function pos(e) {
var r = vs.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
}
function set(p) { v = Math.round(p * 20) / 20; paint(); }
vs.addEventListener('pointerdown', function (e) { vs.setPointerCapture(e.pointerId); root.classList.remove('is-demo'); set(pos(e)); });
vs.addEventListener('pointermove', function (e) { if (e.buttons) set(pos(e)); });
vs.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { v = Math.min(1, v + .1); paint(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { v = Math.max(0, v - .1); paint(); e.preventDefault(); }
else if (e.key === 'Home') { v = 0; paint(); e.preventDefault(); }
else if (e.key === 'End') { v = 1; paint(); e.preventDefault(); }
});
document.addEventListener('click', function (e) { if (!e.target.closest('.vol')) open(false); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./VolumePopover.module.scss";
export type VolumePopoverProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
/** 시작 음량 0~1 */
initialVolume?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SliderVars = CSSProperties & { "--v"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type Rect = { left: number; width: number };
type PtrEvt = {
clientX: number;
buttons: number;
pointerId: number;
currentTarget: { getBoundingClientRect: () => Rect; setPointerCapture: (id: number) => void };
};
type KeyEvt = { key: string; preventDefault: () => void };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function HighIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2" />
<path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128" />
</g>
</svg>
);
}
function LowIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2" />
<path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55ZM208 128a39.93 39.93 0 0 1-10 26.46a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 1 1 12-10.58A40 40 0 0 1 208 128" />
</g>
</svg>
);
}
function MuteIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2" />
<path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.57V91.94l56-43.58Zm101.66-61.3a8 8 0 0 1-11.32 11.32L216 139.31l-18.34 18.35a8 8 0 0 1-11.32-11.32L204.69 128l-18.35-18.34a8 8 0 0 1 11.32-11.32L216 116.69l18.34-18.35a8 8 0 0 1 11.32 11.32L227.31 128Z" />
</g>
</svg>
);
}
export default function VolumePopover({
total = 180,
start = 84,
initialVolume = 0.8,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffd23f",
}: VolumePopoverProps) {
const [open, setOpen] = useState(false);
const [v, setV] = useState(initialVolume);
const [t, setT] = useState(start);
const volRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const sliderVars: SliderVars = { "--v": v.toFixed(3) };
// 더미 재생 시계 — 바닐라의 setInterval(500ms, +0.5초) 을 그대로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.5) % total), 500);
return () => clearInterval(id);
}, [total]);
// 바깥을 누르면 닫는다
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (volRef.current && !volRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
useEffect(() => {
if (open) sliderRef.current?.focus({ preventScroll: true });
}, [open]);
function set(e: PtrEvt) {
const r = e.currentTarget.getBoundingClientRect();
const p = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
setV(Math.round(p * 20) / 20);
}
const pct = Math.round(v * 100);
const state = v === 0 ? styles.isMute : v < 0.5 ? styles.isLow : "";
return (
<figure className={`${styles.player} ${open ? styles.isOpen : ""} ${state}`} 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>
<div className={styles.vol} ref={volRef}>
<button
className={styles.btn}
type="button"
aria-label="음량 조절"
aria-expanded={open}
aria-controls="vol-pop"
onClick={() => setOpen((o) => !o)}
>
<span className={`${styles.ic} ${styles.icHigh}`}>
<HighIcon />
</span>
<span className={`${styles.ic} ${styles.icLow}`}>
<LowIcon />
</span>
<span className={`${styles.ic} ${styles.icMute}`}>
<MuteIcon />
</span>
</button>
<div className={styles.pop} id="vol-pop">
<div
ref={sliderRef}
className={styles.slider}
style={sliderVars}
role="slider"
tabIndex={0}
aria-label="음량"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-valuetext={`${pct}%`}
onPointerDown={(e: PtrEvt) => {
e.currentTarget.setPointerCapture(e.pointerId);
set(e);
}}
onPointerMove={(e: PtrEvt) => {
if (e.buttons) set(e);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowRight") { setV((p) => Math.min(1, p + 0.1)); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setV((p) => Math.max(0, p - 0.1)); e.preventDefault(); }
else if (e.key === "Home") { setV(0); e.preventDefault(); }
else if (e.key === "End") { setV(1); e.preventDefault(); }
}}
>
<span className={styles.track} aria-hidden="true">
<span className={styles.fill} />
<span className={styles.thumbwrap}>
<span className={styles.thumb} />
</span>
</span>
</div>
<span className={styles.pct}>{pct}</span>
</div>
</div>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
</div>
</figure>
);
}03 · 볼륨 슬라이더 팝오버 — 측정 누적면적 31.274% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
아이콘 교체에는 steps(1, end) 를 걸어 두 모양이 반투명하게 겹치는 프레임을 없앴습니다.
.vol {
position: absolute; left: $sp-2; bottom: $sp-2;
display: flex; align-items: flex-end; gap: $sp-1;
}
.vol__pop {
position: absolute; left: calc(100% + #{$sp-1}); bottom: 0;
display: flex; align-items: center; gap: $sp-2;
box-sizing: border-box;
width: 152px; height: 36px;
padding: 0 $sp-2;
border-radius: $r-control;
background: rgba(23, 20, 26, .88);
transform: translateX(-6px) scale(.6);
transform-origin: left bottom;
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.pl.is-open .vol__pop { transform: none; opacity: 1; pointer-events: auto; }
04재생 속도 메뉴
영상 플레이어 ui 의 배속 메뉴에서 무엇이 보이나
배속 버튼을 누르면 0.5배부터 2배까지 여섯 줄이 위로 펼쳐지고, 고른 배속대로 아래 진행 바의 기울기가 실제로 달라집니다.

<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>
<div class="pl__top">
<span class="pl__clock" id="clock">0:18</span>
<span class="pl__badge">3:00</span>
</div>
<div class="spd">
<div class="spd__menu" id="menu" role="menu" aria-label="재생 속도">
<span class="spd__mark" aria-hidden="true"></span>
<button class="spd__item" type="button" role="menuitemradio" aria-checked="false" data-r="0.5"><span class="spd__lab">0.5배</span><span class="spd__ck" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></button>
<button class="spd__item" type="button" role="menuitemradio" aria-checked="false" data-r="0.75"><span class="spd__lab">0.75배</span><span class="spd__ck" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></button>
<button class="spd__item" type="button" role="menuitemradio" aria-checked="true" data-r="1"><span class="spd__lab">1배</span><span class="spd__ck" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></button>
<button class="spd__item" type="button" role="menuitemradio" aria-checked="false" data-r="1.25"><span class="spd__lab">1.25배</span><span class="spd__ck" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></button>
<button class="spd__item" type="button" role="menuitemradio" aria-checked="false" data-r="1.5"><span class="spd__lab">1.5배</span><span class="spd__ck" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></button>
<button class="spd__item" type="button" role="menuitemradio" aria-checked="false" data-r="2"><span class="spd__lab">2배</span><span class="spd__ck" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></button>
</div>
<button class="spd__btn" type="button" id="sbtn" aria-haspopup="menu" aria-expanded="false" aria-controls="menu" aria-label="재생 속도">
<span class="spd__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="16" height="16"><g fill="currentColor"><path d="M232 152v24a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8v-22.87C24 95.65 70.15 48.2 127.63 48A104 104 0 0 1 232 152" opacity=".2"/><path d="M207.06 72.67A111.24 111.24 0 0 0 128 40h-.4C66.07 40.21 16 91 16 153.13V176a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16v-24a111.25 111.25 0 0 0-32.94-79.33M224 176H119.71l54.76-75.3a8 8 0 0 0-12.94-9.42L99.92 176H32v-22.87c0-3.08.15-6.12.43-9.13H56a8 8 0 0 0 0-16H35.27c10.32-38.86 44-68.24 84.73-71.66V80a8 8 0 0 0 16 0V56.33A96.14 96.14 0 0 1 221 128h-21a8 8 0 0 0 0 16h23.67c.21 2.65.33 5.31.33 8Z"/></g></svg></span>
<span class="spd__valwrap">
<span class="spd__val" id="val">1배</span>
<span class="spd__alt spd__alt--fast" aria-hidden="true">1.5배</span>
<span class="spd__alt spd__alt--slow" aria-hidden="true">0.5배</span>
</span>
</button>
</div>
<span class="pl__time"><b id="t-now">0:18</b> / 3:00</span>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
// 메뉴 한 줄의 높이 — 마커 이동량(=줄 높이 × 번호)과 메뉴 전체 높이가 이 값에서 나온다
$row: 17px;
// ── 플레이어 껍데기 (01 과 같은 뼈대. 흰 무대라 본체는 먹색) ──
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $subject-ink;
box-shadow: $shadow-raised;
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(148px, 34vw, 164px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .58), rgba(23, 20, 26, .94));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(255, 247, 230, .16) 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: 22%; top: 16%;
width: 28px; height: 28px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .22);
}
.pl__hill { position: absolute; bottom: -46%; height: 82%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(255, 247, 230, .14); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(255, 247, 230, .08); }
.pl__top {
position: absolute; left: $sp-2; top: $sp-2;
display: flex; align-items: center; gap: $sp-1;
}
.pl__clock, .pl__badge {
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 700;
}
.pl__clock { color: #fff; }
.pl__badge { color: rgba(255, 255, 255, .82); font-weight: 600; }
.pl__time {
position: absolute; left: $sp-2; bottom: $sp-2;
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba(23, 20, 26, .7);
}
.pl__time b { font-weight: 700; }
.pl__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: rgba(255, 255, 255, .24); }
.pl__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.14);
}
// ── 배속 버튼 + 위로 펴지는 메뉴 ──
// 메뉴는 position:absolute 라 부모 높이에 안 잡히지만 scrollHeight 에는 잡힌다(BD9).
// 그래서 바 위로 펴고, 화면 높이(148px)를 메뉴(102px) + 버튼(26px) + 여백이 들어가게 잡아 둔다.
.spd { position: absolute; right: $sp-2; bottom: $sp-2; }
.spd__btn {
box-sizing: border-box;
height: 26px;
display: inline-flex; align-items: center; gap: $sp-1;
padding: 0 $sp-2;
border: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .74);
color: #fff;
cursor: pointer;
transition: background $duration $easing, transform $duration $easing;
}
.spd__btn:hover { background: rgba(23, 20, 26, .92); }
.spd__btn:active { transform: scale(.96); }
.spd__btn:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.pl.is-open .spd__btn { background: rgba(47, 109, 246, .92); }
.spd__ic { display: inline-flex; }
.spd__valwrap { position: relative; display: block; min-width: 34px; height: 14px; }
.spd__val, .spd__alt {
position: absolute; left: 0; top: 0;
white-space: nowrap;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 700;
}
.spd__alt { opacity: 0; }
.spd__menu {
--i: 2;
position: absolute; right: 0; bottom: calc(100% + 4px);
box-sizing: border-box;
width: 92px;
overflow: hidden;
border-radius: $r-control;
background: rgba(23, 20, 26, .95);
box-shadow: $shadow-press;
opacity: 0;
pointer-events: none;
transform: translateY(6px) scale(.72);
transform-origin: right bottom; // 규칙8: 버튼 모서리서 펼침
transition: opacity $duration $easing, transform $duration $easing;
}
.pl.is-open .spd__menu { opacity: 1; pointer-events: auto; transform: none; }
.spd__mark {
position: absolute; left: 0; right: 0; top: 0;
height: $row;
background: rgba(47, 109, 246, .85);
transform: translateY(calc(#{$row} * var(--i)));
transition: transform $duration $easing;
}
.spd__item {
position: relative;
box-sizing: border-box;
width: 100%; height: $row;
display: flex; align-items: center; justify-content: space-between;
padding: 0 $sp-2;
border: 0;
border-radius: 0;
background: transparent;
color: rgba(255, 255, 255, .82);
cursor: pointer;
text-align: left;
}
.spd__item:hover { color: #fff; }
.spd__item:focus-visible { outline: 2px solid $stage-yellow; outline-offset: -2px; }
.spd__item[aria-checked="true"] { color: #fff; }
.spd__lab {
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: $row; font-weight: 700;
}
.spd__ck { display: inline-flex; color: $stage-yellow; opacity: 0; }
.spd__item[aria-checked="true"] .spd__ck { opacity: 1; }
// ── 자동 루프: 메뉴가 열리고 1배 → 1.5배 → 0.5배 → 1배 로 골라진다.
// 체크·라벨 교체는 steps(1, end) 한 프레임 컷, 진행 바 기울기가 배속대로 달라진다.
// 복귀는 90% 안에 (24프레임의 마지막은 95.8% 지점) ──
.pl.is-demo .spd__menu {
animation-name: spd-open;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes spd-open {
// 여닫는 구간을 한 프레임(4.17%) 안으로 좁힌다 — 페이드 중간 프레임이 poster 로 뽑혀 메뉴가 반투명하게 나왔다 (실측 2026-09-12)
0%, 9% { opacity: 0; transform: translateY(6px) scale(.72); }
10%, 80% { opacity: 1; transform: none; }
81%, 100% { opacity: 0; transform: translateY(6px) scale(.72); }
}
.pl.is-demo .spd__mark {
animation-name: spd-mark;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes spd-mark {
0%, 29% { transform: translateY($row * 2); }
30%, 54% { transform: translateY($row * 4); }
55%, 77% { transform: translateY(0); }
78%, 100% { transform: translateY($row * 2); }
}
.pl.is-demo .spd__item:nth-of-type(1) .spd__ck,
.pl.is-demo .spd__alt--slow {
animation-name: spd-slow;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .spd__item:nth-of-type(3) .spd__ck,
.pl.is-demo .spd__val {
animation-name: spd-one;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .spd__item:nth-of-type(5) .spd__ck,
.pl.is-demo .spd__alt--fast {
animation-name: spd-fast;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes spd-one { 0%, 29% { opacity: 1; } 30%, 77% { opacity: 0; } 78%, 100% { opacity: 1; } }
@keyframes spd-fast { 0%, 29% { opacity: 0; } 30%, 54% { opacity: 1; } 55%, 100% { opacity: 0; } }
@keyframes spd-slow { 0%, 54% { opacity: 0; } 55%, 77% { opacity: 1; } 78%, 100% { opacity: 0; } }
.pl.is-demo .pl__fill {
animation-name: spd-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes spd-fill {
0% { transform: scaleX(.14); } // 1배
30% { transform: scaleX(.26); } // 여기서 1.5배 — 기울기가 가팔라진다
55% { transform: scaleX(.41); } // 여기서 0.5배 — 기울기가 완만해진다
78% { transform: scaleX(.456); } // 다시 1배
86% { transform: scaleX(.488); }
90%, 100% { transform: scaleX(.14); }
}
@media (prefers-reduced-motion: reduce) {
.pl__stripes,
.pl.is-demo .spd__menu, .pl.is-demo .spd__mark, .pl.is-demo .pl__fill,
.pl.is-demo .spd__item:nth-of-type(1) .spd__ck,
.pl.is-demo .spd__item:nth-of-type(3) .spd__ck,
.pl.is-demo .spd__item:nth-of-type(5) .spd__ck,
.pl.is-demo .spd__val, .pl.is-demo .spd__alt--fast, .pl.is-demo .spd__alt--slow {
animation: none !important;
}
.spd__menu, .spd__mark, .spd__btn { transition: none; }
}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);
}
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(148px, 34vw, 164px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.58), rgba(23, 20, 26, 0.94));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(255, 247, 230, 0.16) 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: 22%;
top: 16%;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.22);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 82%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(255, 247, 230, 0.14);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(255, 247, 230, 0.08);
}
.pl__top {
position: absolute;
left: 8px;
top: 8px;
display: flex;
align-items: center;
gap: 4px;
}
.pl__clock, .pl__badge {
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
color: #fff;
}
.pl__badge {
color: rgba(255, 255, 255, 0.82);
font-weight: 600;
}
.pl__time {
position: absolute;
left: 8px;
bottom: 8px;
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(23, 20, 26, 0.7);
}
.pl__time b {
font-weight: 700;
}
.pl__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: rgba(255, 255, 255, 0.24);
}
.pl__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.14);
}
.spd {
position: absolute;
right: 8px;
bottom: 8px;
}
.spd__btn {
box-sizing: border-box;
height: 26px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.74);
color: #fff;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.spd__btn:hover {
background: rgba(23, 20, 26, 0.92);
}
.spd__btn:active {
transform: scale(0.96);
}
.spd__btn:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pl.is-open .spd__btn {
background: rgba(47, 109, 246, 0.92);
}
.spd__ic {
display: inline-flex;
}
.spd__valwrap {
position: relative;
display: block;
min-width: 34px;
height: 14px;
}
.spd__val, .spd__alt {
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.spd__alt {
opacity: 0;
}
.spd__menu {
--i: 2;
position: absolute;
right: 0;
bottom: calc(100% + 4px);
box-sizing: border-box;
width: 92px;
overflow: hidden;
border-radius: 12px;
background: rgba(23, 20, 26, 0.95);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
transform: translateY(6px) scale(0.72);
transform-origin: right bottom;
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-open .spd__menu {
opacity: 1;
pointer-events: auto;
transform: none;
}
.spd__mark {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 17px;
background: rgba(47, 109, 246, 0.85);
transform: translateY(calc(17px * var(--i)));
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.spd__item {
position: relative;
box-sizing: border-box;
width: 100%;
height: 17px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
border: 0;
border-radius: 0;
background: transparent;
color: rgba(255, 255, 255, 0.82);
cursor: pointer;
text-align: left;
}
.spd__item:hover {
color: #fff;
}
.spd__item:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: -2px;
}
.spd__item[aria-checked=true] {
color: #fff;
}
.spd__lab {
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 17px;
font-weight: 700;
}
.spd__ck {
display: inline-flex;
color: #ffd23f;
opacity: 0;
}
.spd__item[aria-checked=true] .spd__ck {
opacity: 1;
}
.pl.is-demo .spd__menu {
animation-name: spd-open;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes spd-open {
0%, 9% {
opacity: 0;
transform: translateY(6px) scale(0.72);
}
10%, 80% {
opacity: 1;
transform: none;
}
81%, 100% {
opacity: 0;
transform: translateY(6px) scale(0.72);
}
}
.pl.is-demo .spd__mark {
animation-name: spd-mark;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes spd-mark {
0%, 29% {
transform: translateY(34px);
}
30%, 54% {
transform: translateY(68px);
}
55%, 77% {
transform: translateY(0);
}
78%, 100% {
transform: translateY(34px);
}
}
.pl.is-demo .spd__item:nth-of-type(1) .spd__ck,
.pl.is-demo .spd__alt--slow {
animation-name: spd-slow;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .spd__item:nth-of-type(3) .spd__ck,
.pl.is-demo .spd__val {
animation-name: spd-one;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .spd__item:nth-of-type(5) .spd__ck,
.pl.is-demo .spd__alt--fast {
animation-name: spd-fast;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes spd-one {
0%, 29% {
opacity: 1;
}
30%, 77% {
opacity: 0;
}
78%, 100% {
opacity: 1;
}
}
@keyframes spd-fast {
0%, 29% {
opacity: 0;
}
30%, 54% {
opacity: 1;
}
55%, 100% {
opacity: 0;
}
}
@keyframes spd-slow {
0%, 54% {
opacity: 0;
}
55%, 77% {
opacity: 1;
}
78%, 100% {
opacity: 0;
}
}
.pl.is-demo .pl__fill {
animation-name: spd-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes spd-fill {
0% {
transform: scaleX(0.14);
}
30% {
transform: scaleX(0.26);
}
55% {
transform: scaleX(0.41);
}
78% {
transform: scaleX(0.456);
}
86% {
transform: scaleX(0.488);
}
90%, 100% {
transform: scaleX(0.14);
}
}
@media (prefers-reduced-motion: reduce) {
.pl__stripes,
.pl.is-demo .spd__menu, .pl.is-demo .spd__mark, .pl.is-demo .pl__fill,
.pl.is-demo .spd__item:nth-of-type(1) .spd__ck,
.pl.is-demo .spd__item:nth-of-type(3) .spd__ck,
.pl.is-demo .spd__item:nth-of-type(5) .spd__ck,
.pl.is-demo .spd__val, .pl.is-demo .spd__alt--fast, .pl.is-demo .spd__alt--slow {
animation: none !important;
}
.spd__menu, .spd__mark, .spd__btn {
transition: none;
}
}// 실제 동작: 배속 메뉴를 여닫고, 고른 배속만큼 더미 재생 시계와 진행 바가 빨라지거나 느려진다.
var root = document.getElementById('pl'), btn = document.getElementById('sbtn'),
menu = document.getElementById('menu'), clock = document.getElementById('clock'),
now = document.getElementById('t-now'), fill = document.getElementById('fill'),
val = document.getElementById('val'),
items = [].slice.call(menu.querySelectorAll('[role="menuitemradio"]'));
var DUR = 180, t = 18, rate = 1, i = 2, open = false;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
setInterval(function () {
t = (t + 0.25 * rate) % DUR;
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}, 250);
function pick(n) {
i = n;
rate = parseFloat(items[n].getAttribute('data-r'));
items.forEach(function (b, k) { b.setAttribute('aria-checked', String(k === n)); });
menu.style.setProperty('--i', String(n));
val.textContent = items[n].querySelector('.spd__lab').textContent;
}
function toggle(on) {
root.classList.remove('is-demo');
open = on;
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
if (on) items[i].focus({ preventScroll: true }); else btn.focus({ preventScroll: true });
}
btn.addEventListener('click', function () { toggle(!open); });
items.forEach(function (b, k) { b.addEventListener('click', function () { pick(k); toggle(false); }); });
menu.addEventListener('keydown', function (e) {
var n = i;
if (e.key === 'Escape') { toggle(false); e.preventDefault(); return; }
if (e.key === 'Enter' || e.key === ' ') { pick(i); toggle(false); e.preventDefault(); return; }
if (e.key === 'ArrowDown') n = (i + 1) % items.length;
else if (e.key === 'ArrowUp') n = (i + items.length - 1) % items.length;
else return;
i = n;
items[i].focus({ preventScroll: true });
e.preventDefault();
});
document.addEventListener('click', function (e) { if (open && !e.target.closest('.spd')) toggle(false); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpeedMenu.module.scss";
export type SpeedMenuProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
/** 고를 수 있는 배속 */
rates?: number[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type MenuVars = CSSProperties & { "--i"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault: () => void };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function SpeedIcon() {
return (
<svg viewBox="0 0 256 256" width="16" height="16" aria-hidden="true">
<g fill="currentColor">
<path d="M232 152v24a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8v-22.87C24 95.65 70.15 48.2 127.63 48A104 104 0 0 1 232 152" opacity=".2" />
<path d="M207.06 72.67A111.24 111.24 0 0 0 128 40h-.4C66.07 40.21 16 91 16 153.13V176a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16v-24a111.25 111.25 0 0 0-32.94-79.33M224 176H119.71l54.76-75.3a8 8 0 0 0-12.94-9.42L99.92 176H32v-22.87c0-3.08.15-6.12.43-9.13H56a8 8 0 0 0 0-16H35.27c10.32-38.86 44-68.24 84.73-71.66V80a8 8 0 0 0 16 0V56.33A96.14 96.14 0 0 1 221 128h-21a8 8 0 0 0 0 16h23.67c.21 2.65.33 5.31.33 8Z" />
</g>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
}
export default function SpeedMenu({
total = 180,
start = 18,
rates = [0.5, 0.75, 1, 1.25, 1.5, 2],
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: SpeedMenuProps) {
const [open, setOpen] = useState(false);
const [i, setI] = useState(2);
const [t, setT] = useState(start);
const rootRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const rate = rates[i];
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const menuVars: MenuVars = { "--i": String(i) };
// 더미 재생 시계 — 고른 배속만큼 빨라지거나 느려진다 (바닐라의 setInterval(250ms, +0.25×rate))
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.25 * rate) % total), 250);
return () => clearInterval(id);
}, [rate, total]);
// 바깥을 누르면 닫는다
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
function focusItem(n: number) {
menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitemradio"]')[n]?.focus({ preventScroll: true });
}
useEffect(() => {
if (open) focusItem(i);
// 닫힐 때 버튼으로 돌려보내는 건 toggle 에서 직접 한다
}, [open]);
function close() {
setOpen(false);
btnRef.current?.focus({ preventScroll: true });
}
return (
<figure className={`${styles.player} ${open ? styles.isOpen : ""}`} 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>
<div className={styles.top}>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
</div>
<div className={styles.spd} ref={rootRef}>
<div
ref={menuRef}
className={styles.menu}
style={menuVars}
id="spd-menu"
role="menu"
aria-label="재생 속도"
onKeyDown={(e: KeyEvt) => {
if (e.key === "Escape") { close(); e.preventDefault(); return; }
if (e.key === "Enter" || e.key === " ") { close(); e.preventDefault(); return; }
let n = i;
if (e.key === "ArrowDown") n = (i + 1) % rates.length;
else if (e.key === "ArrowUp") n = (i + rates.length - 1) % rates.length;
else return;
setI(n);
focusItem(n);
e.preventDefault();
}}
>
<span className={styles.mark} aria-hidden="true" />
{rates.map((r, k) => (
<button
key={r}
className={styles.item}
type="button"
role="menuitemradio"
aria-checked={k === i}
onClick={() => { setI(k); close(); }}
>
<span className={styles.lab}>{r}배</span>
<span className={styles.ck}>
<CheckIcon />
</span>
</button>
))}
</div>
<button
ref={btnRef}
className={styles.btn}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls="spd-menu"
aria-label="재생 속도"
onClick={() => setOpen((o) => !o)}
>
<span className={styles.ic} aria-hidden="true">
<SpeedIcon />
</span>
<span className={styles.valwrap}>
<span className={styles.val}>{rate}배</span>
</span>
</button>
</div>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
</figure>
);
}04 · 재생 속도 메뉴 — 측정 누적면적 42.843% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
고른 줄을 표시하는 파란 막대는 줄마다 배경을 켜고 끄는 대신 --i 값만 바꿔 17px 단위로 미끄러집니다.
.spd__mark {
position: absolute; left: 0; right: 0; top: 0;
height: $row;
background: rgba(47, 109, 246, .85);
transform: translateY(calc(#{$row} * var(--i)));
transition: transform $duration $easing;
}
@keyframes spd-mark {
0%, 29% { transform: translateY($row * 2); }
30%, 54% { transform: translateY($row * 4); }
55%, 77% { transform: translateY(0); }
78%, 100% { transform: translateY($row * 2); }
}
@keyframes spd-fill {
0% { transform: scaleX(.14); } // 1배
30% { transform: scaleX(.26); } // 여기서 1.5배 — 기울기가 가팔라진다
55% { transform: scaleX(.41); } // 여기서 0.5배 — 기울기가 완만해진다
78% { transform: scaleX(.456); } // 다시 1배
86% { transform: scaleX(.488); }
90%, 100% { transform: scaleX(.14); }
}
05자막 토글과 자막 스타일
화면에서 일어나는 일
CC 버튼을 누르면 자막이 통째로 꺼지고, 켜져 있는 동안에는 네 문장이 아래에서 떠오르며 차례로 갈아 끼워집니다.

<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" id="clock">0:12</span>
<span class="pl__badge">3:00</span>
<div class="cap" id="cap">
<span class="cap__l cap__l--1 is-on">영상이 시작됩니다</span>
<span class="cap__l cap__l--2">자막을 켜 보세요</span>
<span class="cap__l cap__l--3">스타일을 고를 수 있어요</span>
<span class="cap__l cap__l--4">이제 준비가 끝났어요</span>
</div>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
</div>
<div class="pl__bar">
<button class="cc" type="button" id="cc" aria-pressed="true">
<span class="cc__ic" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 64v128a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h192a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48H32a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m0 144H32V64h192zm-105.07-40.29a8 8 0 0 1-2.93 10.93a40 40 0 1 1 0-69.28a8 8 0 1 1-8 13.85a24 24 0 1 0 0 41.58a8 8 0 0 1 10.93 2.92m80 0a8 8 0 0 1-2.93 10.93a40 40 0 1 1 0-69.28a8 8 0 1 1-8 13.85a24 24 0 1 0 0 41.58a8 8 0 0 1 10.93 2.92"/></g></svg></span>
<span class="cc__t">자막</span>
</button>
<div class="seg" id="seg" role="group" aria-label="자막 스타일">
<button class="seg__b seg__b--chip is-on" type="button" data-s="chip" aria-pressed="true">바탕칩</button>
<button class="seg__b seg__b--line" type="button" data-s="line" aria-pressed="false">외곽선</button>
</div>
<span class="pl__time"><b id="t-now">0:12</b> / 3:00</span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.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;
}
// ── 화면 ──
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
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: 82%; 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); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
.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(.07);
}
// ── 자막 줄 (네 문장이 같은 칸에 겹쳐 쌓인다 — 한 번에 하나만 보인다) ──
.cap {
position: absolute;
left: 0; right: 0; bottom: $sp-4;
display: grid;
place-items: center;
opacity: 1;
transition: opacity $duration $easing;
}
.pl.is-off .cap { opacity: 0; }
.cap__l {
grid-area: 1 / 1;
box-sizing: border-box;
max-width: 92%;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background-color: rgba(23, 20, 26, .72);
color: #fff;
font-size: clamp(10px, 2.9vw, 13px);
line-height: 16px;
font-weight: 700;
white-space: nowrap;
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
opacity: 0;
transform: translateY(10px);
transition: opacity $duration $easing, transform $duration $easing;
}
.cap__l.is-on { opacity: 1; transform: none; }
.pl.is-outline .cap__l {
background-color: rgba(23, 20, 26, 0);
text-shadow: 1px 1px 0 rgba(23, 20, 26, .95), -1px 1px 0 rgba(23, 20, 26, .95), 1px -1px 0 rgba(23, 20, 26, .95), -1px -1px 0 rgba(23, 20, 26, .95);
}
// ── 조작 줄 ──
.pl__bar {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-top: 1px solid rgba(23, 20, 26, .12);
background: $subject-cream;
}
.cc {
box-sizing: border-box;
height: 32px;
flex: 0 0 auto;
display: inline-flex; align-items: center; gap: $sp-1;
padding: 0 $sp-2;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
cursor: pointer;
font-size: 12px; line-height: 16px; font-weight: 700;
transition: background $duration $easing, color $duration $easing;
}
.cc[aria-pressed="false"] { background: rgba(23, 20, 26, .1); color: $subject-ink; }
.cc:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.cc__ic { display: inline-flex; }
.seg { display: flex; align-items: center; gap: $sp-1; }
.seg__b {
box-sizing: border-box;
height: 28px;
flex: 0 0 auto;
display: inline-flex; align-items: center;
padding: 0 $sp-2;
border: 0;
border-radius: $r-pill;
background-color: rgba(23, 20, 26, .1);
color: $subject-ink;
cursor: pointer;
font-size: 11px; line-height: 14px; font-weight: 700;
transition: background-color $duration $easing, color $duration $easing;
}
.seg__b.is-on { background-color: $subject-ink; color: #fff; }
.seg__b:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.pl__time {
margin-left: auto;
color: rgba(23, 20, 26, .66);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 600;
}
.pl__time b { color: $subject-ink; font-weight: 700; }
// ── 자동 루프: 2초 안에 네 문장이 아래에서 올라오며 갈아 끼워지고, 절반 지점에서 스타일이 바뀐다 ──
// 문장 교체는 steps(1, end) 로 한 프레임 컷 — 두 문장이 절대 겹치지 않는다. 부드러움은 등장 rise 로만.
.pl.is-demo .cap__l--1 {
animation-name: cap-1, cap-style;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--2 {
animation-name: cap-2, cap-style;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--3 {
animation-name: cap-3, cap-style;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--4 {
animation-name: cap-4, cap-style;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
// 자막 교체: opacity 는 0.01% 폭으로 **컷**하고(두 문장이 겹치는 프레임도, 아무 문장도 없는 프레임도 없다),
// 부드러움은 translateY 등장 rise 로만 준다. 예전 판은 나가는 문장과 들어오는 문장 사이에 빈 구간이 있어
// 정확히 25% 지점 프레임(24프레임 중 7번째)이 자막 없는 빈 화면이었고 그게 poster 로 뽑혔다 (실측 2026-09-12)
@keyframes cap-1 {
0%, 21.99% { opacity: 1; transform: none; }
22%, 87.99% { opacity: 0; transform: translateY(10px); }
88% { opacity: 1; transform: translateY(10px); }
93%, 100% { opacity: 1; transform: none; }
}
@keyframes cap-2 {
0%, 21.99% { opacity: 0; transform: translateY(10px); }
22% { opacity: 1; transform: translateY(10px); }
27%, 43.99% { opacity: 1; transform: none; }
44%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes cap-3 {
0%, 43.99% { opacity: 0; transform: translateY(10px); }
44% { opacity: 1; transform: translateY(10px); }
49%, 65.99% { opacity: 1; transform: none; }
66%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes cap-4 {
0%, 65.99% { opacity: 0; transform: translateY(10px); }
66% { opacity: 1; transform: translateY(10px); }
71%, 87.99% { opacity: 1; transform: none; }
88%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes cap-style {
0%, 49% {
background-color: rgba(23, 20, 26, .72);
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
}
50%, 87% {
background-color: rgba(23, 20, 26, 0);
text-shadow: 1px 1px 0 rgba(23, 20, 26, .95), -1px 1px 0 rgba(23, 20, 26, .95), 1px -1px 0 rgba(23, 20, 26, .95), -1px -1px 0 rgba(23, 20, 26, .95);
}
88%, 100% {
background-color: rgba(23, 20, 26, .72);
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
}
}
.pl.is-demo .seg__b--chip {
animation-name: seg-chip;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .seg__b--line {
animation-name: seg-line;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes seg-chip {
0%, 49% { background-color: $subject-ink; color: #fff; }
50%, 87% { background-color: rgba(23, 20, 26, .1); color: $subject-ink; }
88%, 100% { background-color: $subject-ink; color: #fff; }
}
@keyframes seg-line {
0%, 49% { background-color: rgba(23, 20, 26, .1); color: $subject-ink; }
50%, 87% { background-color: $subject-ink; color: #fff; }
88%, 100% { background-color: rgba(23, 20, 26, .1); color: $subject-ink; }
}
.pl.is-demo .pl__fill {
animation-name: cap-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes cap-fill {
0% { transform: scaleX(.07); }
88%, 100% { transform: scaleX(.62); }
}
// ── 좁은 화면: 간격을 올리지 않는다. 덜 중요한 것을 뺀다 ──
@media (max-width: 340px) {
.pl__time { display: none; }
.cap__l { line-height: 14px; }
.cc { font-size: 11px; }
.seg__b { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .cap__l--1, .pl.is-demo .cap__l--2, .pl.is-demo .cap__l--3, .pl.is-demo .cap__l--4,
.pl.is-demo .seg__b--chip, .pl.is-demo .seg__b--line, .pl.is-demo .pl__fill,
.pl__stripes { animation: none !important; }
.cap, .cap__l, .cc, .seg__b { transition: none; }
}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;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
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: 82%;
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, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.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.07);
}
.cap {
position: absolute;
left: 0;
right: 0;
bottom: 16px;
display: grid;
place-items: center;
opacity: 1;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl.is-off .cap {
opacity: 0;
}
.cap__l {
grid-area: 1/1;
box-sizing: border-box;
max-width: 92%;
padding: 4px 8px;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.72);
color: #fff;
font-size: clamp(10px, 2.9vw, 13px);
line-height: 16px;
font-weight: 700;
white-space: nowrap;
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
opacity: 0;
transform: translateY(10px);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cap__l.is-on {
opacity: 1;
transform: none;
}
.pl.is-outline .cap__l {
background-color: rgba(23, 20, 26, 0);
text-shadow: 1px 1px 0 rgba(23, 20, 26, 0.95), -1px 1px 0 rgba(23, 20, 26, 0.95), 1px -1px 0 rgba(23, 20, 26, 0.95), -1px -1px 0 rgba(23, 20, 26, 0.95);
}
.pl__bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-top: 1px solid rgba(23, 20, 26, 0.12);
background: #fff7e6;
}
.cc {
box-sizing: border-box;
height: 32px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
cursor: pointer;
font-size: 12px;
line-height: 16px;
font-weight: 700;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cc[aria-pressed=false] {
background: rgba(23, 20, 26, 0.1);
color: #17141a;
}
.cc:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.cc__ic {
display: inline-flex;
}
.seg {
display: flex;
align-items: center;
gap: 4px;
}
.seg__b {
box-sizing: border-box;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 0;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.1);
color: #17141a;
cursor: pointer;
font-size: 11px;
line-height: 14px;
font-weight: 700;
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.seg__b.is-on {
background-color: #17141a;
color: #fff;
}
.seg__b:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pl__time {
margin-left: auto;
color: rgba(23, 20, 26, 0.66);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 600;
}
.pl__time b {
color: #17141a;
font-weight: 700;
}
.pl.is-demo .cap__l--1 {
animation-name: cap-1, cap-style;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--2 {
animation-name: cap-2, cap-style;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--3 {
animation-name: cap-3, cap-style;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--4 {
animation-name: cap-4, cap-style;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes cap-1 {
0%, 21.99% {
opacity: 1;
transform: none;
}
22%, 87.99% {
opacity: 0;
transform: translateY(10px);
}
88% {
opacity: 1;
transform: translateY(10px);
}
93%, 100% {
opacity: 1;
transform: none;
}
}
@keyframes cap-2 {
0%, 21.99% {
opacity: 0;
transform: translateY(10px);
}
22% {
opacity: 1;
transform: translateY(10px);
}
27%, 43.99% {
opacity: 1;
transform: none;
}
44%, 100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes cap-3 {
0%, 43.99% {
opacity: 0;
transform: translateY(10px);
}
44% {
opacity: 1;
transform: translateY(10px);
}
49%, 65.99% {
opacity: 1;
transform: none;
}
66%, 100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes cap-4 {
0%, 65.99% {
opacity: 0;
transform: translateY(10px);
}
66% {
opacity: 1;
transform: translateY(10px);
}
71%, 87.99% {
opacity: 1;
transform: none;
}
88%, 100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes cap-style {
0%, 49% {
background-color: rgba(23, 20, 26, 0.72);
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
}
50%, 87% {
background-color: rgba(23, 20, 26, 0);
text-shadow: 1px 1px 0 rgba(23, 20, 26, 0.95), -1px 1px 0 rgba(23, 20, 26, 0.95), 1px -1px 0 rgba(23, 20, 26, 0.95), -1px -1px 0 rgba(23, 20, 26, 0.95);
}
88%, 100% {
background-color: rgba(23, 20, 26, 0.72);
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
}
}
.pl.is-demo .seg__b--chip {
animation-name: seg-chip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .seg__b--line {
animation-name: seg-line;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes seg-chip {
0%, 49% {
background-color: #17141a;
color: #fff;
}
50%, 87% {
background-color: rgba(23, 20, 26, 0.1);
color: #17141a;
}
88%, 100% {
background-color: #17141a;
color: #fff;
}
}
@keyframes seg-line {
0%, 49% {
background-color: rgba(23, 20, 26, 0.1);
color: #17141a;
}
50%, 87% {
background-color: #17141a;
color: #fff;
}
88%, 100% {
background-color: rgba(23, 20, 26, 0.1);
color: #17141a;
}
}
.pl.is-demo .pl__fill {
animation-name: cap-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes cap-fill {
0% {
transform: scaleX(0.07);
}
88%, 100% {
transform: scaleX(0.62);
}
}
@media (max-width: 340px) {
.pl__time {
display: none;
}
.cap__l {
line-height: 14px;
}
.cc {
font-size: 11px;
}
.seg__b {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .cap__l--1, .pl.is-demo .cap__l--2, .pl.is-demo .cap__l--3, .pl.is-demo .cap__l--4,
.pl.is-demo .seg__b--chip, .pl.is-demo .seg__b--line, .pl.is-demo .pl__fill,
.pl__stripes {
animation: none !important;
}
.cap, .cap__l, .cc, .seg__b {
transition: none;
}
}// 실제 동작: CC 버튼으로 자막을 켜고 끄고, 세그먼트로 자막 스타일을 고른다. 더미 재생 시계가 문장을 갈아 끼운다.
var root = document.getElementById('pl'), cc = document.getElementById('cc'),
seg = document.getElementById('seg'), clock = document.getElementById('clock'),
now = document.getElementById('t-now'), fill = document.getElementById('fill'),
lines = root.querySelectorAll('.cap__l');
var DUR = 180, t = 12, last = Date.now();
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function show(n) {
var i = n % lines.length;
for (var k = 0; k < lines.length; k++) lines[k].classList.toggle('is-on', k === i);
}
function calm() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
setInterval(function () {
var n = Date.now();
t = (t + (n - last) / 1000) % DUR;
last = n;
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
if (!root.classList.contains('is-demo')) show(Math.floor(t / 2));
}, 250);
cc.addEventListener('click', function () {
calm();
var on = cc.getAttribute('aria-pressed') !== 'true';
cc.setAttribute('aria-pressed', String(on));
root.classList.toggle('is-off', !on);
});
seg.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('.seg__b') : null;
if (!b) return;
calm();
var bs = seg.querySelectorAll('.seg__b');
for (var k = 0; k < bs.length; k++) {
var on = bs[k] === b;
bs[k].setAttribute('aria-pressed', String(on));
bs[k].classList.toggle('is-on', on);
}
root.classList.toggle('is-outline', b.getAttribute('data-s') === 'line');
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CaptionToggle.module.scss";
export type CaptionToggleProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
/** 돌려 가며 보여 줄 자막 문장 */
lines?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function CcIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M232 64v128a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h192a8 8 0 0 1 8 8" opacity=".2" />
<path d="M224 48H32a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m0 144H32V64h192zm-105.07-40.29a8 8 0 0 1-2.93 10.93a40 40 0 1 1 0-69.28a8 8 0 1 1-8 13.85a24 24 0 1 0 0 41.58a8 8 0 0 1 10.93 2.92m80 0a8 8 0 0 1-2.93 10.93a40 40 0 1 1 0-69.28a8 8 0 1 1-8 13.85a24 24 0 1 0 0 41.58a8 8 0 0 1 10.93 2.92" />
</g>
</svg>
);
}
export default function CaptionToggle({
total = 180,
start = 12,
lines = ["영상이 시작됩니다", "자막을 켜 보세요", "스타일을 고를 수 있어요", "이제 준비가 끝났어요"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CaptionToggleProps) {
const [on, setOn] = useState(true);
const [outline, setOutline] = useState(false);
const [t, setT] = useState(start);
// 더미 재생 시계 — 바닐라의 setInterval(250ms) 과 같은 방식(경과 시간으로 걷는다)
useEffect(() => {
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);
}, [total]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 시계가 2초마다 문장을 갈아 끼운다
const cur = Math.floor(t / 2) % lines.length;
return (
<figure
className={`${styles.player} ${on ? "" : styles.isOff} ${outline ? styles.isOutline : ""}`}
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>
<div className={styles.cap}>
{lines.map((l, k) => (
<span key={l} className={`${styles.line} ${k === cur ? styles.isOn : ""}`}>
{l}
</span>
))}
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
<div className={styles.bar}>
<button
className={styles.cc}
type="button"
aria-pressed={on}
onClick={() => setOn((v) => !v)}
>
<span className={styles.ccIc} aria-hidden="true">
<CcIcon />
</span>
<span>자막</span>
</button>
<div className={styles.seg} role="group" aria-label="자막 스타일">
<button
className={`${styles.segBtn} ${outline ? "" : styles.isOn}`}
type="button"
aria-pressed={!outline}
onClick={() => setOutline(false)}
>
바탕칩
</button>
<button
className={`${styles.segBtn} ${outline ? styles.isOn : ""}`}
type="button"
aria-pressed={outline}
onClick={() => setOutline(true)}
>
외곽선
</button>
</div>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
</div>
</figure>
);
}05 · 자막 토글과 자막 스타일 — 측정 누적면적 34.058% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
바탕칩과 외곽선, 두 스타일을 가르는 것은 background-color 를 지우고 네 방향 text-shadow 를 세운 것뿐입니다.
@keyframes cap-1 {
0%, 21.99% { opacity: 1; transform: none; }
22%, 87.99% { opacity: 0; transform: translateY(10px); }
88% { opacity: 1; transform: translateY(10px); }
93%, 100% { opacity: 1; transform: none; }
}
@keyframes cap-2 {
0%, 21.99% { opacity: 0; transform: translateY(10px); }
22% { opacity: 1; transform: translateY(10px); }
27%, 43.99% { opacity: 1; transform: none; }
44%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes cap-3 {
0%, 43.99% { opacity: 0; transform: translateY(10px); }
44% { opacity: 1; transform: translateY(10px); }
49%, 65.99% { opacity: 1; transform: none; }
66%, 100% { opacity: 0; transform: translateY(10px); }
}
06챕터 마커 시크 바
어떤 움직임인가
시크 바가 인트로·설치·예제·정리 네 구간으로 끊어져 보이고, 재생 머리가 경계를 넘는 순간 위에 뜬 챕터 이름이 한 프레임에 바뀝니다. 구간 자리는 --s·--e 퍼센트로 잡고 사이 틈은 margin-right 로 깎았기 때문에, 눈에 보이는 틈이 있어도 경계 좌표는 재생 머리와 어긋나지 않습니다.

<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" id="clock">0:08</span>
<span class="pl__badge">3:00</span>
<div class="nm">
<div class="nm__list" id="nm">
<b class="nm__i">1 · 인트로</b>
<b class="nm__i">2 · 설치</b>
<b class="nm__i">3 · 예제</b>
<b class="nm__i">4 · 정리</b>
</div>
</div>
</div>
<div class="sk__row">
<span class="sk__t"><b id="t-now">0:08</b> / 3:00</span>
<div class="sk" id="sk" role="slider" tabindex="0" aria-label="재생 위치" aria-valuemin="0" aria-valuemax="180" aria-valuenow="8" aria-valuetext="인트로 챕터, 0:08">
<div class="ch" id="ch">
<span class="ch__seg" style="--s:0;--e:.1333;--k:7.5"><i class="ch__fill"></i></span>
<span class="ch__seg" style="--s:.1333;--e:.4;--k:3.75"><i class="ch__fill"></i></span>
<span class="ch__seg" style="--s:.4;--e:.7778;--k:2.647"><i class="ch__fill"></i></span>
<span class="ch__seg ch__seg--end" style="--s:.7778;--e:1;--k:4.5"><i class="ch__fill"></i></span>
<span class="ch__head" aria-hidden="true"><i class="ch__dot"></i></span>
</div>
</div>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// --p 는 등록된 사용자 지정 속성 — 키프레임에서 보간된다 (미등록이면 steps 처럼 튄다)
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.0444; }
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(76px, 22vw, 108px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute; inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .13) 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: 16%; top: 10%;
width: 22px; height: 22px; 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: 82%; 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); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
// ── 챕터 이름 — 한 줄짜리 창으로 4줄을 굴린다. 교체는 steps(1,end) 로 한 프레임 컷 ──
.nm {
position: absolute; left: $sp-2; bottom: $sp-2;
height: 20px;
padding-left: $sp-2; padding-right: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .74);
overflow: hidden;
}
.nm__list { translate: 0 calc(var(--i, 0) * -20px); }
.nm__i {
display: block;
height: 20px;
color: #fff;
font-size: 11px; line-height: 20px; font-weight: 700;
white-space: nowrap;
}
.sk__row {
display: flex; align-items: center; gap: $sp-3;
padding: $sp-3;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, .72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
}
.sk {
position: relative;
display: flex; align-items: center;
flex: 1 1 auto;
box-sizing: border-box;
height: 36px;
padding-left: $sp-2; padding-right: $sp-2;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-sm; }
// 구간은 % 로 자리를 잡는다 — 틈을 margin-right 로 깎아도 경계 좌표가 안 밀려 재생 머리와 딱 맞는다
.ch {
--p: 0.0444;
position: relative;
flex: 1 1 auto;
height: 12px;
}
.ch__seg {
position: absolute; top: 0; bottom: 0;
left: calc(var(--s) * 100%);
right: calc(100% - var(--e) * 100%);
margin-right: $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .16);
overflow: hidden;
}
.ch__seg--end { margin-right: 0; }
.ch__seg:hover { background: rgba(23, 20, 26, .26); }
.ch__fill {
position: absolute; inset: 0;
display: block;
border-radius: $r-pill;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
// 구간 안 진행률 = (p − 시작) × 1/(끝−시작), 0~1 로 잘라낸다
transform: scaleX(clamp(0, calc((var(--p) - var(--s)) * var(--k)), 1));
}
.ch__head {
position: absolute; top: -6px; bottom: -6px;
left: calc(var(--p) * 100%);
width: 4px;
border-radius: $r-pill;
background: $subject-ink;
transform: translateX(-50%);
pointer-events: none;
}
.ch__dot {
position: absolute; left: 50%; top: -4px;
width: 12px; height: 12px;
border-radius: 50%;
background: $stage-paper;
box-shadow: 0 2px 6px rgba(23, 20, 26, .45);
transform: translateX(-50%);
}
// ── 자동 루프: 재생 머리가 바를 지나며 구간을 차례로 채우고, 경계를 넘을 때마다 이름이 갈린다 ──
.pl.is-demo .ch {
animation-name: ch-p;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ch-p {
0% { --p: 0.0444; }
80% { --p: 0.94; }
86% { --p: 0.94; }
87% { --p: 0.0444; }
100% { --p: 0.0444; }
}
.pl.is-demo .nm__list {
animation-name: nm-roll, nm-rise;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
// 경계 시각: --p 가 .1333 / .4 / .7778 을 지나는 순간 (7.9% · 31.8% · 65.5%), 복귀는 87%
@keyframes nm-roll {
0% { translate: 0 0; }
7.9% { translate: 0 -20px; }
31.8% { translate: 0 -40px; }
65.5% { translate: 0 -60px; }
87% { translate: 0 0; }
100% { translate: 0 0; }
}
@keyframes nm-rise {
0% { transform: translateY(0); }
7.8% { transform: translateY(0); }
7.9% { transform: translateY(4px); }
15.9% { transform: translateY(0); }
31.7% { transform: translateY(0); }
31.8% { transform: translateY(4px); }
39.8% { transform: translateY(0); }
65.4% { transform: translateY(0); }
65.5% { transform: translateY(4px); }
73.5% { transform: translateY(0); }
86.9% { transform: translateY(0); }
87% { transform: translateY(4px); }
95% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.sk__row { gap: $sp-2; padding: $sp-2; }
.sk { height: 32px; }
.nm { bottom: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .ch, .pl.is-demo .nm__list, .pl__stripes { animation: none !important; }
.ch__fill, .ch__head { transition: none; }
}@property --p {
syntax: "<number>";
inherits: true;
initial-value: 0.0444;
}
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);
}
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(76px, 22vw, 108px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.13) 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: 16%;
top: 10%;
width: 22px;
height: 22px;
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: 82%;
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, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.nm {
position: absolute;
left: 8px;
bottom: 8px;
height: 20px;
padding-left: 8px;
padding-right: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.74);
overflow: hidden;
}
.nm__list {
translate: 0 calc(var(--i, 0) * -20px);
}
.nm__i {
display: block;
height: 20px;
color: #fff;
font-size: 11px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.sk__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, 0.72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
}
.sk {
position: relative;
display: flex;
align-items: center;
flex: 1 1 auto;
box-sizing: border-box;
height: 36px;
padding-left: 8px;
padding-right: 8px;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 8px;
}
.ch {
--p: 0.0444;
position: relative;
flex: 1 1 auto;
height: 12px;
}
.ch__seg {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--s) * 100%);
right: calc(100% - var(--e) * 100%);
margin-right: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
overflow: hidden;
}
.ch__seg--end {
margin-right: 0;
}
.ch__seg:hover {
background: rgba(23, 20, 26, 0.26);
}
.ch__fill {
position: absolute;
inset: 0;
display: block;
border-radius: 999px;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(clamp(0, (var(--p) - var(--s)) * var(--k), 1));
}
.ch__head {
position: absolute;
top: -6px;
bottom: -6px;
left: calc(var(--p) * 100%);
width: 4px;
border-radius: 999px;
background: #17141a;
transform: translateX(-50%);
pointer-events: none;
}
.ch__dot {
position: absolute;
left: 50%;
top: -4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.45);
transform: translateX(-50%);
}
.pl.is-demo .ch {
animation-name: ch-p;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ch-p {
0% {
--p: 0.0444;
}
80% {
--p: 0.94;
}
86% {
--p: 0.94;
}
87% {
--p: 0.0444;
}
100% {
--p: 0.0444;
}
}
.pl.is-demo .nm__list {
animation-name: nm-roll, nm-rise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes nm-roll {
0% {
translate: 0 0;
}
7.9% {
translate: 0 -20px;
}
31.8% {
translate: 0 -40px;
}
65.5% {
translate: 0 -60px;
}
87% {
translate: 0 0;
}
100% {
translate: 0 0;
}
}
@keyframes nm-rise {
0% {
transform: translateY(0);
}
7.8% {
transform: translateY(0);
}
7.9% {
transform: translateY(4px);
}
15.9% {
transform: translateY(0);
}
31.7% {
transform: translateY(0);
}
31.8% {
transform: translateY(4px);
}
39.8% {
transform: translateY(0);
}
65.4% {
transform: translateY(0);
}
65.5% {
transform: translateY(4px);
}
73.5% {
transform: translateY(0);
}
86.9% {
transform: translateY(0);
}
87% {
transform: translateY(4px);
}
95% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.sk__row {
gap: 8px;
padding: 8px;
}
.sk {
height: 32px;
}
.nm {
bottom: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .ch, .pl.is-demo .nm__list, .pl__stripes {
animation: none !important;
}
.ch__fill, .ch__head {
transition: none;
}
}// 실제 동작: 챕터 눈금을 누르면 그 챕터 시작으로 건너뛰고, ←→ 5초 · Home/End 로도 움직인다.
var root = document.getElementById('pl'), sk = document.getElementById('sk'), ch = document.getElementById('ch'),
nm = document.getElementById('nm'), clock = document.getElementById('clock'), now = document.getElementById('t-now');
var DUR = 180, CH = [[0, '인트로'], [24, '설치'], [72, '예제'], [140, '정리']], t = 8;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function idx(s) { var i = 0, k; for (k = 0; k < CH.length; k++) { if (s >= CH[k][0]) i = k; } return i; }
function label() {
var i = idx(t);
clock.textContent = fmt(t);
now.textContent = fmt(t);
sk.setAttribute('aria-valuenow', Math.floor(t));
sk.setAttribute('aria-valuetext', CH[i][1] + ' 챕터, ' + fmt(t));
return i;
}
function paint() {
ch.style.setProperty('--p', (t / DUR).toFixed(4));
nm.style.setProperty('--i', label());
}
// 자동 루프 중엔 CSS 가 --p 를 움직인다 — 글자는 그리기 직전(rAF)에 그 값을 읽어 같은 프레임에 맞춘다.
// setInterval 로 읽으면 프레임 사이에 값이 어긋나 시계가 재생 머리와 따로 논다 (실측 2026-09-12: 2:49 인데 머리는 인트로)
var prev = 0;
requestAnimationFrame(function tick(ms) {
if (root.classList.contains('is-demo')) {
var v = parseFloat(getComputedStyle(ch).getPropertyValue('--p'));
if (v === v) { t = v * DUR; label(); }
} else if (ms - prev > 80) {
prev = ms; t = (t + .08) % DUR; paint();
}
requestAnimationFrame(tick);
});
function take() { root.classList.remove('is-demo'); }
[].forEach.call(ch.querySelectorAll('.ch__seg'), function (el, i) {
el.addEventListener('click', function () { take(); t = CH[i][0]; paint(); sk.focus({ preventScroll: true }); });
});
sk.addEventListener('keydown', function (e) {
var k = e.key;
if (k === 'ArrowRight') { t = Math.min(DUR - 1, t + 5); }
else if (k === 'ArrowLeft') { t = Math.max(0, t - 5); }
else if (k === 'Home') { t = 0; }
else if (k === 'End') { t = DUR - 1; }
else { return; }
take(); paint(); e.preventDefault();
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChapterMarkers.module.scss";
export type Chapter = { at: number; name: string };
export type ChapterMarkersProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
/** 챕터 시작 시각과 이름 */
chapters?: Chapter[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type NameVars = CSSProperties & { "--i"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault: () => void };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function clamp01(v: number) {
return Math.max(0, Math.min(1, v));
}
export default function ChapterMarkers({
total = 180,
start = 8,
chapters = [
{ at: 0, name: "인트로" },
{ at: 24, name: "설치" },
{ at: 72, name: "예제" },
{ at: 140, name: "정리" },
],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ChapterMarkersProps) {
const [t, setT] = useState(start);
const skRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 rAF(실시간 1배) 를 setInterval(250ms, +0.25초) 로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.25) % total), 250);
return () => clearInterval(id);
}, [total]);
// 구간은 % 로 자리를 잡는다 — s(시작) · e(끝) · k = 1/(e−s) 로 구간 안 진행률을 낸다
const segs = chapters.map((c, i) => {
const s = c.at / total;
const e = (i + 1 < chapters.length ? chapters[i + 1].at : total) / total;
return { at: c.at, name: c.name, s, e, k: 1 / (e - s) };
});
let i = 0;
for (let k = 0; k < chapters.length; k++) if (t >= chapters[k].at) i = k;
const p = t / total;
const nameVars: NameVars = { "--i": String(i) };
return (
<figure className={styles.player} 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>
<div className={styles.nm}>
<div className={styles.nmList} style={nameVars}>
{chapters.map((c, k) => (
<b key={c.at} className={styles.nmItem}>
{k + 1} · {c.name}
</b>
))}
</div>
</div>
</div>
<div className={styles.row}>
<span className={styles.rowTime}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<div
ref={skRef}
className={styles.sk}
role="slider"
tabIndex={0}
aria-label="재생 위치"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={Math.floor(t)}
aria-valuetext={`${chapters[i].name} 챕터, ${fmt(t)}`}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowRight") { setT((v) => Math.min(total - 1, v + 5)); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setT((v) => Math.max(0, v - 5)); e.preventDefault(); }
else if (e.key === "Home") { setT(0); e.preventDefault(); }
else if (e.key === "End") { setT(total - 1); e.preventDefault(); }
}}
>
<div className={styles.ch}>
{segs.map((g, k) => (
<span
key={g.at}
className={`${styles.seg} ${k === segs.length - 1 ? styles.segEnd : ""}`}
style={{ left: `${(g.s * 100).toFixed(4)}%`, right: `${(100 - g.e * 100).toFixed(4)}%` }}
onClick={() => {
setT(g.at);
skRef.current?.focus({ preventScroll: true });
}}
>
<i
className={styles.segFill}
style={{ transform: `scaleX(${clamp01((p - g.s) * g.k).toFixed(4)})` }}
/>
</span>
))}
<span className={styles.head} style={{ left: `${(p * 100).toFixed(4)}%` }} aria-hidden="true">
<i className={styles.dot} />
</span>
</div>
</div>
</div>
</figure>
);
}06 · 챕터 마커 시크 바 — 측정 누적면적 29.851% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
눈금을 누르면 그 챕터 시작으로 건너뜁니다.
.ch__seg {
position: absolute; top: 0; bottom: 0;
left: calc(var(--s) * 100%);
right: calc(100% - var(--e) * 100%);
margin-right: $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .16);
overflow: hidden;
}
.ch__fill {
position: absolute; inset: 0;
display: block;
border-radius: $r-pill;
background: $color;
transform-origin: left center;
transform: scaleX(clamp(0, calc((var(--p) - var(--s)) * var(--k)), 1));
}
07미니 플레이어
무엇이 보이나
미니 버튼을 누르면 플레이어가 0.44배 카드로 줄어들어 오른쪽 아래에 뜨고, 원래 자리에는 점선 자리 표시가 남습니다.

<main class="stage">
<div class="pip is-demo" id="pip">
<div class="pip__stack">
<p class="pip__ph">미니 플레이어로 보는 중</p>
<figure class="pl" 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" id="clock">0:12</span>
<span class="pl__badge">3:00</span>
<button class="pl__close" type="button" id="pip-close" aria-label="미니 플레이어 닫기">
<svg viewBox="0 0 256 256" width="24" height="24" 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>
</button>
<div class="pl__bar">
<span class="pl__time"><b id="t-now">0:12</b> / 3:00</span>
<button class="pl__mini" type="button" id="pip-mini" aria-label="미니 플레이어" aria-pressed="false">
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor"><path d="M224 64v64h-88v72H40a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 48H40a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16M40 64h176v56h-80a8 8 0 0 0-8 8v64H40Zm176 128h-72v-56h72z"/></g></svg>
</button>
</div>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
</div>
</figure>
</div>
<div class="pip__doc" aria-hidden="true">
<span class="pip__line"></span>
<span class="pip__line pip__line--b"></span>
<span class="pip__line pip__line--c"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
// 미니로 갈 때 쓰는 값 — 레이아웃(top/left/width/height)을 건드리지 않고 transform 하나로만 옮긴다
$mini-scale: .44;
$mini-x: -4px;
$mini-y: 40px;
.pip {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
}
.pip__stack {
position: relative;
height: clamp(96px, 26vw, 132px);
}
// 원래 자리에 남는 자리표시
.pip__ph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
box-sizing: border-box;
margin: 0;
border: 2px dashed rgba(255, 247, 230, .55);
border-radius: $r-card;
background: rgba(23, 20, 26, .16);
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 700;
opacity: 0;
}
// 본문 자리 (글 줄 흉내)
.pip__doc {
display: flex;
flex-direction: column;
gap: $sp-2;
padding-top: $sp-3;
}
.pip__line {
height: 8px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .55);
}
.pip__line--b { width: 86%; }
.pip__line--c { width: 62%; background: rgba(255, 247, 230, .38); }
// ── 플레이어 ──
.pl {
position: absolute;
inset: 0;
box-sizing: border-box;
margin: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
transform-origin: right bottom; // 규칙8: 우하단 도킹 계산 기준
transition: transform $duration $easing;
}
.pl__screen {
position: relative;
height: 100%;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), 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: 20%; top: 14%;
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: 82%; 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); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
.pl__bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; 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; }
.pl__mini {
box-sizing: border-box;
width: 36px; height: 36px;
flex: 0 0 auto;
margin-left: auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
cursor: pointer;
box-shadow: 0 6px 14px rgba(47, 109, 246, .45);
}
.pl__mini:hover { background: rgba(47, 109, 246, .88); }
.pl__mini:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
// 미니 카드 위의 닫기 — 카드가 통째로 축소되므로 미리 키워 둔다 (축소 후 실효 약 33px)
.pl__close {
position: absolute; top: $sp-1; right: $sp-1;
box-sizing: border-box;
width: 40px; height: 40px;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: $r-control;
background: rgba(23, 20, 26, .82);
color: #fff;
cursor: pointer;
transform: scale(1.9);
transform-origin: right top; // 규칙8: 부모 축소 보정 모서리 고정
opacity: 0;
pointer-events: none;
}
.pl__close: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(.07);
}
// ── 사람이 눌렀을 때 (JS 가 .is-demo 를 떼고 .is-mini 를 건다) ──
.pip.is-mini .pl { transform: translate($mini-x, $mini-y) scale($mini-scale); }
.pip.is-mini .pip__ph { opacity: 1; }
.pip.is-mini .pl__close { opacity: 1; pointer-events: auto; }
.pip.is-mini .pl__mini { opacity: 0; pointer-events: none; }
// ── 자동 루프: 플레이어가 오른쪽 아래 조각 카드로 줄었다가 제자리로. transform·opacity 만 ──
.pip.is-demo .pl {
animation-name: pip-shrink;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pip.is-demo .pip__ph {
animation-name: pip-ph;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pip.is-demo .pl__close {
animation-name: pip-close;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pip.is-demo .pl__mini {
animation-name: pip-minibtn;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 줄어드는 구간을 세 프레임(24프레임 기준 4.17% 간격)으로 좁히고, 자리표시·닫기 버튼을 **줄어들기가 끝나는 그 시점**에
// 컷으로 켠다 — 그래야 "미니로 다 줄어든 장면" 이 최대 변화 프레임이 되어 poster 로 뽑힌다.
// 앞판은 자리표시가 줄어드는 도중(34%)에 켜져서 poster 가 어중간한 중간 크기 장면이었다 (실측 2026-09-12)
@keyframes pip-shrink {
0%, 26% { transform: none; }
38%, 70% { transform: translate($mini-x, $mini-y) scale($mini-scale); }
86%, 100% { transform: none; }
}
// 자리표시는 **줄어들기가 시작하는 그 프레임**에 켜고 돌아온 뒤에 끈다. 다 줄어든 뒤에 켜면
// 날아가는 동안 원래 자리가 텅 빈 채로 남아, 중간 프레임(= poster)이 "카드가 붕 떠 있는" 장면이 된다 (실측 2026-09-12)
@keyframes pip-ph {
0%, 25.99% { opacity: 0; }
26%, 85.99% { opacity: 1; }
86%, 100% { opacity: 0; }
}
@keyframes pip-close {
0%, 37.99% { opacity: 0; }
38%, 71.99% { opacity: 1; }
72%, 100% { opacity: 0; }
}
@keyframes pip-minibtn {
0%, 25.99% { opacity: 1; }
26%, 71.99% { opacity: 0; }
72%, 100% { opacity: 1; }
}
@media (max-width: 340px) {
.pip__doc { gap: $sp-1; padding-top: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pip.is-demo .pl, .pip.is-demo .pip__ph, .pip.is-demo .pl__close,
.pip.is-demo .pl__mini, .pl__stripes { animation: none !important; }
.pl { transition: none; }
}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);
}
.pip {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
}
.pip__stack {
position: relative;
height: clamp(96px, 26vw, 132px);
}
.pip__ph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
box-sizing: border-box;
margin: 0;
border: 2px dashed rgba(255, 247, 230, 0.55);
border-radius: 16px;
background: rgba(23, 20, 26, 0.16);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
opacity: 0;
}
.pip__doc {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
}
.pip__line {
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.55);
}
.pip__line--b {
width: 86%;
}
.pip__line--c {
width: 62%;
background: rgba(255, 247, 230, 0.38);
}
.pl {
position: absolute;
inset: 0;
box-sizing: border-box;
margin: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
transform-origin: right bottom;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl__screen {
position: relative;
height: 100%;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), 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: 20%;
top: 14%;
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: 82%;
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, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.pl__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
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;
}
.pl__mini {
box-sizing: border-box;
width: 36px;
height: 36px;
flex: 0 0 auto;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
cursor: pointer;
box-shadow: 0 6px 14px rgba(47, 109, 246, 0.45);
}
.pl__mini:hover {
background: rgba(47, 109, 246, 0.88);
}
.pl__mini:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pl__close {
position: absolute;
top: 4px;
right: 4px;
box-sizing: border-box;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.82);
color: #fff;
cursor: pointer;
transform: scale(1.9);
transform-origin: right top;
opacity: 0;
pointer-events: none;
}
.pl__close: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.07);
}
.pip.is-mini .pl {
transform: translate(-4px, 40px) scale(0.44);
}
.pip.is-mini .pip__ph {
opacity: 1;
}
.pip.is-mini .pl__close {
opacity: 1;
pointer-events: auto;
}
.pip.is-mini .pl__mini {
opacity: 0;
pointer-events: none;
}
.pip.is-demo .pl {
animation-name: pip-shrink;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pip.is-demo .pip__ph {
animation-name: pip-ph;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pip.is-demo .pl__close {
animation-name: pip-close;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pip.is-demo .pl__mini {
animation-name: pip-minibtn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes pip-shrink {
0%, 26% {
transform: none;
}
38%, 70% {
transform: translate(-4px, 40px) scale(0.44);
}
86%, 100% {
transform: none;
}
}
@keyframes pip-ph {
0%, 25.99% {
opacity: 0;
}
26%, 85.99% {
opacity: 1;
}
86%, 100% {
opacity: 0;
}
}
@keyframes pip-close {
0%, 37.99% {
opacity: 0;
}
38%, 71.99% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes pip-minibtn {
0%, 25.99% {
opacity: 1;
}
26%, 71.99% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.pip__doc {
gap: 4px;
padding-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pip.is-demo .pl, .pip.is-demo .pip__ph, .pip.is-demo .pl__close,
.pip.is-demo .pl__mini, .pl__stripes {
animation: none !important;
}
.pl {
transition: none;
}
}// 실제 동작: 미니 버튼이 플레이어를 transform 으로 오른쪽 아래 조각 카드로 줄인다. 닫기·Escape 로 복귀.
var pip = document.getElementById('pip'),
btnMini = document.getElementById('pip-mini'),
btnClose = document.getElementById('pip-close'),
clock = document.getElementById('clock'),
now = document.getElementById('t-now'),
fill = document.getElementById('fill');
var DUR = 180, t = 12, last = Date.now();
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
setInterval(function () {
var n = Date.now();
t = (t + (n - last) / 1000) % DUR;
last = n;
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}, 250);
function setMini(on) {
pip.classList.remove('is-demo');
pip.classList.toggle('is-mini', on);
btnMini.setAttribute('aria-pressed', on ? 'true' : 'false');
}
btnMini.addEventListener('click', function () {
setMini(true);
btnClose.focus({ preventScroll: true });
});
btnClose.addEventListener('click', function () {
setMini(false);
btnMini.focus({ preventScroll: true });
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && pip.classList.contains('is-mini')) {
setMini(false);
btnMini.focus({ preventScroll: true });
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MiniPlayer.module.scss";
export type MiniPlayerProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function MiniIcon() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M224 64v64h-88v72H40a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" />
<path d="M216 48H40a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16M40 64h176v56h-80a8 8 0 0 0-8 8v64H40Zm176 128h-72v-56h72z" />
</g>
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 256 256" width="24" height="24" 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 MiniPlayer({
total = 180,
start = 12,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MiniPlayerProps) {
const [mini, setMini] = useState(false);
const [t, setT] = useState(start);
const miniRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 setInterval(250ms) 를 그대로
useEffect(() => {
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);
}, [total]);
// 미니로 들어가면 닫기 버튼으로 초점을 옮긴다 (닫을 때 되돌리는 건 close 에서)
useEffect(() => {
if (mini) closeRef.current?.focus({ preventScroll: true });
}, [mini]);
// Escape 로 미니에서 나온다. 진짜 Document PiP 는 iframe 안에서 못 쓴다 — transform 으로 흉내 낸다
useEffect(() => {
if (!mini) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [mini]);
function close() {
setMini(false);
miniRef.current?.focus({ preventScroll: true });
}
return (
<div className={`${styles.pip} ${mini ? styles.isMini : ""}`} style={vars}>
<div className={styles.stack}>
<p className={styles.ph}>미니 플레이어로 보는 중</p>
<figure className={styles.player}>
<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>
<button
ref={closeRef}
className={styles.close}
type="button"
aria-label="미니 플레이어 닫기"
onClick={close}
>
<CloseIcon />
</button>
<div className={styles.bar}>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<button
ref={miniRef}
className={styles.mini}
type="button"
aria-label="미니 플레이어"
aria-pressed={mini}
onClick={() => setMini(true)}
>
<MiniIcon />
</button>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
</figure>
</div>
<div className={styles.doc} aria-hidden="true">
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineB}`} />
<span className={`${styles.line} ${styles.lineC}`} />
</div>
</div>
);
}07 · 미니 플레이어 — 측정 누적면적 46.533% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
새 창을 띄우는 것이 아니라 translate(-4px, 40px) scale(.44) 하나로 옮기는 것이라 주변 글줄은 한 픽셀도 밀리지 않습니다.
$mini-scale: .44;
$mini-x: -4px;
$mini-y: 40px;
.pl {
position: absolute;
inset: 0;
transform-origin: right bottom;
transition: transform $duration $easing;
}
.pip.is-mini .pl { transform: translate($mini-x, $mini-y) scale($mini-scale); }
.pip.is-mini .pip__ph { opacity: 1; }
.pip.is-mini .pl__close { opacity: 1; pointer-events: auto; }
.pip.is-mini .pl__mini { opacity: 0; pointer-events: none; }
@keyframes pip-shrink {
0%, 26% { transform: none; }
38%, 70% { transform: translate($mini-x, $mini-y) scale($mini-scale); }
86%, 100% { transform: none; }
}
08풀스크린 토글
화면에서 일어나는 일
확장 버튼을 누르면 플레이어가 1.3배로 커지면서 모서리 둥글기가 0 으로 펴지고, 버튼 안 아이콘이 축소 모양으로 바뀝니다.

<main class="stage">
<figure class="fs is-demo" id="fs">
<div class="fs__screen">
<div class="fs__stripes" aria-hidden="true"></div>
<div class="fs__world" aria-hidden="true"><span class="fs__sun"></span><span class="fs__hill fs__hill--a"></span><span class="fs__hill fs__hill--b"></span></div>
<span class="fs__clock" id="clock">0:47</span>
<span class="fs__badge">3:00</span>
<span class="fs__hint" aria-hidden="true">Esc 로 나가기</span>
<div class="fs__bar">
<span class="fs__time"><b id="t-now">0:47</b> / 3:00</span>
<button class="fsb" type="button" id="fsb" aria-expanded="false" aria-label="전체화면">
<span class="fsb__ic fsb__ic--out" aria-hidden="true"><svg viewBox="0 0 256 256" width="17" height="17"><g fill="currentColor"><path d="M208 48v160H48V48Z" opacity=".2"/><path d="M216 48v40a8 8 0 0 1-16 0V56h-32a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8M88 200H56v-32a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16m120-40a8 8 0 0 0-8 8v32h-32a8 8 0 0 0 0 16h40a8 8 0 0 0 8-8v-40a8 8 0 0 0-8-8M88 40H48a8 8 0 0 0-8 8v40a8 8 0 0 0 16 0V56h32a8 8 0 0 0 0-16"/></g></svg></span>
<span class="fsb__ic fsb__ic--in" aria-hidden="true"><svg viewBox="0 0 256 256" width="17" height="17"><g fill="currentColor"><path d="M208 64v128a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M152 96V48a8 8 0 0 1 16 0v40h40a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8m-56 56H48a8 8 0 0 0 0 16h40v40a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8m112 0h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-40h40a8 8 0 0 0 0-16M96 40a8 8 0 0 0-8 8v40H48a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8"/></g></svg></span>
</button>
</div>
<span class="fs__fillwrap" aria-hidden="true"><span class="fs__fill" id="fill"></span></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// ── 플레이어. 커지는 건 width/height 가 아니라 transform: scale() 하나다 ──
.fs {
position: relative;
box-sizing: border-box;
width: min(300px, 72vw);
margin: 0;
transform: scale(1);
transform-origin: center center;
transition: transform $duration $easing;
}
.fs__screen {
position: relative;
height: clamp(88px, 26vw, 124px);
border-radius: $r-card;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(23, 20, 26, .92));
box-shadow: $shadow-raised;
transition: border-radius $duration $easing;
}
.fs.is-full { transform: scale(1.3); }
.fs.is-full .fs__screen { border-radius: 0; }
.fs__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(255, 255, 255, .13) 0 12px, transparent 12px 32px);
animation-name: fs-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.fs__world { position: absolute; inset: 0; }
.fs__sun {
position: absolute; right: 20%; top: 16%;
width: 26px; height: 26px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .22);
}
.fs__hill { position: absolute; bottom: -46%; height: 80%; border-radius: 50%; }
.fs__hill--a { left: -12%; width: 70%; background: rgba(255, 255, 255, .16); }
.fs__hill--b { right: -16%; width: 60%; background: rgba(255, 255, 255, .1); }
.fs__clock,
.fs__badge {
position: absolute; top: $sp-2;
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: 10px; line-height: 14px; font-weight: 700;
}
.fs__clock { left: $sp-2; }
.fs__badge { right: $sp-2; color: rgba(255, 255, 255, .85); font-weight: 600; }
// 전체화면일 때만 뜨는 안내 — 컷으로 갈아 끼운다
.fs__hint {
position: absolute; left: 50%; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(47, 109, 246, .92);
color: #fff;
font-size: 10px; line-height: 14px; font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: translateX(-50%);
}
.fs.is-full .fs__hint { opacity: 1; }
.fs__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;
}
.fs__time {
color: $subject-cream; // 화면 하단 그라디언트가 중간 밝기(#696b79)를 지나 잉크는 3.46:1 — 크림이 두 시점 다 통과(4.57~8.4:1)
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba($subject-ink, .7);
}
.fs__time b { font-weight: 700; }
.fsb {
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: $r-sm;
background: rgba(255, 255, 255, .16);
color: #fff;
cursor: pointer;
transition: background $duration $easing, transform $duration $easing;
}
.fsb:hover { background: rgba(255, 255, 255, .3); }
.fsb:active { transform: scale(.92); }
.fsb:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.fsb__ic {
position: absolute; inset: 0;
display: inline-flex; align-items: center; justify-content: center;
}
.fsb__ic--in { opacity: 0; }
.fs.is-full .fsb__ic--out { opacity: 0; }
.fs.is-full .fsb__ic--in { opacity: 1; }
.fs__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 4px; background: rgba(255, 255, 255, .26); }
.fs__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.26);
}
// ── 자동 루프: 보통 → 전체화면 → 보통. 아이콘과 안내는 steps(1,end) 로 한 프레임에 갈아 끼운다 ──
.fs.is-demo {
animation-name: fs-zoom;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fs-zoom {
0%, 16% { transform: scale(1); }
38%, 64% { transform: scale(1.3); }
86%, 100% { transform: scale(1); }
}
.fs.is-demo .fs__screen {
animation-name: fs-round;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fs-round {
0%, 16% { border-radius: $r-card; }
38%, 64% { border-radius: 0; }
86%, 100% { border-radius: $r-card; }
}
.fs.is-demo .fsb__ic--out,
.fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fs.is-demo .fsb__ic--out { animation-name: fs-ic-out; }
.fs.is-demo .fsb__ic--in { animation-name: fs-ic-in; }
.fs.is-demo .fs__hint { animation-name: fs-hint; }
@keyframes fs-ic-out {
0%, 26% { opacity: 1; }
27%, 74% { opacity: 0; }
75%, 100% { opacity: 1; }
}
@keyframes fs-ic-in {
0%, 26% { opacity: 0; }
27%, 74% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes fs-hint {
0%, 26% { opacity: 0; }
27%, 74% { opacity: 1; }
75%, 100% { opacity: 0; }
}
.fs.is-demo .fs__fill {
animation-name: fs-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-fill {
0% { transform: scaleX(.26); }
86%, 100% { transform: scaleX(.62); }
}
@media (max-width: 340px) {
.fs__bar { padding: 0 $sp-1 $sp-2; }
.fs__clock, .fs__badge { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.fs.is-demo, .fs.is-demo .fs__screen, .fs.is-demo .fsb__ic--out, .fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint, .fs.is-demo .fs__fill, .fs__stripes { animation: none !important; }
.fs, .fs__screen, .fsb { transition: none; }
}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);
}
.fs {
position: relative;
box-sizing: border-box;
width: min(300px, 72vw);
margin: 0;
transform: scale(1);
transform-origin: center center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fs__screen {
position: relative;
height: clamp(88px, 26vw, 124px);
border-radius: 16px;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(23, 20, 26, 0.92));
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: border-radius 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fs.is-full {
transform: scale(1.3);
}
.fs.is-full .fs__screen {
border-radius: 0;
}
.fs__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.13) 0 12px, transparent 12px 32px);
animation-name: fs-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.fs__world {
position: absolute;
inset: 0;
}
.fs__sun {
position: absolute;
right: 20%;
top: 16%;
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.22);
}
.fs__hill {
position: absolute;
bottom: -46%;
height: 80%;
border-radius: 50%;
}
.fs__hill--a {
left: -12%;
width: 70%;
background: rgba(255, 255, 255, 0.16);
}
.fs__hill--b {
right: -16%;
width: 60%;
background: rgba(255, 255, 255, 0.1);
}
.fs__clock,
.fs__badge {
position: absolute;
top: 8px;
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: 10px;
line-height: 14px;
font-weight: 700;
}
.fs__clock {
left: 8px;
}
.fs__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
font-weight: 600;
}
.fs__hint {
position: absolute;
left: 50%;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.92);
color: #fff;
font-size: 10px;
line-height: 14px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: translateX(-50%);
}
.fs.is-full .fs__hint {
opacity: 1;
}
.fs__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 12px;
}
.fs__time {
color: #fff7e6;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(23, 20, 26, 0.7);
}
.fs__time b {
font-weight: 700;
}
.fsb {
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: 8px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fsb:hover {
background: rgba(255, 255, 255, 0.3);
}
.fsb:active {
transform: scale(0.92);
}
.fsb:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.fsb__ic {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fsb__ic--in {
opacity: 0;
}
.fs.is-full .fsb__ic--out {
opacity: 0;
}
.fs.is-full .fsb__ic--in {
opacity: 1;
}
.fs__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.26);
}
.fs__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.26);
}
.fs.is-demo {
animation-name: fs-zoom;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes fs-zoom {
0%, 16% {
transform: scale(1);
}
38%, 64% {
transform: scale(1.3);
}
86%, 100% {
transform: scale(1);
}
}
.fs.is-demo .fs__screen {
animation-name: fs-round;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes fs-round {
0%, 16% {
border-radius: 16px;
}
38%, 64% {
border-radius: 0;
}
86%, 100% {
border-radius: 16px;
}
}
.fs.is-demo .fsb__ic--out,
.fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fs.is-demo .fsb__ic--out {
animation-name: fs-ic-out;
}
.fs.is-demo .fsb__ic--in {
animation-name: fs-ic-in;
}
.fs.is-demo .fs__hint {
animation-name: fs-hint;
}
@keyframes fs-ic-out {
0%, 26% {
opacity: 1;
}
27%, 74% {
opacity: 0;
}
75%, 100% {
opacity: 1;
}
}
@keyframes fs-ic-in {
0%, 26% {
opacity: 0;
}
27%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
@keyframes fs-hint {
0%, 26% {
opacity: 0;
}
27%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
.fs.is-demo .fs__fill {
animation-name: fs-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-fill {
0% {
transform: scaleX(0.26);
}
86%, 100% {
transform: scaleX(0.62);
}
}
@media (max-width: 340px) {
.fs__bar {
padding: 0 4px 8px;
}
.fs__clock, .fs__badge {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.fs.is-demo, .fs.is-demo .fs__screen, .fs.is-demo .fsb__ic--out, .fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint, .fs.is-demo .fs__fill, .fs__stripes {
animation: none !important;
}
.fs, .fs__screen, .fsb {
transition: none;
}
}// 실제 동작: 버튼으로 무대를 채우고 Escape 로 돌아온다. 진짜 Fullscreen API 는 iframe 안에서 못 쓴다 — transform 으로 흉내 낸다.
var root = document.getElementById('fs'),
btn = document.getElementById('fsb'),
clock = document.getElementById('clock'),
now = document.getElementById('t-now'),
fill = document.getElementById('fill');
var DUR = 180, t = 47;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
setInterval(function () {
t = (t + .5) % DUR;
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}, 500);
function full(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-full', on);
btn.setAttribute('aria-expanded', String(on));
btn.setAttribute('aria-label', on ? '전체화면 나가기' : '전체화면');
}
btn.addEventListener('click', function () { full(!root.classList.contains('is-full')); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-full')) {
full(false);
btn.focus({ preventScroll: true });
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FullscreenToggle.module.scss";
export type FullscreenToggleProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function ExpandIcon() {
return (
<svg viewBox="0 0 256 256" width="17" height="17" aria-hidden="true">
<g fill="currentColor">
<path d="M208 48v160H48V48Z" opacity=".2" />
<path d="M216 48v40a8 8 0 0 1-16 0V56h-32a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8M88 200H56v-32a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16m120-40a8 8 0 0 0-8 8v32h-32a8 8 0 0 0 0 16h40a8 8 0 0 0 8-8v-40a8 8 0 0 0-8-8M88 40H48a8 8 0 0 0-8 8v40a8 8 0 0 0 16 0V56h32a8 8 0 0 0 0-16" />
</g>
</svg>
);
}
function CollapseIcon() {
return (
<svg viewBox="0 0 256 256" width="17" height="17" aria-hidden="true">
<g fill="currentColor">
<path d="M208 64v128a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2" />
<path d="M152 96V48a8 8 0 0 1 16 0v40h40a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8m-56 56H48a8 8 0 0 0 0 16h40v40a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8m112 0h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-40h40a8 8 0 0 0 0-16M96 40a8 8 0 0 0-8 8v40H48a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" />
</g>
</svg>
);
}
export default function FullscreenToggle({
total = 180,
start = 47,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FullscreenToggleProps) {
const [full, setFull] = useState(false);
const [t, setT] = useState(start);
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 setInterval(500ms, +0.5초) 을 그대로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.5) % total), 500);
return () => clearInterval(id);
}, [total]);
// Escape 로 나간다. 진짜 Fullscreen API 는 iframe 안에서 못 쓴다 — transform 으로 흉내 낸다
useEffect(() => {
if (!full) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setFull(false);
btnRef.current?.focus({ preventScroll: true });
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [full]);
return (
<figure className={`${styles.player} ${full ? styles.isFull : ""}`} 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.hint} aria-hidden="true">Esc 로 나가기</span>
<div className={styles.bar}>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<button
ref={btnRef}
className={styles.fsb}
type="button"
aria-expanded={full}
aria-label={full ? "전체화면 나가기" : "전체화면"}
onClick={() => setFull((v) => !v)}
>
<span className={`${styles.ic} ${styles.icOut}`}>
<ExpandIcon />
</span>
<span className={`${styles.ic} ${styles.icIn}`}>
<CollapseIcon />
</span>
</button>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
</figure>
);
}08 · 풀스크린 토글 — 측정 누적면적 56.881% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
커지는 일은 width 와 height 가 아니라 transform: scale() 이 맡기 때문에 옆 요소의 레이아웃을 다시 계산하지 않습니다.
.fs {
position: relative;
box-sizing: border-box;
width: min(300px, 72vw);
transform: scale(1);
transform-origin: center center;
transition: transform $duration $easing;
}
.fs.is-full { transform: scale(1.3); }
.fs.is-full .fs__screen { border-radius: 0; }
@keyframes fs-zoom {
0%, 16% { transform: scale(1); }
38%, 64% { transform: scale(1.3); }
86%, 100% { transform: scale(1); }
}
@keyframes fs-round {
0%, 16% { border-radius: $r-card; }
38%, 64% { border-radius: 0; }
86%, 100% { border-radius: $r-card; }
}
09키보드 단축키 안내 오버레이
어떤 움직임인가
? 버튼을 누르면 화면이 어두워지며 단축키 네 줄이 2×2 격자로 뜨고, Space·좌우 화살표·M·F 가 목록 안에만 적혀 있는 것이 아니라 그 자리에서 실제로 듣습니다.

<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 포함 · 비밀번호 보기
어디를 바꾸면 되나
자동 재생에서는 다섯 개의 키 모양이 같은 kbd-cycle 키프레임을 음수 animation-delay 로 어긋나게 돌려 차례로 눌립니다.
.pl.is-demo .ovl kbd {
animation-name: kbd-cycle;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes kbd-cycle {
0%, 10% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; }
11%, 20% { transform: translateY(2px); background: $stage-yellow; color: $subject-ink; }
21%, 100% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; }
}
.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; }
어디서 깨지는가 — 함정 다섯
첫 판에서 가장 크게 틀어진 것은 03번 음량이었습니다. 팝오버를 스피커 버튼과 같은 flex 줄 안에 넣어 두었더니, 닫혀 있을 때도 152px 폭을 그대로 차지해서 버튼이 왼쪽 끝이 아니라 화면 한가운데에 섰고 오른쪽 아래의 "1:24 / 3:00" 시간 글자와 겹쳤습니다. 닫힌 상자는 보이지 않을 뿐 자리는 내놓지 않습니다. position: absolute; left: calc(100% + 4px) 로 버튼 옆에 띄우고 시간 글자를 오른쪽으로 보내고 나서야 버튼이 제자리에 붙었습니다.
둘째는 02번 시크 바에서 나왔습니다. 파란 채움을 transform: scaleX(.18) 고정값으로 두고 손잡이만 움직였더니, 재생 머리는 54%까지 갔는데 채워진 구간은 20% 에 멈춰 있는 장면이 나왔습니다. 눈으로 보면 "재생이 안 된 곳을 재생 머리가 지나간" 꼴이라 한눈에 이상합니다. 채움·버퍼·손잡이·툴팁을 전부 --p 하나에서 계산하도록 고쳤는데, 여기에는 조건이 하나 붙습니다. --p 를 @property 로 syntax: "<number>" 라고 등록해 두지 않으면 브라우저는 그 값을 그냥 글자로 보고 키프레임 사이를 보간하지 않습니다. 등록 전에는 바가 중간에 툭툭 끊겨 뛰었습니다. 06번 챕터 바도 같은 등록을 씁니다.
셋째는 자막입니다. 05번의 첫 판은 나가는 문장이 25% 에서 사라지고 들어오는 문장이 25% 에서 30% 사이에 떠오르게 되어 있었습니다. 2초를 12fps 로 뽑으면 프레임 간격이 정확히 4.1667% 라 25.0% 지점이 샘플에 그대로 걸렸고, 그 한 장이 자막이 하나도 없는 빈 화면이었습니다. 그래서 opacity 는 21.99% 에서 22% 로 0.01% 폭만 주어 컷으로 갈아 끼우고, 부드러움은 translateY 로 떠오르는 쪽에만 남겼습니다. 겹치는 프레임도 비는 프레임도 없어진 것은 이 뒤였습니다. 같은 문제가 06번에도 있었는데 그쪽은 방향이 달랐습니다. CSS 가 --p 를 움직이는데 시간 글자만 setInterval 로 따로 세었더니, 재생 머리는 첫 챕터에 있는데 시간 글자는 2:49 를 가리켰습니다. requestAnimationFrame 안에서 getComputedStyle 로 --p 를 읽어 같은 프레임에 맞추자 둘이 붙었습니다.
넷째는 크기입니다. scale() 로 키운 상자는 overflow: hidden 이 걸려 있어도 브라우저가 재는 문서 높이에는 커진 크기 그대로 잡힙니다. 눈으로는 무대 안에 얌전히 들어가 있는데 폭을 320px 로 줄여 재면 세로 스크롤이 생기는 식이라, 화면만 봐서는 원인이 안 보입니다. 07·08번이 나란히 여기 걸렸습니다. 08번은 1.3배로 커질 자리를 미리 비워 두려고 플레이어 폭을 min(300px, 72vw) 로 좁히고 나서야 무대 안쪽에 들어왔습니다. 07번은 줄어드는 쪽인데도 문서 높이를 늘렸는데, 카드가 transform-origin: right bottom 을 축 삼아 아래로 40px 내려가면서 그만큼의 자리를 문서 아래쪽에 새로 만들었기 때문입니다.
다섯째는 차례입니다. 09번의 키 다섯 개는 같은 키프레임 하나를 음수 animation-delay 로 -1980ms 부터 -940ms 까지 어긋나게 돌려, Space·왼쪽·오른쪽·M·F 순으로 눌리게 해 두었습니다. 이 다섯 줄을 animation 단축 한 줄로 합치면 단축에 적지 않은 animation-delay 가 전부 0 으로 초기화됩니다. 어긋나게 준 값이 통째로 지워지니 다섯 개가 같은 프레임에 함께 빛나고, 하나씩 눌리는 그림이 사라집니다. 눈에 보이는 결과는 "키가 순서대로 안 눌린다" 인데 원인은 단축 속성 한 줄이라, 키프레임 쪽만 들여다보면 하루를 써도 안 나옵니다. animation-name 과 animation-duration 처럼 개별 속성으로만 적어야 지연이 살아남습니다. 여기까지 고친 아홉 벌이 그대로 zip 에 들어 있고, 그 압축 비밀번호는 3ekbtjj6 입니다.
접근성 — reduced-motion 에서 무엇이 남는가
움직임을 줄여 달라는 설정에서 끄는 것은 장식용 반복뿐입니다. 창밖 줄무늬 패닝과 자동 시연 루프는 멈추지만, 눌림·열림·음량·챕터 같은 상태 값은 그대로 남아 지금 무엇이 켜져 있는지 화면에서 계속 읽힙니다. 아홉 개 모두 @media (prefers-reduced-motion: reduce) 블록이 있고, 그 안에서 animation: none 과 transition: none 을 같이 끕니다.
| 번호 | 붙은 역할·상태 | 움직임을 줄였을 때 남는 것 |
|---|---|---|
| 01 | aria-label 이 재생과 일시정지로 교체 |
아이콘은 도는 과정 없이 바로 바뀐 모양 |
| 02 · 03 | role="slider" + aria-valuemin/max/now |
채움과 손잡이가 값 위치에 그대로 서 있음 |
| 04 | aria-haspopup="menu" · role="menuitemradio" |
열린 메뉴와 고른 줄의 체크 |
| 05 | aria-pressed (CC 와 스타일 두 칸) |
켜진 자막 문장과 고른 스타일 |
| 06 | aria-valuetext 로 "설치 챕터, 0:24" |
네 구간과 재생 머리 위치 |
| 07 | aria-pressed + aria-label="미니 플레이어" |
줄어든 카드와 점선 자리 표시 |
| 08 | aria-expanded |
커진 화면과 축소 아이콘 |
| 09 | role="dialog" aria-modal="true" |
열린 목록과 키 모양 |
숫자만 읽어 주면 모자랄 때가 있습니다. 06번이 aria-valuetext 에 "설치 챕터, 0:24" 처럼 챕터 이름을 실어 보내는 이유가 그것이고, 쓰인 속성들의 정의는 MDN 의 slider 역할 문서에 있습니다. 04·06·09번은 마우스 없이도 끝까지 갑니다. 초점을 돌려놓을 때 focus({ preventScroll: true }) 를 쓰는 것은 부품이 iframe 안에 박혀 있어, 옵션 없이 부르면 읽던 화면이 저 혼자 뛰기 때문입니다.
영상·오디오 자리에서 쓰는 부품은 미디어 카테고리에, 눌러야 반응하는 부품은 클릭 카테고리에 모아 두었습니다.
FAQ
영상 없이 만든 css 오디오 플레이어 디자인 컨트롤을 진짜 <video> 위에 그대로 올릴 수 있나요?
껍데기는 그대로 쓸 수 있습니다. 풍경 요소를 <video> 로 갈아 끼우고 진행 값 --p 와 시간 글자를 timeupdate 이벤트에서 채우면 됩니다. 바꾸는 것은 값을 어디서 받아 오느냐뿐입니다.
08번 전체화면 버튼은 왜 진짜 전체화면이 아닌가요?
이 페이지에 실린 데모가 iframe 안에서 돌기 때문입니다. iframe 은 allow="fullscreen" 을 받지 않으면 requestFullscreen() 요청이 조용히 거절되므로, 무대 안에서 transform: scale(1.3) 으로 같은 느낌만 흉내 냈습니다. 실제 페이지에 옮길 때는 Fullscreen API 를 부르고, 이 코드의 아이콘 교체와 aria-expanded 처리는 그대로 두면 됩니다.
숫자 하나로 여러 요소를 움직이는 방식이 성능에 불리하지 않나요?
--p 가 바뀔 때마다 그 값을 읽는 요소의 스타일이 다시 계산되는 것은 맞습니다. 다만 계산 결과가 들어가는 자리는 transform 과 opacity 뿐이라 레이아웃을 다시 재는 단계가 없고, 한 부품이 읽는 요소도 네 개 안쪽입니다.








