9 Custom Video Player CSS Controls, No Video File
A custom video player css setup replaces the browser's default control bar with your own markup and styles.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Play/pause morph button
- 02 Seek bar with time tooltip
- 03 Volume slider popover
- 04 Playback speed menu
- 05 Caption toggle and style
- 06 Chapter marker seek bar
- 07 Mini player (PiP-style)
- 08 Fullscreen toggle
- 09 Keyboard shortcut overlay
The order follows what a person actually does with a player, not what is quickest to build. You press play (01). You drag to a moment you remember (02). You set the volume (03), then the pace (04), then switch on captions (05). Chapters (06) cut that same bar into named parts once the video runs long enough to browse. The last three change the frame instead of the picture: the player shrinks into a corner card (07), swells to fill the stage (08), and finally puts up its own keyboard cheat sheet (09). Behind every one of them is a fake stage — moving stripes, a sun, two hills, and a counter — because nine embedded <video> files would have cost more bandwidth than the entire rest of this site. Every animation in this video player style css sticks to transform, opacity, and paint-only properties like color and border-radius, so nothing forces the browser into a fresh layout pass.
01Play/pause morph button
What this custom video controls css does
Press the button and the two upright bars spin out while a triangle spins in, and the clock beside them stops counting until you press it again. Two icons are stacked in the same box, each with its own rotated, shrunken resting pose, so only one is ever at full size.

<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="Pause">
<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;
}
}// 실제 동작: 버튼으로 Play/일시정지를 바꾸고 더미 Play 시계를 걷는다.
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 ? 'Pause' : 'Play');
}
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 · Play/pause morph button — Measured area 31.303% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
Across all 23 moving frames it changes 31.23% of the picture, which reads as one object turning rather than two images crossfading.
.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);
}
02Seek bar with time tooltip
What happens on screen
Hover anywhere along the bar and a tooltip follows the cursor with the timestamp for that spot, plus a scene tile whose pattern shifts as you move. The blue played span, the gray buffered span, the thumb, and the tooltip are all derived from one custom property called --p, so they cannot drift apart.

<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="Seek position" 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)에 그 값을 읽어 같은 프레임에 맞춘다.
// 따로 세면 시계와 Play 머리가 어긋난다 (실측 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 · Seek bar with time tooltip — Measured area 30.392% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Registering --p through @property as a <number> is what lets keyframes interpolate it instead of flipping at the midpoint.
@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)));
}
03Volume slider popover
What moves
Click the speaker and a slider springs out to its right; drag it and the level really moves, with the icon stepping down to a quiet speaker and then to a crossed-out one at zero. The icon swap uses steps(1, end) so it cuts in a single frame rather than dissolving through a blurry half-state.

<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="Volume control" 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="Volume" 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 · Volume slider popover — Measured area 31.274% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The popover is placed absolutely beside the button, which is the only reason the speaker stays pinned to the bottom-left corner whether the slider is open or closed.
.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; }
04Playback speed menu
What you see
The speed button opens a six-row list running from 0.5x to 2x, and the row you pick genuinely changes how fast the clock beside it counts. One tinted marker slides to the chosen row instead of six rows repainting, so exactly one element moves no matter how long the list grows.

<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="Playback speed">
<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.5x</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.75x</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">1x</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.25x</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.5x</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">2x</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="Playback speed">
<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">1x</span>
<span class="spd__alt spd__alt--fast" aria-hidden="true">1.5x</span>
<span class="spd__alt spd__alt--slow" aria-hidden="true">0.5x</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;
}
}// 실제 동작: 배속 메뉴를 여닫고, 고른 배속만큼 더미 Play 시계와 진행 바가 빨라지거나 느려진다.
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 · Playback speed menu — Measured area 42.843% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
At 42.84% changed area it is the busiest of the first six items, because the whole panel scales in and out while the marker and the progress bar keep moving underneath.
.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;
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;
}
05Caption toggle and style
What happens on screen
Each caption line rises from below as it takes over from the previous one, and a two-button picker swaps between a dark backing pill and outlined text with no pill at all. The CC button stores its state in aria-pressed, not only in a class name, so a screen reader announces the toggle the same way a sighted reader sees it.

<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">The video starts</span>
<span class="cap__l cap__l--2">Turn captions on</span>
<span class="cap__l cap__l--3">Pick a caption style</span>
<span class="cap__l cap__l--4">Everything is ready</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">Captions</span>
</button>
<div class="seg" id="seg" role="group" aria-label="Caption style">
<button class="seg__b seg__b--chip is-on" type="button" data-s="chip" aria-pressed="true">Pill</button>
<button class="seg__b seg__b--line" type="button" data-s="line" aria-pressed="false">Outline</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 버튼으로 자막을 켜고 끄고, 세그먼트로 Captions 스타일을 고른다. 더미 Play 시계가 문장을 갈아 끼운다.
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 · Caption toggle and style — Measured area 34.058% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
Notice the 21.99% and 22% pair in the keyframes below: that hard cut is a repair, and the section on what breaks explains why.
@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); }
}
06Chapter marker seek bar
What moves
The single bar breaks into one tick per chapter, each filling on its own, and the name above changes the instant the playhead crosses a boundary. Every tick is positioned by percentage through left and right, so trimming the visual gap with margin-right never moves where a boundary truly falls.

<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 · Intro</b>
<b class="nm__i">2 · Setup</b>
<b class="nm__i">3 · Example</b>
<b class="nm__i">4 · Recap</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="Seek position" aria-valuemin="0" aria-valuemax="180" aria-valuenow="8" aria-valuetext="Intro chapter, 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;
}
}// 실제 동작: chapter 눈금을 누르면 그 chapter 시작으로 건너뛰고, ←→ 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, 'Intro'], [24, 'Setup'], [72, 'Example'], [140, 'Recap']], 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] + ' chapter, ' + fmt(t));
return i;
}
function paint() {
ch.style.setProperty('--p', (t / DUR).toFixed(4));
nm.style.setProperty('--i', label());
}
// 자동 루프 중엔 CSS 가 --p 를 움직인다 — 글자는 그리기 직전(rAF)에 그 값을 읽어 같은 프레임에 맞춘다.
// setInterval 로 읽으면 프레임 사이에 값이 어긋나 시계가 Play 머리와 따로 논다 (실측 2026-09-12: 2:49 인데 머리는 Intro)
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 · Chapter marker seek bar — Measured area 29.851% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Clicking a tick jumps the playhead to the start of that chapter, and the arrow keys step five seconds at a time.
.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__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));
}
07Mini player (PiP-style)
What you see
The mini button shrinks the whole player into a small floating card at the bottom right and leaves a labeled placeholder where it used to sit. No second window opens: a single translate paired with scale performs the entire move, and the layout box never changes size.

<main class="stage">
<div class="pip is-demo" id="pip">
<div class="pip__stack">
<p class="pip__ph">Playing in mini player</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="Close mini player">
<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="Mini player" 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 으로 오른쪽 아래 조각 카드로 줄인다. Close·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 · Mini player (PiP-style) — Measured area 46.533% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
That is why 46.53% of the picture changes here without a single reflow, and why the close button or Escape can snap it straight back.
$mini-scale: .44;
$mini-x: -4px;
$mini-y: 40px;
.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; }
}
08Fullscreen toggle
What happens on screen
The expand button grows the player until it fills its stage, squares off the rounded corners on the way, and flips the icon to the compress arrows. Pressing it again or hitting Escape brings everything back.

<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">Press Esc to exit</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="Fullscreen">
<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 ? 'Exit fullscreen' : 'Fullscreen');
}
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 · Fullscreen toggle — Measured area 56.881% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
At 56.88% changed area this is the largest visual jump of the nine, and it is driven entirely by one scale factor plus a border-radius transition.
.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; }
09Keyboard shortcut overlay
What moves
The question-mark button dims the stage and floats a list of shortcuts over it, and Space, the left and right arrows, M, and F all genuinely work while it is open. Each key cap runs the same short keyframe, offset from its neighbors by a negative delay, which is why they light in sequence instead of together.

<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">Mute</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="Shortcut help" 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="Keyboard shortcuts">
<div class="ovl__panel">
<div class="ovl__head">
<p class="ovl__title">Shortcuts</p>
<button class="ovl__x" type="button" id="ovx" aria-label="Close">
<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">Play / Pause</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">Skip 5s</span></li>
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-m" data-k="m">M</kbd></span><span class="ovl__what">Mute</span></li>
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-f" data-k="f">F</kbd></span><span class="ovl__what">Fullscreen</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;
}
}// 실제 동작: ? 로 Shortcuts 다이얼로그를 열고 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 · Keyboard shortcut overlay — Measured area 43.807% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
With an intensity of 87.5 it is by far the liveliest of the nine, and it is also the only one that moves in 21 of 23 frames rather than all 23.
.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; 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); }
}
.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; }
Where it breaks — the trap
Three of these nine went out wrong on the first pass, and each failure came from the same root: a control that looks right standing still can still be wrong in motion.
The volume popover was the loudest failure. In its first build the speaker button and the popover were siblings in one flex row, which meant the popover reserved its full 152px of width even while closed. The speaker ended up floating near the middle of the frame instead of hugging the corner, and once opened, the popover lay across the "1:25 / 3:00" timecode. The repair was not a negative margin but taking the popover out of the flow completely with position: absolute; left: calc(100% + 4px), then moving the timecode over to the right edge. A closed popover now occupies no space at all, so the button sits exactly where a person expects to find it.
The seek bar broke more quietly. Its played span was written as a constant, transform: scaleX(.18), while only the thumb was wired to the animated value. Watching frame by frame, the playhead reached 54% of the track while the blue fill still sat at 20%, which is the sort of thing nobody consciously notices and everybody feels as cheapness. Deriving all four pieces from --p fixed it, but that alone was not enough: without registering the property, a custom property in keyframes does not interpolate at all. It holds its old value and then jumps to the new one halfway through, so the bar teleports. The @property rule at the top of that snippet is the whole difference between a bar that slides and a bar that snaps.
The caption swap produced the strangest bug of the set. The outgoing line faded out at 25% of the loop and the incoming line started rising at 25%, which looks perfectly reasonable in a code review. The capture takes 24 frames over 2 seconds, so samples land every 4.1667%, and the seventh sample lands on exactly 25.0% — the one instant when neither line was on screen. The recorded clip had a caption player with no caption in it. Narrowing the handoff to a 0.01% span, from 21.99% to 22%, means the opacity change can never be caught mid-flight, while the translateY entrance still glides. For the same family of reasons, the chapter clock in 06 had to stop counting with setInterval and start reading --p through getComputedStyle inside requestAnimationFrame, because a separately ticking timer once read 2:49 while the playhead sat in the first chapter. The one trap with no workaround is fullscreen: item 08 never calls requestFullscreen(), because an iframe silently refuses the request unless its host adds allow="fullscreen", so the demo fakes the move with transform: scale(1.3) inside its own stage and leaves the real API as a one-line swap for your page. The full source for all nine, including every keyframe trimmed out of the snippets above, is in an archive locked with 3ekbtjj6, and dropping the unpacked folder next to your markup is all the setup there is. One last measured note before you open it: a box grown with scale() leaves the layout alone but still swells the document's scroll height, even under overflow: hidden, which bit both 07 and 08 at a 320px-wide viewport until the stages were given explicit room.
Accessibility — what survives reduced-motion
Every one of the nine ships a @media (prefers-reduced-motion: reduce) block, and each block switches off decorative loops only. The panning stripes stop, the self-running demo stops, and the state values stay exactly as they were: pressed stays pressed, open stays open, the volume keeps its number, and the chapter keeps its name. A reader who has motion turned off still gets a working control, not a frozen picture of one.
The roles were chosen per part rather than sprinkled evenly. Items 02, 03, and 06 are marked with the ARIA slider role and carry aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext; it is that last one that makes 06 announce "Setup chapter, 0:24" instead of the bare number 24, which on its own would mean nothing. Items 05 and 07 use aria-pressed because they are toggles, while 01 swaps its aria-label between Play and Pause instead. Item 04 is a real menu: aria-haspopup="menu" and aria-expanded on the trigger, role="menuitemradio" with aria-checked on the rows. Item 08 uses aria-expanded, and 09 is a role="dialog" with aria-modal="true".
Nothing in the set needs a mouse: 02, 03, and 06 answer the arrow keys plus Home and End, 04 walks its rows with the arrow keys and commits with Enter or Space, 09 answers Space, the arrows, M, and F, and Escape closes whatever is open in 04, 07, 08, and 09. Every focus return in the set is written as focus({ preventScroll: true }), because these parts get embedded in iframes on a listing page and a plain focus() would yank the reader's scroll position down to whichever demo just took focus. More parts that carry moving pictures and sound live in the media category, and the ones that answer a press are gathered in the click category.
FAQ
Can I use this custom video player html css without any video file at all?
Yes, and that is what the demos are doing. The stage behind each control is CSS: a repeating gradient panning sideways for the picture, a circle for the sun, two rounded shapes for hills, and a counter for the timecode. Swap that block for a real <video> element and wire the same class toggles to its play(), pause(), currentTime, and volume, and the controls keep behaving identically.
Why build controls instead of using the browser's own?
The native control bar is competent and free, so use it when you only need play, seek, and volume. Build your own when the bar has to carry chapters, a speed menu, a caption-style picker, or your brand's shape and color, or when the control has to look identical in Chrome, Safari, and Firefox — which the native bars never will. The cost is that you inherit the whole job, keyboard and roles included.
How do I change the animation speed of all nine at once?
Each stylesheet declares two locals at the top, $duration and $easing, and both alias tokens from the shared file, which also supplies the $dur-loop every self-running loop runs on. Retime $dur-quick, $dur-base, and $dur-loop there and all nine follow; the only hardcoded milliseconds left anywhere are the negative animation-delay values that stagger the key caps in 09. The React versions in the archive take duration, easing, and color as props and pass them down as custom properties.








