video player css で組む カスタム動画プレーヤー 9選 — 動画ファイルなしで動く
video player css とは、mp4 を1本も置かずに、再生ボタン・シークバー・音量つまみといった操作まわりだけを HTML と CSS で組み立てるやり方です。ここに並ぶ9つは絵ではなく、押せばその場で状態が変わります。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 再生・一時停止モーフボタン
- 02 時間ツールチップ付きシークバー
- 03 音量スライダーポップオーバー
- 04 再生速度メニュー
- 05 字幕トグルと字幕スタイル
- 06 チャプターマーカーシークバー
- 07 ミニプレーヤー(PiP風)
- 08 フルスクリーントグル
- 09 キーボードショートカットのオーバーレイ
並びは人気順ではなく、動画を見る人が触る順番です。まず再生を押し(01)、どこまで進んだかを見て好きな地点へ飛び(02)、音を整えます(03)。次の3つは見方を変えるもので、速さ(04)・字幕(05)・章立て(06)と続きます。最後の3つは見る枠そのものを扱う操作で、小さく畳んで読みながら見る(07)、大きく広げる(08)、そしてマウスを使わない人のための一覧(09)です。9つとも動画ファイルは読み込まず、見えている動きは style video player css のキーフレームと数十行の素の JavaScript だけで作っています。収録は 480×300 の枠で2秒・12fps、合計24フレームを撮り、隣り合うフレームの間で何%の画素が変わったかを測りました。
| 項目 | 変化面積 | 強度 | 動いたフレーム |
|---|---|---|---|
| 01 再生・一時停止モーフ | 31.23% | 27.5 | 23/23 |
| 02 シークバー ツールチップ | 30.39% | 31.2 | 23/23 |
| 03 音量ポップオーバー | 31.22% | 33.3 | 23/23 |
| 04 再生速度メニュー | 42.84% | 39.1 | 23/23 |
| 05 字幕トグル | 34.06% | 34.5 | 23/23 |
| 06 チャプターマーカー | 29.85% | 33.6 | 23/23 |
| 07 ミニプレーヤー | 46.53% | 47.3 | 23/23 |
| 08 フルスクリーントグル | 56.88% | 29.9 | 23/23 |
| 09 ショートカット一覧 | 43.81% | 87.5 | 21/23 |
幅 320px でも9つともはみ出しは 0 でした。
01再生・一時停止モーフボタン
「html video player css」で何が見えるか
ボタンを押すと再生の三角が回りながら一時停止の2本バーへ入れ替わり、横の時計が実際に進みます。自動再生では状態が切り替わるたびに白い円がひとつ外へ広がり、自分で押したときはボタン自体が少し縮んで反応を返します。

<main class="stage">
<figure class="pl is-demo is-playing" id="pl">
<div class="pl__screen">
<div class="pl__stripes" aria-hidden="true"></div>
<div class="pl__world" aria-hidden="true"><span class="pl__sun"></span><span class="pl__hill pl__hill--a"></span><span class="pl__hill pl__hill--b"></span></div>
<span class="pl__clock" id="clock">0:12</span>
<span class="pl__badge">3:00</span>
<div class="pl__bar">
<button class="pp" type="button" id="pp" aria-label="一時停止">
<span class="pp__ring" aria-hidden="true"></span>
<span class="pp__ic pp__ic--play" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2"/><path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z"/></g></svg></span>
<span class="pp__ic pp__ic--pause" aria-hidden="true"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2"/><path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z"/></g></svg></span>
</button>
<span class="pl__time"><b id="t-now">0:12</b> / 3:00</span>
</div>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .32), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.pl__world { position: absolute; inset: 0; }
.pl__sun {
position: absolute; right: 18%; top: 14%;
width: 28px; height: 28px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .25);
}
.pl__hill { position: absolute; bottom: -46%; height: 82%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(23, 20, 26, .15); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(23, 20, 26, .09); }
.pl__clock {
position: absolute; left: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 16px; font-weight: 700;
}
.pl__badge {
position: absolute; right: $sp-2; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
color: rgba(255, 255, 255, .85);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 600;
}
.pl__bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2 calc(#{$sp-2} + 8px);
}
.pl__time {
color: $subject-ink; // 화면 아래쪽은 크림이라 흰 글자가 1.35~2.10:1 이었다 — 잉크로 8.69~13.49:1
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, .6);
}
.pl__time b { font-weight: 700; }
.pp {
position: relative;
box-sizing: border-box;
width: 38px; height: 38px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: 50%;
background: $color;
color: #fff;
cursor: pointer;
box-shadow: 0 6px 14px rgba(47, 109, 246, .45);
transition: transform $duration $easing;
}
.pp:hover { transform: scale(1.06); }
.pp:active { transform: scale(.94); }
.pp:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.pp__ic {
position: absolute; inset: 0;
display: inline-flex; align-items: center; justify-content: center;
transition: opacity $duration $easing, transform $duration $easing;
}
.pp__ic--pause { opacity: 0; transform: rotate(120deg) scale(.3); }
.pl.is-playing .pp__ic--play { opacity: 0; transform: rotate(-120deg) scale(.3); }
.pl.is-playing .pp__ic--pause { opacity: 1; transform: none; }
.pp__ring {
position: absolute; inset: 0;
border: 2px solid rgba(255, 255, 255, .8);
border-radius: 50%;
opacity: 0;
transform: scale(1);
}
.pl__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: rgba(255, 255, 255, .28); }
.pl__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.07);
}
// ── 자동 루프: 재생 중(일시정지 아이콘) → 멈춤(재생 아이콘) → 다시 재생. 원은 상태가 바뀌는 순간마다 한 번 퍼진다 ──
.pl.is-demo .pp__ic--play {
animation-name: pp-play; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.pl.is-demo .pp__ic--pause {
animation-name: pp-pause; animation-duration: $dur-loop;
animation-timing-function: $easing; animation-iteration-count: infinite;
}
.pl.is-demo .pp__ring {
animation-name: pp-ring; animation-duration: $dur-loop;
animation-timing-function: ease-out; animation-iteration-count: infinite;
}
.pl.is-demo .pl__fill {
animation-name: pp-fill; animation-duration: $dur-loop;
animation-timing-function: linear; animation-iteration-count: infinite;
}
@keyframes pp-play {
0%, 38% { opacity: 0; transform: rotate(-120deg) scale(.3); }
50%, 78% { opacity: 1; transform: none; }
90%, 100% { opacity: 0; transform: rotate(-120deg) scale(.3); }
}
@keyframes pp-pause {
0%, 38% { opacity: 1; transform: none; }
50%, 78% { opacity: 0; transform: rotate(120deg) scale(.3); }
90%, 100% { opacity: 1; transform: none; }
}
@keyframes pp-ring {
0%, 36% { opacity: 0; transform: scale(.9); }
42% { opacity: .9; }
56% { opacity: 0; transform: scale(1.9); }
76% { opacity: 0; transform: scale(.9); }
82% { opacity: .9; }
96%, 100% { opacity: 0; transform: scale(1.9); }
}
@keyframes pp-fill {
0% { transform: scaleX(.07); }
38% { transform: scaleX(.3); }
50%, 78% { transform: scaleX(.3); }
90%, 100% { transform: scaleX(.07); }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .pp__ic--play, .pl.is-demo .pp__ic--pause, .pl.is-demo .pp__ring,
.pl.is-demo .pl__fill, .pl__stripes { animation: none !important; }
.pp, .pp__ic { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.32), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.pl__world {
position: absolute;
inset: 0;
}
.pl__sun {
position: absolute;
right: 18%;
top: 14%;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.25);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 82%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(23, 20, 26, 0.15);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(23, 20, 26, 0.09);
}
.pl__clock {
position: absolute;
left: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 16px;
font-weight: 700;
}
.pl__badge {
position: absolute;
right: 8px;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
color: rgba(255, 255, 255, 0.85);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 600;
}
.pl__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px calc(8px + 8px);
}
.pl__time {
color: #17141a;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, 0.6);
}
.pl__time b {
font-weight: 700;
}
.pp {
position: relative;
box-sizing: border-box;
width: 38px;
height: 38px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
background: #2f6df6;
color: #fff;
cursor: pointer;
box-shadow: 0 6px 14px rgba(47, 109, 246, 0.45);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pp:hover {
transform: scale(1.06);
}
.pp:active {
transform: scale(0.94);
}
.pp:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pp__ic {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pp__ic--pause {
opacity: 0;
transform: rotate(120deg) scale(0.3);
}
.pl.is-playing .pp__ic--play {
opacity: 0;
transform: rotate(-120deg) scale(0.3);
}
.pl.is-playing .pp__ic--pause {
opacity: 1;
transform: none;
}
.pp__ring {
position: absolute;
inset: 0;
border: 2px solid rgba(255, 255, 255, 0.8);
border-radius: 50%;
opacity: 0;
transform: scale(1);
}
.pl__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: rgba(255, 255, 255, 0.28);
}
.pl__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.07);
}
.pl.is-demo .pp__ic--play {
animation-name: pp-play;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .pp__ic--pause {
animation-name: pp-pause;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .pp__ring {
animation-name: pp-ring;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.pl.is-demo .pl__fill {
animation-name: pp-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pp-play {
0%, 38% {
opacity: 0;
transform: rotate(-120deg) scale(0.3);
}
50%, 78% {
opacity: 1;
transform: none;
}
90%, 100% {
opacity: 0;
transform: rotate(-120deg) scale(0.3);
}
}
@keyframes pp-pause {
0%, 38% {
opacity: 1;
transform: none;
}
50%, 78% {
opacity: 0;
transform: rotate(120deg) scale(0.3);
}
90%, 100% {
opacity: 1;
transform: none;
}
}
@keyframes pp-ring {
0%, 36% {
opacity: 0;
transform: scale(0.9);
}
42% {
opacity: 0.9;
}
56% {
opacity: 0;
transform: scale(1.9);
}
76% {
opacity: 0;
transform: scale(0.9);
}
82% {
opacity: 0.9;
}
96%, 100% {
opacity: 0;
transform: scale(1.9);
}
}
@keyframes pp-fill {
0% {
transform: scaleX(0.07);
}
38% {
transform: scaleX(0.3);
}
50%, 78% {
transform: scaleX(0.3);
}
90%, 100% {
transform: scaleX(0.07);
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .pp__ic--play, .pl.is-demo .pp__ic--pause, .pl.is-demo .pp__ring,
.pl.is-demo .pl__fill, .pl__stripes {
animation: none !important;
}
.pp, .pp__ic {
transition: none;
}
}// 실제 동작: 버튼으로 再生/일시정지를 바꾸고 더미 再生 시계를 걷는다.
var root = document.getElementById('pl'),
btn = document.getElementById('pp'),
clock = document.getElementById('clock'),
now = document.getElementById('t-now'),
fill = document.getElementById('fill');
var DUR = 180, t = 12, playing = true, last = Date.now();
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function paint() {
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}
setInterval(function () {
var n = Date.now();
if (playing) t = (t + (n - last) / 1000) % DUR;
last = n;
paint();
}, 250);
function toggle() {
root.classList.remove('is-demo');
playing = !playing;
root.classList.toggle('is-playing', playing);
btn.setAttribute('aria-label', playing ? '一時停止' : '再生');
}
btn.addEventListener('click', toggle);import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PlayPauseMorph.module.scss";
export type PlayPauseMorphProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function PlayIcon() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M228.23 134.69L84.15 222.81A8 8 0 0 1 72 216.12V39.88a8 8 0 0 1 12.15-6.69l144.08 88.12a7.82 7.82 0 0 1 0 13.38" opacity=".2" />
<path d="M232.4 114.49L88.32 26.35a16 16 0 0 0-16.2-.3A15.86 15.86 0 0 0 64 39.87v176.26A15.94 15.94 0 0 0 80 232a16.07 16.07 0 0 0 8.36-2.35l144.04-88.14a15.81 15.81 0 0 0 0-27ZM80 215.94V40l143.83 88Z" />
</g>
</svg>
);
}
function PauseIcon() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M208 48v160a8 8 0 0 1-8 8h-40a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8M96 40H56a8 8 0 0 0-8 8v160a8 8 0 0 0 8 8h40a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" opacity=".2" />
<path d="M200 32h-40a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176h-40V48h40ZM96 32H56a16 16 0 0 0-16 16v160a16 16 0 0 0 16 16h40a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16m0 176H56V48h40Z" />
</g>
</svg>
);
}
export default function PlayPauseMorph({
total = 180,
start = 12,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: PlayPauseMorphProps) {
const [playing, setPlaying] = useState(true);
const [t, setT] = useState(start);
const [pulse, setPulse] = useState(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 setInterval(250ms) 를 그대로
useEffect(() => {
if (!playing) return;
let last = Date.now();
const id = setInterval(() => {
const n = Date.now();
const step = (n - last) / 1000;
last = n;
setT((p) => (p + step) % total);
}, 250);
return () => clearInterval(id);
}, [playing, total]);
function toggle() {
setPlaying((p) => !p);
setPulse((p) => p + 1);
}
return (
<figure className={`${styles.player} ${playing ? styles.isPlaying : ""}`} style={vars}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<div className={styles.bar}>
<button
className={styles.pp}
type="button"
aria-label={playing ? "일시정지" : "재생"}
onClick={toggle}
>
{pulse > 0 ? <span key={pulse} className={styles.ring} aria-hidden="true" /> : null}
<span className={`${styles.ic} ${styles.icPlay}`}>
<PlayIcon />
</span>
<span className={`${styles.ic} ${styles.icPause}`}>
<PauseIcon />
</span>
</button>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
</figure>
);
}01 · 再生・一時停止モーフボタン — 計測 累積面積 31.303% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
入れ替えの正体は、同じ場所に重ねた2つのアイコンへ rotate と scale を反対向きに当てているだけです。
.pp__ic {
position: absolute; inset: 0;
display: inline-flex; align-items: center; justify-content: center;
transition: opacity $duration $easing, transform $duration $easing;
}
.pp__ic--pause { opacity: 0; transform: rotate(120deg) scale(.3); }
.pl.is-playing .pp__ic--play { opacity: 0; transform: rotate(-120deg) scale(.3); }
.pl.is-playing .pp__ic--pause { opacity: 1; transform: none; }
02時間ツールチップ付きシークバー
画面で起きること
マウスを乗せた位置に時間のふきだしが追いかけてきて、再生済みの区間が青く塗られ、その先に読み込み済みのバッファが薄く敷かれます。ふきだしの中の小さなプレビュー枠も、乗せた位置に合わせて模様がずれます。

<main class="stage">
<figure class="pl is-demo" id="pl">
<div class="pl__screen">
<div class="pl__stripes" aria-hidden="true"></div>
<div class="pl__world" aria-hidden="true"><span class="pl__sun"></span><span class="pl__hill pl__hill--a"></span><span class="pl__hill pl__hill--b"></span></div>
<span class="pl__clock" id="clock">0:32</span>
<span class="pl__badge">3:00</span>
</div>
<div class="sk__row">
<span class="sk__t"><b id="t-now">0:32</b> / 3:00</span>
<div class="sk is-hover" id="sk" role="slider" tabindex="0" aria-label="再生位置" aria-valuemin="0" aria-valuemax="180" aria-valuenow="32" aria-valuetext="0:32 / 3:00">
<span class="sk__track" aria-hidden="true">
<span class="sk__buf" id="buf"></span>
<span class="sk__fill"></span>
<span class="sk__thumbwrap"><span class="sk__thumb"></span></span>
</span>
<span class="sk__tip" aria-hidden="true">
<span class="sk__prev"><span class="sk__pat"></span></span>
<b class="sk__tt" id="tt">0:32</b>
</span>
</div>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// --p 는 등록된 사용자 지정 속성 — 키프레임에서 보간된다 (미등록이면 steps 처럼 튄다)
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.18; }
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(84px, 22vw, 116px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute; inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .13) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.pl__world { position: absolute; inset: 0; }
.pl__sun {
position: absolute; right: 18%; top: 12%;
width: 24px; height: 24px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .25);
}
.pl__hill { position: absolute; bottom: -46%; height: 82%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(23, 20, 26, .15); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(23, 20, 26, .09); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
.sk__row {
display: flex; align-items: center; gap: $sp-3;
padding: $sp-3;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, .72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
}
.sk {
--p: 0.18;
--h: 0.18;
position: relative;
flex: 1 1 auto;
height: 44px;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-sm; }
.sk__track {
position: absolute; left: 0; right: 0; top: 50%;
height: 10px;
margin-top: -5px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .14);
}
.sk__buf, .sk__fill {
position: absolute; inset: 0;
border-radius: $r-pill;
transform-origin: left center; // 규칙8: 좌변서 차오름
}
// 채움과 버퍼는 둘 다 --p 하나에서 나온다. 채움을 고정값(scaleX(.18))으로 두면 재생 머리만 가고
// 파란 구간은 제자리에 남는다 (실측 2026-09-12: 머리 54% · 채움 20%)
.sk__buf { background: rgba(23, 20, 26, .16); transform: scaleX(min(1, calc(var(--p) + .18))); }
.sk__fill { background: $color; transform: scaleX(var(--p)); }
.sk__thumbwrap {
position: absolute; left: 0; right: 0; top: 0; bottom: 0;
pointer-events: none;
transform: translateX(calc((100% - 14px) * var(--p)));
}
.sk__thumb {
position: absolute; left: 0; top: 50%;
width: 14px; height: 14px;
margin-top: -7px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(23, 20, 26, .4);
}
.sk__tip {
position: absolute; bottom: 34px; left: 0;
width: 76px;
padding: $sp-1;
border-radius: $r-sm;
background: $subject-ink;
box-shadow: $shadow-press;
opacity: 0;
transform: translateX(calc((100% - 76px) * var(--h, var(--p))));
transition: opacity $duration $easing;
pointer-events: none;
}
.sk.is-hover .sk__tip, .sk:hover .sk__tip, .sk:focus-visible .sk__tip { opacity: 1; }
.sk__prev {
position: relative;
display: block;
height: 26px;
border-radius: $r-xs;
overflow: hidden;
background: linear-gradient(180deg, rgba(111, 169, 255, .5), rgba(255, 247, 230, .9));
}
.sk__pat {
position: absolute; inset: 0 -80px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .16) 0 10px, transparent 10px 40px);
transform: translateX(calc(-40px + var(--h, var(--p)) * -80px));
}
.sk__tt {
display: block;
padding-top: $sp-1;
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 12px; font-weight: 700;
text-align: center;
}
// ── 자동 루프: 툴팁이 바를 쓸며 시간을 재고 되돌아온다. --p 하나가 채움·엄지·툴팁·무늬를 함께 움직인다 ──
.pl.is-demo .sk {
--h: var(--p);
animation-name: sk-p;
animation-duration: $dur-loop;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes sk-p {
0% { --p: 0.18; }
78% { --p: 0.92; }
88% { --p: 0.92; }
100% { --p: 0.18; }
}
.pl.is-demo .sk__tip { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .sk, .pl__stripes { animation: none !important; }
.sk__tip { transition: none; }
}@property --p {
syntax: "<number>";
inherits: true;
initial-value: 0.18;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(84px, 22vw, 116px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.13) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.pl__world {
position: absolute;
inset: 0;
}
.pl__sun {
position: absolute;
right: 18%;
top: 12%;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.25);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 82%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(23, 20, 26, 0.15);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(23, 20, 26, 0.09);
}
.pl__clock, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.sk__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, 0.72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
}
.sk {
--p: 0.18;
--h: 0.18;
position: relative;
flex: 1 1 auto;
height: 44px;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 8px;
}
.sk__track {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 10px;
margin-top: -5px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.14);
}
.sk__buf, .sk__fill {
position: absolute;
inset: 0;
border-radius: 999px;
transform-origin: left center;
}
.sk__buf {
background: rgba(23, 20, 26, 0.16);
transform: scaleX(min(1, var(--p) + 0.18));
}
.sk__fill {
background: #2f6df6;
transform: scaleX(var(--p));
}
.sk__thumbwrap {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
pointer-events: none;
transform: translateX(calc((100% - 14px) * var(--p)));
}
.sk__thumb {
position: absolute;
left: 0;
top: 50%;
width: 14px;
height: 14px;
margin-top: -7px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.4);
}
.sk__tip {
position: absolute;
bottom: 34px;
left: 0;
width: 76px;
padding: 4px;
border-radius: 8px;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateX(calc((100% - 76px) * var(--h, var(--p))));
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
.sk.is-hover .sk__tip, .sk:hover .sk__tip, .sk:focus-visible .sk__tip {
opacity: 1;
}
.sk__prev {
position: relative;
display: block;
height: 26px;
border-radius: 4px;
overflow: hidden;
background: linear-gradient(180deg, rgba(111, 169, 255, 0.5), rgba(255, 247, 230, 0.9));
}
.sk__pat {
position: absolute;
inset: 0 -80px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.16) 0 10px, transparent 10px 40px);
transform: translateX(calc(-40px + var(--h, var(--p)) * -80px));
}
.sk__tt {
display: block;
padding-top: 4px;
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 12px;
font-weight: 700;
text-align: center;
}
.pl.is-demo .sk {
--h: var(--p);
animation-name: sk-p;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
@keyframes sk-p {
0% {
--p: 0.18;
}
78% {
--p: 0.92;
}
88% {
--p: 0.92;
}
100% {
--p: 0.18;
}
}
.pl.is-demo .sk__tip {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .sk, .pl__stripes {
animation: none !important;
}
.sk__tip {
transition: none;
}
}// 실제 동작: 마우스를 올리면 그 지점 시간을 보여 주고, 누르거나 끌면 그곳으로 건너뛴다.
var root = document.getElementById('pl'),
sk = document.getElementById('sk'),
tt = document.getElementById('tt'),
now = document.getElementById('t-now'),
clock = document.getElementById('clock');
var DUR = 180, t = 32;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function paint() {
var p = t / DUR;
sk.style.setProperty('--p', p.toFixed(4));
sk.setAttribute('aria-valuenow', Math.floor(t));
sk.setAttribute('aria-valuetext', fmt(t) + ' / 3:00');
now.textContent = fmt(t);
clock.textContent = fmt(t);
tt.textContent = fmt(p * DUR);
}
// 자동 루프 중엔 CSS 가 --p 를 움직이므로, 글자는 그리기 직전(rAF)에 그 값을 읽어 같은 프레임에 맞춘다.
// 따로 세면 시계와 再生 머리가 어긋난다 (실측 2026-09-12, 06 에서 같은 병)
var prev = 0;
requestAnimationFrame(function tick(ms) {
if (root.classList.contains('is-demo')) {
var v = parseFloat(getComputedStyle(sk).getPropertyValue('--p'));
if (v === v) {
t = v * DUR;
sk.setAttribute('aria-valuenow', Math.floor(t));
sk.setAttribute('aria-valuetext', fmt(t) + ' / 3:00');
now.textContent = fmt(t);
clock.textContent = fmt(t);
tt.textContent = fmt(t);
}
} else if (ms - prev > 500) {
prev = ms; t = (t + .5) % DUR; paint();
}
requestAnimationFrame(tick);
});
function pos(e) {
var r = sk.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
}
sk.addEventListener('mousemove', function (e) {
root.classList.remove('is-demo');
var p = pos(e);
tt.textContent = fmt(p * DUR);
sk.style.setProperty('--h', p.toFixed(4));
});
sk.addEventListener('mouseleave', function () { sk.style.setProperty('--h', ''); });
function seek(e) { root.classList.remove('is-demo'); t = pos(e) * DUR; paint(); }
sk.addEventListener('pointerdown', function (e) { sk.setPointerCapture(e.pointerId); seek(e); });
sk.addEventListener('pointermove', function (e) { if (e.buttons) seek(e); });
sk.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { t = Math.min(DUR, t + 5); paint(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { t = Math.max(0, t - 5); paint(); e.preventDefault(); }
else if (e.key === 'Home') { t = 0; paint(); e.preventDefault(); }
else if (e.key === 'End') { t = DUR - 1; paint(); e.preventDefault(); }
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SeekBarTooltip.module.scss";
export type SeekBarTooltipProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SeekVars = CSSProperties & { "--p"?: string; "--h"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type Rect = { left: number; width: number };
type MoveEvt = { clientX: number; currentTarget: { getBoundingClientRect: () => Rect } };
type PtrEvt = MoveEvt & {
buttons: number;
pointerId: number;
currentTarget: { getBoundingClientRect: () => Rect; setPointerCapture: (id: number) => void };
};
type KeyEvt = { key: string; preventDefault: () => void };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function pos(e: MoveEvt) {
const r = e.currentTarget.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
}
export default function SeekBarTooltip({
total = 180,
start = 32,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SeekBarTooltipProps) {
const [t, setT] = useState(start);
const [hover, setHover] = useState(-1);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 setInterval(500ms, +0.5초) 을 그대로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.5) % total), 500);
return () => clearInterval(id);
}, [total]);
const p = t / total;
const h = hover >= 0 ? hover : p;
const seekVars: SeekVars = { "--p": p.toFixed(4), "--h": h.toFixed(4) };
function seek(e: MoveEvt) {
setT(pos(e) * total);
}
return (
<figure className={styles.player} style={vars}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
</div>
<div className={styles.row}>
<span className={styles.rowTime}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<div
className={`${styles.seek} ${hover >= 0 ? styles.isHover : ""}`}
style={seekVars}
role="slider"
tabIndex={0}
aria-label="재생 위치"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={Math.floor(t)}
aria-valuetext={`${fmt(t)} / ${fmt(total)}`}
onMouseMove={(e: MoveEvt) => setHover(pos(e))}
onMouseLeave={() => setHover(-1)}
onPointerDown={(e: PtrEvt) => {
e.currentTarget.setPointerCapture(e.pointerId);
seek(e);
}}
onPointerMove={(e: PtrEvt) => {
if (e.buttons) seek(e);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowRight") { setT((v) => Math.min(total, v + 5)); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setT((v) => Math.max(0, v - 5)); e.preventDefault(); }
else if (e.key === "Home") { setT(0); e.preventDefault(); }
else if (e.key === "End") { setT(total - 1); e.preventDefault(); }
}}
>
<span className={styles.track} aria-hidden="true">
<span className={styles.buf} style={{ transform: `scaleX(${Math.min(1, p + 0.18).toFixed(4)})` }} />
<span className={styles.fill} style={{ transform: `scaleX(${p.toFixed(4)})` }} />
<span className={styles.thumbwrap}>
<span className={styles.thumb} />
</span>
</span>
<span className={styles.tip} aria-hidden="true">
<span className={styles.prev}>
<span className={styles.pat} />
</span>
<b className={styles.tt}>{fmt(h * total)}</b>
</span>
</div>
</div>
</figure>
);
}02 · 時間ツールチップ付きシークバー — 計測 累積面積 30.392% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
動く部品は4つありますが、もとになる値は --p という数ひとつだけです。
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.18; }
.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 { transform: translateX(calc((100% - 14px) * var(--p))); }
.sk__tip { transform: translateX(calc((100% - 76px) * var(--h, var(--p)))); }
@keyframes sk-p {
0% { --p: 0.18; }
78% { --p: 0.92; }
88% { --p: 0.92; }
100% { --p: 0.18; }
}
03音量スライダーポップオーバー
どんな動きか
スピーカーボタンを押すと、その右横からスライダーが飛び出し、つまみを動かすと音量が実際に上下します。音量が下がるとアイコンは小さいスピーカーへ、0 でミュートへ替わり、この交代はフェードさせず steps(1, end) で1フレームのうちに差し替えます。

<main class="stage">
<figure class="pl is-demo" id="pl">
<div class="pl__screen">
<div class="pl__stripes" aria-hidden="true"></div>
<div class="pl__world" aria-hidden="true"><span class="pl__sun"></span><span class="pl__hill pl__hill--a"></span><span class="pl__hill pl__hill--b"></span></div>
<span class="pl__clock" id="clock">1:24</span>
<span class="pl__badge">3:00</span>
<div class="vol">
<button class="vol__btn" type="button" id="vbtn" aria-label="音量調節" aria-expanded="true" aria-controls="pop">
<span class="vol__ic vol__ic--high" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128"/></g></svg></span>
<span class="vol__ic vol__ic--low" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55ZM208 128a39.93 39.93 0 0 1-10 26.46a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 1 1 12-10.58A40 40 0 0 1 208 128"/></g></svg></span>
<span class="vol__ic vol__ic--mute" aria-hidden="true"><svg viewBox="0 0 256 256" width="18" height="18"><g fill="currentColor"><path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2"/><path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.57V91.94l56-43.58Zm101.66-61.3a8 8 0 0 1-11.32 11.32L216 139.31l-18.34 18.35a8 8 0 0 1-11.32-11.32L204.69 128l-18.35-18.34a8 8 0 0 1 11.32-11.32L216 116.69l18.34-18.35a8 8 0 0 1 11.32 11.32L227.31 128Z"/></g></svg></span>
</button>
<div class="vol__pop" id="pop">
<div class="vol__slider" id="vs" role="slider" tabindex="0" aria-label="音量" aria-valuemin="0" aria-valuemax="100" aria-valuenow="80" aria-valuetext="80%">
<span class="vol__track" aria-hidden="true">
<span class="vol__fill"></span>
<span class="vol__thumbwrap"><span class="vol__thumb"></span></span>
</span>
</div>
<span class="vol__pct" id="pct">80</span>
</div>
</div>
<span class="pl__time"><b id="t-now">1:24</b> / 3:00</span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
@property --v { syntax: "<number>"; inherits: true; initial-value: 0.8; }
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-ink;
@include stage($stage-orange);
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute; inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .13) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.pl__world { position: absolute; inset: 0; }
.pl__sun {
position: absolute; right: 20%; top: 14%;
width: 26px; height: 26px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .25);
}
.pl__hill { position: absolute; bottom: -46%; height: 82%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(23, 20, 26, .15); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(23, 20, 26, .09); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
.pl__time {
position: absolute; right: $sp-2; bottom: $sp-2;
color: $subject-ink; // 화면 아래쪽은 크림이라 흰 글자가 1.35~2.10:1 이었다 — 잉크로 8.69~13.49:1
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, .6);
}
.vol {
position: absolute; left: $sp-2; bottom: $sp-2;
display: flex; align-items: flex-end; gap: $sp-1;
}
.vol__pop {
// 팝오버는 버튼 옆으로 절대 배치한다 — 흐름에 두면 닫혀 있을 때도 152px 자리를 잡아 버튼이 화면 한가운데로 밀린다
position: absolute; left: calc(100% + #{$sp-1}); bottom: 0;
display: flex; align-items: center; gap: $sp-2;
box-sizing: border-box;
width: 152px; height: 36px;
padding: 0 $sp-2;
border-radius: $r-control;
background: rgba(23, 20, 26, .88);
transform: translateX(-6px) scale(.6);
transform-origin: left bottom; // 규칙8: 버튼 옆 변서 펼침
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.pl.is-open .vol__pop { transform: none; opacity: 1; pointer-events: auto; }
.vol__slider {
--v: 0.8;
position: relative;
flex: 1 1 auto;
height: 32px;
cursor: pointer;
touch-action: none;
}
.vol__slider:focus-visible { outline: 2px solid $stage-yellow; outline-offset: -2px; border-radius: $r-sm; }
.vol__track {
position: absolute; left: 0; right: 0; top: 50%;
height: 6px;
margin-top: -3px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .22);
}
.vol__fill {
position: absolute; inset: 0;
border-radius: $r-pill;
background: rgba(255, 210, 63, .95);
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(var(--v));
}
.vol__thumbwrap {
position: absolute; inset: 0;
pointer-events: none;
transform: translateX(calc((100% - 12px) * var(--v)));
}
.vol__thumb {
position: absolute; left: 0; top: 50%;
width: 12px; height: 12px;
margin-top: -6px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(23, 20, 26, .5);
}
.vol__pct {
flex: 0 0 auto;
min-width: 20px;
color: rgba(255, 255, 255, .9);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 12px; font-weight: 700;
text-align: right;
}
.vol__btn {
position: relative;
box-sizing: border-box;
width: 36px; height: 36px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: $r-control;
background: rgba(23, 20, 26, .72);
color: #fff;
cursor: pointer;
transition: background $duration $easing;
}
.vol__btn:hover { background: rgba(23, 20, 26, .9); }
.vol__btn:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.vol__ic { position: absolute; display: inline-flex; }
.vol__ic--low, .vol__ic--mute { display: none; }
.pl.is-low .vol__ic--high { display: none; }
.pl.is-low .vol__ic--low { display: inline-flex; }
.pl.is-mute .vol__ic--high, .pl.is-low .vol__ic--low { display: none; }
.pl.is-mute .vol__ic--mute { display: inline-flex; }
// ── 자동 루프: 팝오버가 열리고 음량이 내려가 0(뮤트)을 지나 다시 오른다. 아이콘 교체는 한 프레임 컷 ──
.pl.is-demo .vol__pop {
animation-name: vol-pop;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pl.is-demo .vol__slider {
animation-name: vol-v;
animation-duration: $dur-loop;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--high {
animation-name: vol-i-high;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--low {
display: inline-flex;
animation-name: vol-i-low;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--mute {
display: inline-flex;
animation-name: vol-i-mute;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes vol-pop {
0%, 6% { opacity: 0; transform: translateX(-6px) scale(.6); }
16%, 80% { opacity: 1; transform: none; }
88%, 100% { opacity: 0; transform: translateX(-6px) scale(.6); }
}
@keyframes vol-v {
0%, 8% { --v: 0.8; }
40% { --v: 0.22; }
50%, 58% { --v: 0; }
68% { --v: 0.3; }
80%, 100% { --v: 0.8; }
}
@keyframes vol-i-high {
0%, 36% { opacity: 1; }
37%, 63% { opacity: 0; }
64%, 100% { opacity: 1; }
}
@keyframes vol-i-low {
0%, 36% { opacity: 0; }
37%, 44% { opacity: 1; }
45%, 66% { opacity: 0; }
67%, 74% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes vol-i-mute {
0%, 44% { opacity: 0; }
45%, 58% { opacity: 1; }
59%, 100% { opacity: 0; }
}
.pl.is-demo .vol__pct {
animation-name: vol-pct;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes vol-pct {
0%, 8% { opacity: 1; }
40% { opacity: 1; }
50%, 58% { opacity: 1; }
68% { opacity: 1; }
80%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .vol__pop, .pl.is-demo .vol__slider, .pl.is-demo .vol__ic--high,
.pl.is-demo .vol__ic--low, .pl.is-demo .vol__ic--mute, .pl.is-demo .vol__pct,
.pl__stripes { animation: none !important; }
.vol__pop { transition: none; }
}@property --v {
syntax: "<number>";
inherits: true;
initial-value: 0.8;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.13) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.pl__world {
position: absolute;
inset: 0;
}
.pl__sun {
position: absolute;
right: 20%;
top: 14%;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.25);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 82%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(23, 20, 26, 0.15);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(23, 20, 26, 0.09);
}
.pl__clock, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.pl__time {
position: absolute;
right: 8px;
bottom: 8px;
color: #17141a;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, 0.6);
}
.vol {
position: absolute;
left: 8px;
bottom: 8px;
display: flex;
align-items: flex-end;
gap: 4px;
}
.vol__pop {
position: absolute;
left: calc(100% + 4px);
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 152px;
height: 36px;
padding: 0 8px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.88);
transform: translateX(-6px) scale(0.6);
transform-origin: left bottom;
opacity: 0;
pointer-events: none;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pl.is-open .vol__pop {
transform: none;
opacity: 1;
pointer-events: auto;
}
.vol__slider {
--v: 0.8;
position: relative;
flex: 1 1 auto;
height: 32px;
cursor: pointer;
touch-action: none;
}
.vol__slider:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: -2px;
border-radius: 8px;
}
.vol__track {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 6px;
margin-top: -3px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
}
.vol__fill {
position: absolute;
inset: 0;
border-radius: 999px;
background: rgba(255, 210, 63, 0.95);
transform-origin: left center;
transform: scaleX(var(--v));
}
.vol__thumbwrap {
position: absolute;
inset: 0;
pointer-events: none;
transform: translateX(calc((100% - 12px) * var(--v)));
}
.vol__thumb {
position: absolute;
left: 0;
top: 50%;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: #fff;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.5);
}
.vol__pct {
flex: 0 0 auto;
min-width: 20px;
color: rgba(255, 255, 255, 0.9);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 12px;
font-weight: 700;
text-align: right;
}
.vol__btn {
position: relative;
box-sizing: border-box;
width: 36px;
height: 36px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.72);
color: #fff;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.vol__btn:hover {
background: rgba(23, 20, 26, 0.9);
}
.vol__btn:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.vol__ic {
position: absolute;
display: inline-flex;
}
.vol__ic--low, .vol__ic--mute {
display: none;
}
.pl.is-low .vol__ic--high {
display: none;
}
.pl.is-low .vol__ic--low {
display: inline-flex;
}
.pl.is-mute .vol__ic--high, .pl.is-low .vol__ic--low {
display: none;
}
.pl.is-mute .vol__ic--mute {
display: inline-flex;
}
.pl.is-demo .vol__pop {
animation-name: vol-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__slider {
animation-name: vol-v;
animation-duration: 2s;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--high {
animation-name: vol-i-high;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--low {
display: inline-flex;
animation-name: vol-i-low;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .vol__ic--mute {
display: inline-flex;
animation-name: vol-i-mute;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes vol-pop {
0%, 6% {
opacity: 0;
transform: translateX(-6px) scale(0.6);
}
16%, 80% {
opacity: 1;
transform: none;
}
88%, 100% {
opacity: 0;
transform: translateX(-6px) scale(0.6);
}
}
@keyframes vol-v {
0%, 8% {
--v: 0.8;
}
40% {
--v: 0.22;
}
50%, 58% {
--v: 0;
}
68% {
--v: 0.3;
}
80%, 100% {
--v: 0.8;
}
}
@keyframes vol-i-high {
0%, 36% {
opacity: 1;
}
37%, 63% {
opacity: 0;
}
64%, 100% {
opacity: 1;
}
}
@keyframes vol-i-low {
0%, 36% {
opacity: 0;
}
37%, 44% {
opacity: 1;
}
45%, 66% {
opacity: 0;
}
67%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
@keyframes vol-i-mute {
0%, 44% {
opacity: 0;
}
45%, 58% {
opacity: 1;
}
59%, 100% {
opacity: 0;
}
}
.pl.is-demo .vol__pct {
animation-name: vol-pct;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes vol-pct {
0%, 8% {
opacity: 1;
}
40% {
opacity: 1;
}
50%, 58% {
opacity: 1;
}
68% {
opacity: 1;
}
80%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .vol__pop, .pl.is-demo .vol__slider, .pl.is-demo .vol__ic--high,
.pl.is-demo .vol__ic--low, .pl.is-demo .vol__ic--mute, .pl.is-demo .vol__pct,
.pl__stripes {
animation: none !important;
}
.vol__pop {
transition: none;
}
}// 실제 동작: 버튼으로 슬라이더를 여닫고, 끌거나 화살표로 음량을 바꾸면 아이콘이 바뀐다.
var root = document.getElementById('pl'),
btn = document.getElementById('vbtn'),
pop = document.getElementById('pop'),
vs = document.getElementById('vs'),
pct = document.getElementById('pct'),
clock = document.getElementById('clock'),
now = document.getElementById('t-now');
var DUR = 180, t = 84, v = 0.8;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
setInterval(function () {
t = (t + .5) % DUR;
clock.textContent = fmt(t);
now.textContent = fmt(t);
}, 500);
function paint() {
vs.style.setProperty('--v', v.toFixed(3));
vs.setAttribute('aria-valuenow', Math.round(v * 100));
vs.setAttribute('aria-valuetext', Math.round(v * 100) + '%');
pct.textContent = Math.round(v * 100);
root.classList.toggle('is-mute', v === 0);
root.classList.toggle('is-low', v > 0 && v < 0.5);
}
function open(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-open', on);
btn.setAttribute('aria-expanded', String(on));
if (on) vs.focus({ preventScroll: true });
}
btn.addEventListener('click', function () { open(!root.classList.contains('is-open')); });
function pos(e) {
var r = vs.getBoundingClientRect();
return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
}
function set(p) { v = Math.round(p * 20) / 20; paint(); }
vs.addEventListener('pointerdown', function (e) { vs.setPointerCapture(e.pointerId); root.classList.remove('is-demo'); set(pos(e)); });
vs.addEventListener('pointermove', function (e) { if (e.buttons) set(pos(e)); });
vs.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { v = Math.min(1, v + .1); paint(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { v = Math.max(0, v - .1); paint(); e.preventDefault(); }
else if (e.key === 'Home') { v = 0; paint(); e.preventDefault(); }
else if (e.key === 'End') { v = 1; paint(); e.preventDefault(); }
});
document.addEventListener('click', function (e) { if (!e.target.closest('.vol')) open(false); });import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./VolumePopover.module.scss";
export type VolumePopoverProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
/** 시작 음량 0~1 */
initialVolume?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SliderVars = CSSProperties & { "--v"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type Rect = { left: number; width: number };
type PtrEvt = {
clientX: number;
buttons: number;
pointerId: number;
currentTarget: { getBoundingClientRect: () => Rect; setPointerCapture: (id: number) => void };
};
type KeyEvt = { key: string; preventDefault: () => void };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function HighIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2" />
<path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55Zm54-106.08a40 40 0 0 1 0 52.88a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 0 1 12-10.58M248 128a79.9 79.9 0 0 1-20.37 53.34a8 8 0 0 1-11.92-10.67a64 64 0 0 0 0-85.33a8 8 0 1 1 11.92-10.67A79.83 79.83 0 0 1 248 128" />
</g>
</svg>
);
}
function LowIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2" />
<path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.55V91.91l56-43.55ZM208 128a39.93 39.93 0 0 1-10 26.46a8 8 0 0 1-12-10.58a24 24 0 0 0 0-31.72a8 8 0 1 1 12-10.58A40 40 0 0 1 208 128" />
</g>
</svg>
);
}
function MuteIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M80 88v80H32a8 8 0 0 1-8-8V96a8 8 0 0 1 8-8Z" opacity=".2" />
<path d="M155.51 24.81a8 8 0 0 0-8.42.88L77.25 80H32a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h45.25l69.84 54.31A8 8 0 0 0 160 224V32a8 8 0 0 0-4.49-7.19M32 96h40v64H32Zm112 111.64l-56-43.57V91.94l56-43.58Zm101.66-61.3a8 8 0 0 1-11.32 11.32L216 139.31l-18.34 18.35a8 8 0 0 1-11.32-11.32L204.69 128l-18.35-18.34a8 8 0 0 1 11.32-11.32L216 116.69l18.34-18.35a8 8 0 0 1 11.32 11.32L227.31 128Z" />
</g>
</svg>
);
}
export default function VolumePopover({
total = 180,
start = 84,
initialVolume = 0.8,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffd23f",
}: VolumePopoverProps) {
const [open, setOpen] = useState(false);
const [v, setV] = useState(initialVolume);
const [t, setT] = useState(start);
const volRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const sliderVars: SliderVars = { "--v": v.toFixed(3) };
// 더미 재생 시계 — 바닐라의 setInterval(500ms, +0.5초) 을 그대로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.5) % total), 500);
return () => clearInterval(id);
}, [total]);
// 바깥을 누르면 닫는다
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (volRef.current && !volRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("click", onDocClick);
return () => document.removeEventListener("click", onDocClick);
}, []);
useEffect(() => {
if (open) sliderRef.current?.focus({ preventScroll: true });
}, [open]);
function set(e: PtrEvt) {
const r = e.currentTarget.getBoundingClientRect();
const p = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
setV(Math.round(p * 20) / 20);
}
const pct = Math.round(v * 100);
const state = v === 0 ? styles.isMute : v < 0.5 ? styles.isLow : "";
return (
<figure className={`${styles.player} ${open ? styles.isOpen : ""} ${state}`} style={vars}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<div className={styles.vol} ref={volRef}>
<button
className={styles.btn}
type="button"
aria-label="음량 조절"
aria-expanded={open}
aria-controls="vol-pop"
onClick={() => setOpen((o) => !o)}
>
<span className={`${styles.ic} ${styles.icHigh}`}>
<HighIcon />
</span>
<span className={`${styles.ic} ${styles.icLow}`}>
<LowIcon />
</span>
<span className={`${styles.ic} ${styles.icMute}`}>
<MuteIcon />
</span>
</button>
<div className={styles.pop} id="vol-pop">
<div
ref={sliderRef}
className={styles.slider}
style={sliderVars}
role="slider"
tabIndex={0}
aria-label="음량"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
aria-valuetext={`${pct}%`}
onPointerDown={(e: PtrEvt) => {
e.currentTarget.setPointerCapture(e.pointerId);
set(e);
}}
onPointerMove={(e: PtrEvt) => {
if (e.buttons) set(e);
}}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowRight") { setV((p) => Math.min(1, p + 0.1)); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setV((p) => Math.max(0, p - 0.1)); e.preventDefault(); }
else if (e.key === "Home") { setV(0); e.preventDefault(); }
else if (e.key === "End") { setV(1); e.preventDefault(); }
}}
>
<span className={styles.track} aria-hidden="true">
<span className={styles.fill} />
<span className={styles.thumbwrap}>
<span className={styles.thumb} />
</span>
</span>
</div>
<span className={styles.pct}>{pct}</span>
</div>
</div>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
</div>
</figure>
);
}03 · 音量スライダーポップオーバー — 計測 累積面積 31.274% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
ポップオーバーは並びの中ではなく、ボタンの横に浮かせて置いています。
.vol { position: absolute; left: $sp-2; bottom: $sp-2; display: flex; align-items: flex-end; gap: $sp-1; }
.vol__pop {
position: absolute; left: calc(100% + #{$sp-1}); bottom: 0;
display: flex; align-items: center; gap: $sp-2;
width: 152px; height: 36px;
border-radius: $r-control;
background: rgba(23, 20, 26, .88);
transform: translateX(-6px) scale(.6);
transform-origin: left bottom;
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.pl.is-open .vol__pop { transform: none; opacity: 1; pointer-events: auto; }
04再生速度メニュー
何が見えるか
速度ボタンを押すと 0.5倍から2倍までの一覧が開き、選んだ倍率の分だけ画面の時計が本当に速く、または遅く進みます。チェックが付くのは選んだ行だけです。

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

<main class="stage">
<figure class="pl is-demo" id="pl">
<div class="pl__screen">
<div class="pl__stripes" aria-hidden="true"></div>
<div class="pl__world" aria-hidden="true"><span class="pl__sun"></span><span class="pl__hill pl__hill--a"></span><span class="pl__hill pl__hill--b"></span></div>
<span class="pl__clock" id="clock">0:12</span>
<span class="pl__badge">3:00</span>
<div class="cap" id="cap">
<span class="cap__l cap__l--1 is-on">動画が始まります</span>
<span class="cap__l cap__l--2">字幕をオンにどうぞ</span>
<span class="cap__l cap__l--3">スタイルを選べます</span>
<span class="cap__l cap__l--4">これで準備完了です</span>
</div>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
</div>
<div class="pl__bar">
<button class="cc" type="button" id="cc" aria-pressed="true">
<span class="cc__ic" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 64v128a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h192a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 48H32a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m0 144H32V64h192zm-105.07-40.29a8 8 0 0 1-2.93 10.93a40 40 0 1 1 0-69.28a8 8 0 1 1-8 13.85a24 24 0 1 0 0 41.58a8 8 0 0 1 10.93 2.92m80 0a8 8 0 0 1-2.93 10.93a40 40 0 1 1 0-69.28a8 8 0 1 1-8 13.85a24 24 0 1 0 0 41.58a8 8 0 0 1 10.93 2.92"/></g></svg></span>
<span class="cc__t">字幕</span>
</button>
<div class="seg" id="seg" role="group" aria-label="字幕スタイル">
<button class="seg__b seg__b--chip is-on" type="button" data-s="chip" aria-pressed="true">地チップ</button>
<button class="seg__b seg__b--line" type="button" data-s="line" aria-pressed="false">輪郭</button>
</div>
<span class="pl__time"><b id="t-now">0:12</b> / 3:00</span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
}
// ── 화면 ──
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .32), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.pl__world { position: absolute; inset: 0; }
.pl__sun {
position: absolute; right: 18%; top: 12%;
width: 26px; height: 26px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .25);
}
.pl__hill { position: absolute; bottom: -46%; height: 82%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(23, 20, 26, .15); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(23, 20, 26, .09); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
.pl__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: rgba(255, 255, 255, .28); }
.pl__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.07);
}
// ── 자막 줄 (네 문장이 같은 칸에 겹쳐 쌓인다 — 한 번에 하나만 보인다) ──
.cap {
position: absolute;
left: 0; right: 0; bottom: $sp-4;
display: grid;
place-items: center;
opacity: 1;
transition: opacity $duration $easing;
}
.pl.is-off .cap { opacity: 0; }
.cap__l {
grid-area: 1 / 1;
box-sizing: border-box;
max-width: 92%;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background-color: rgba(23, 20, 26, .72);
color: #fff;
font-size: clamp(10px, 2.9vw, 13px);
line-height: 16px;
font-weight: 700;
white-space: nowrap;
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
opacity: 0;
transform: translateY(10px);
transition: opacity $duration $easing, transform $duration $easing;
}
.cap__l.is-on { opacity: 1; transform: none; }
.pl.is-outline .cap__l {
background-color: rgba(23, 20, 26, 0);
text-shadow: 1px 1px 0 rgba(23, 20, 26, .95), -1px 1px 0 rgba(23, 20, 26, .95), 1px -1px 0 rgba(23, 20, 26, .95), -1px -1px 0 rgba(23, 20, 26, .95);
}
// ── 조작 줄 ──
.pl__bar {
display: flex;
align-items: center;
gap: $sp-2;
padding: $sp-2;
border-top: 1px solid rgba(23, 20, 26, .12);
background: $subject-cream;
}
.cc {
box-sizing: border-box;
height: 32px;
flex: 0 0 auto;
display: inline-flex; align-items: center; gap: $sp-1;
padding: 0 $sp-2;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
cursor: pointer;
font-size: 12px; line-height: 16px; font-weight: 700;
transition: background $duration $easing, color $duration $easing;
}
.cc[aria-pressed="false"] { background: rgba(23, 20, 26, .1); color: $subject-ink; }
.cc:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.cc__ic { display: inline-flex; }
.seg { display: flex; align-items: center; gap: $sp-1; }
.seg__b {
box-sizing: border-box;
height: 28px;
flex: 0 0 auto;
display: inline-flex; align-items: center;
padding: 0 $sp-2;
border: 0;
border-radius: $r-pill;
background-color: rgba(23, 20, 26, .1);
color: $subject-ink;
cursor: pointer;
font-size: 11px; line-height: 14px; font-weight: 700;
transition: background-color $duration $easing, color $duration $easing;
}
.seg__b.is-on { background-color: $subject-ink; color: #fff; }
.seg__b:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.pl__time {
margin-left: auto;
color: rgba(23, 20, 26, .66);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 600;
}
.pl__time b { color: $subject-ink; font-weight: 700; }
// ── 자동 루프: 2초 안에 네 문장이 아래에서 올라오며 갈아 끼워지고, 절반 지점에서 스타일이 바뀐다 ──
// 문장 교체는 steps(1, end) 로 한 프레임 컷 — 두 문장이 절대 겹치지 않는다. 부드러움은 등장 rise 로만.
.pl.is-demo .cap__l--1 {
animation-name: cap-1, cap-style;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--2 {
animation-name: cap-2, cap-style;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--3 {
animation-name: cap-3, cap-style;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--4 {
animation-name: cap-4, cap-style;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
}
// 자막 교체: opacity 는 0.01% 폭으로 **컷**하고(두 문장이 겹치는 프레임도, 아무 문장도 없는 프레임도 없다),
// 부드러움은 translateY 등장 rise 로만 준다. 예전 판은 나가는 문장과 들어오는 문장 사이에 빈 구간이 있어
// 정확히 25% 지점 프레임(24프레임 중 7번째)이 자막 없는 빈 화면이었고 그게 poster 로 뽑혔다 (실측 2026-09-12)
@keyframes cap-1 {
0%, 21.99% { opacity: 1; transform: none; }
22%, 87.99% { opacity: 0; transform: translateY(10px); }
88% { opacity: 1; transform: translateY(10px); }
93%, 100% { opacity: 1; transform: none; }
}
@keyframes cap-2 {
0%, 21.99% { opacity: 0; transform: translateY(10px); }
22% { opacity: 1; transform: translateY(10px); }
27%, 43.99% { opacity: 1; transform: none; }
44%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes cap-3 {
0%, 43.99% { opacity: 0; transform: translateY(10px); }
44% { opacity: 1; transform: translateY(10px); }
49%, 65.99% { opacity: 1; transform: none; }
66%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes cap-4 {
0%, 65.99% { opacity: 0; transform: translateY(10px); }
66% { opacity: 1; transform: translateY(10px); }
71%, 87.99% { opacity: 1; transform: none; }
88%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes cap-style {
0%, 49% {
background-color: rgba(23, 20, 26, .72);
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
}
50%, 87% {
background-color: rgba(23, 20, 26, 0);
text-shadow: 1px 1px 0 rgba(23, 20, 26, .95), -1px 1px 0 rgba(23, 20, 26, .95), 1px -1px 0 rgba(23, 20, 26, .95), -1px -1px 0 rgba(23, 20, 26, .95);
}
88%, 100% {
background-color: rgba(23, 20, 26, .72);
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
}
}
.pl.is-demo .seg__b--chip {
animation-name: seg-chip;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .seg__b--line {
animation-name: seg-line;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes seg-chip {
0%, 49% { background-color: $subject-ink; color: #fff; }
50%, 87% { background-color: rgba(23, 20, 26, .1); color: $subject-ink; }
88%, 100% { background-color: $subject-ink; color: #fff; }
}
@keyframes seg-line {
0%, 49% { background-color: rgba(23, 20, 26, .1); color: $subject-ink; }
50%, 87% { background-color: $subject-ink; color: #fff; }
88%, 100% { background-color: rgba(23, 20, 26, .1); color: $subject-ink; }
}
.pl.is-demo .pl__fill {
animation-name: cap-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes cap-fill {
0% { transform: scaleX(.07); }
88%, 100% { transform: scaleX(.62); }
}
// ── 좁은 화면: 간격을 올리지 않는다. 덜 중요한 것을 뺀다 ──
@media (max-width: 340px) {
.pl__time { display: none; }
.cap__l { line-height: 14px; }
.cc { font-size: 11px; }
.seg__b { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .cap__l--1, .pl.is-demo .cap__l--2, .pl.is-demo .cap__l--3, .pl.is-demo .cap__l--4,
.pl.is-demo .seg__b--chip, .pl.is-demo .seg__b--line, .pl.is-demo .pl__fill,
.pl__stripes { animation: none !important; }
.cap, .cap__l, .cc, .seg__b { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(96px, 26vw, 140px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.32), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.pl__world {
position: absolute;
inset: 0;
}
.pl__sun {
position: absolute;
right: 18%;
top: 12%;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.25);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 82%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(23, 20, 26, 0.15);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(23, 20, 26, 0.09);
}
.pl__clock, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.pl__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: rgba(255, 255, 255, 0.28);
}
.pl__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.07);
}
.cap {
position: absolute;
left: 0;
right: 0;
bottom: 16px;
display: grid;
place-items: center;
opacity: 1;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl.is-off .cap {
opacity: 0;
}
.cap__l {
grid-area: 1/1;
box-sizing: border-box;
max-width: 92%;
padding: 4px 8px;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.72);
color: #fff;
font-size: clamp(10px, 2.9vw, 13px);
line-height: 16px;
font-weight: 700;
white-space: nowrap;
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
opacity: 0;
transform: translateY(10px);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cap__l.is-on {
opacity: 1;
transform: none;
}
.pl.is-outline .cap__l {
background-color: rgba(23, 20, 26, 0);
text-shadow: 1px 1px 0 rgba(23, 20, 26, 0.95), -1px 1px 0 rgba(23, 20, 26, 0.95), 1px -1px 0 rgba(23, 20, 26, 0.95), -1px -1px 0 rgba(23, 20, 26, 0.95);
}
.pl__bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border-top: 1px solid rgba(23, 20, 26, 0.12);
background: #fff7e6;
}
.cc {
box-sizing: border-box;
height: 32px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
cursor: pointer;
font-size: 12px;
line-height: 16px;
font-weight: 700;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cc[aria-pressed=false] {
background: rgba(23, 20, 26, 0.1);
color: #17141a;
}
.cc:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.cc__ic {
display: inline-flex;
}
.seg {
display: flex;
align-items: center;
gap: 4px;
}
.seg__b {
box-sizing: border-box;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
padding: 0 8px;
border: 0;
border-radius: 999px;
background-color: rgba(23, 20, 26, 0.1);
color: #17141a;
cursor: pointer;
font-size: 11px;
line-height: 14px;
font-weight: 700;
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.seg__b.is-on {
background-color: #17141a;
color: #fff;
}
.seg__b:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pl__time {
margin-left: auto;
color: rgba(23, 20, 26, 0.66);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 600;
}
.pl__time b {
color: #17141a;
font-weight: 700;
}
.pl.is-demo .cap__l--1 {
animation-name: cap-1, cap-style;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--2 {
animation-name: cap-2, cap-style;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--3 {
animation-name: cap-3, cap-style;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.pl.is-demo .cap__l--4 {
animation-name: cap-4, cap-style;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
@keyframes cap-1 {
0%, 21.99% {
opacity: 1;
transform: none;
}
22%, 87.99% {
opacity: 0;
transform: translateY(10px);
}
88% {
opacity: 1;
transform: translateY(10px);
}
93%, 100% {
opacity: 1;
transform: none;
}
}
@keyframes cap-2 {
0%, 21.99% {
opacity: 0;
transform: translateY(10px);
}
22% {
opacity: 1;
transform: translateY(10px);
}
27%, 43.99% {
opacity: 1;
transform: none;
}
44%, 100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes cap-3 {
0%, 43.99% {
opacity: 0;
transform: translateY(10px);
}
44% {
opacity: 1;
transform: translateY(10px);
}
49%, 65.99% {
opacity: 1;
transform: none;
}
66%, 100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes cap-4 {
0%, 65.99% {
opacity: 0;
transform: translateY(10px);
}
66% {
opacity: 1;
transform: translateY(10px);
}
71%, 87.99% {
opacity: 1;
transform: none;
}
88%, 100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes cap-style {
0%, 49% {
background-color: rgba(23, 20, 26, 0.72);
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
}
50%, 87% {
background-color: rgba(23, 20, 26, 0);
text-shadow: 1px 1px 0 rgba(23, 20, 26, 0.95), -1px 1px 0 rgba(23, 20, 26, 0.95), 1px -1px 0 rgba(23, 20, 26, 0.95), -1px -1px 0 rgba(23, 20, 26, 0.95);
}
88%, 100% {
background-color: rgba(23, 20, 26, 0.72);
text-shadow: 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0), 0 0 0 rgba(23, 20, 26, 0);
}
}
.pl.is-demo .seg__b--chip {
animation-name: seg-chip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .seg__b--line {
animation-name: seg-line;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes seg-chip {
0%, 49% {
background-color: #17141a;
color: #fff;
}
50%, 87% {
background-color: rgba(23, 20, 26, 0.1);
color: #17141a;
}
88%, 100% {
background-color: #17141a;
color: #fff;
}
}
@keyframes seg-line {
0%, 49% {
background-color: rgba(23, 20, 26, 0.1);
color: #17141a;
}
50%, 87% {
background-color: #17141a;
color: #fff;
}
88%, 100% {
background-color: rgba(23, 20, 26, 0.1);
color: #17141a;
}
}
.pl.is-demo .pl__fill {
animation-name: cap-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes cap-fill {
0% {
transform: scaleX(0.07);
}
88%, 100% {
transform: scaleX(0.62);
}
}
@media (max-width: 340px) {
.pl__time {
display: none;
}
.cap__l {
line-height: 14px;
}
.cc {
font-size: 11px;
}
.seg__b {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .cap__l--1, .pl.is-demo .cap__l--2, .pl.is-demo .cap__l--3, .pl.is-demo .cap__l--4,
.pl.is-demo .seg__b--chip, .pl.is-demo .seg__b--line, .pl.is-demo .pl__fill,
.pl__stripes {
animation: none !important;
}
.cap, .cap__l, .cc, .seg__b {
transition: none;
}
}// 실제 동작: CC 버튼으로 자막을 켜고 끄고, 세그먼트로 字幕 스타일을 고른다. 더미 再生 시계가 문장을 갈아 끼운다.
var root = document.getElementById('pl'), cc = document.getElementById('cc'),
seg = document.getElementById('seg'), clock = document.getElementById('clock'),
now = document.getElementById('t-now'), fill = document.getElementById('fill'),
lines = root.querySelectorAll('.cap__l');
var DUR = 180, t = 12, last = Date.now();
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function show(n) {
var i = n % lines.length;
for (var k = 0; k < lines.length; k++) lines[k].classList.toggle('is-on', k === i);
}
function calm() { if (root.classList.contains('is-demo')) root.classList.remove('is-demo'); }
setInterval(function () {
var n = Date.now();
t = (t + (n - last) / 1000) % DUR;
last = n;
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
if (!root.classList.contains('is-demo')) show(Math.floor(t / 2));
}, 250);
cc.addEventListener('click', function () {
calm();
var on = cc.getAttribute('aria-pressed') !== 'true';
cc.setAttribute('aria-pressed', String(on));
root.classList.toggle('is-off', !on);
});
seg.addEventListener('click', function (e) {
var b = e.target.closest ? e.target.closest('.seg__b') : null;
if (!b) return;
calm();
var bs = seg.querySelectorAll('.seg__b');
for (var k = 0; k < bs.length; k++) {
var on = bs[k] === b;
bs[k].setAttribute('aria-pressed', String(on));
bs[k].classList.toggle('is-on', on);
}
root.classList.toggle('is-outline', b.getAttribute('data-s') === 'line');
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CaptionToggle.module.scss";
export type CaptionToggleProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
/** 돌려 가며 보여 줄 자막 문장 */
lines?: string[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function CcIcon() {
return (
<svg viewBox="0 0 256 256" width="18" height="18" aria-hidden="true">
<g fill="currentColor">
<path d="M232 64v128a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h192a8 8 0 0 1 8 8" opacity=".2" />
<path d="M224 48H32a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h192a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16m0 144H32V64h192zm-105.07-40.29a8 8 0 0 1-2.93 10.93a40 40 0 1 1 0-69.28a8 8 0 1 1-8 13.85a24 24 0 1 0 0 41.58a8 8 0 0 1 10.93 2.92m80 0a8 8 0 0 1-2.93 10.93a40 40 0 1 1 0-69.28a8 8 0 1 1-8 13.85a24 24 0 1 0 0 41.58a8 8 0 0 1 10.93 2.92" />
</g>
</svg>
);
}
export default function CaptionToggle({
total = 180,
start = 12,
lines = ["영상이 시작됩니다", "자막을 켜 보세요", "스타일을 고를 수 있어요", "이제 준비가 끝났어요"],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CaptionToggleProps) {
const [on, setOn] = useState(true);
const [outline, setOutline] = useState(false);
const [t, setT] = useState(start);
// 더미 재생 시계 — 바닐라의 setInterval(250ms) 과 같은 방식(경과 시간으로 걷는다)
useEffect(() => {
let last = Date.now();
const id = setInterval(() => {
const n = Date.now();
const step = (n - last) / 1000;
last = n;
setT((p) => (p + step) % total);
}, 250);
return () => clearInterval(id);
}, [total]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 시계가 2초마다 문장을 갈아 끼운다
const cur = Math.floor(t / 2) % lines.length;
return (
<figure
className={`${styles.player} ${on ? "" : styles.isOff} ${outline ? styles.isOutline : ""}`}
style={vars}
>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<div className={styles.cap}>
{lines.map((l, k) => (
<span key={l} className={`${styles.line} ${k === cur ? styles.isOn : ""}`}>
{l}
</span>
))}
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
<div className={styles.bar}>
<button
className={styles.cc}
type="button"
aria-pressed={on}
onClick={() => setOn((v) => !v)}
>
<span className={styles.ccIc} aria-hidden="true">
<CcIcon />
</span>
<span>자막</span>
</button>
<div className={styles.seg} role="group" aria-label="자막 스타일">
<button
className={`${styles.segBtn} ${outline ? "" : styles.isOn}`}
type="button"
aria-pressed={!outline}
onClick={() => setOutline(false)}
>
바탕칩
</button>
<button
className={`${styles.segBtn} ${outline ? styles.isOn : ""}`}
type="button"
aria-pressed={outline}
onClick={() => setOutline(true)}
>
외곽선
</button>
</div>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
</div>
</figure>
);
}05 · 字幕トグルと字幕スタイル — 計測 累積面積 34.058% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
押下の状態は aria-pressed に残ります。
@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); }
}
06チャプターマーカーシークバー
どんな動きか
シークバーがチャプターごとの目盛りに分かれ、再生ヘッドが境界を越えると上のチャプター名が1行分巻き上がります。目盛りを押せばその章の先頭へ実際に飛びます。

<main class="stage">
<figure class="pl is-demo" id="pl">
<div class="pl__screen">
<div class="pl__stripes" aria-hidden="true"></div>
<div class="pl__world" aria-hidden="true"><span class="pl__sun"></span><span class="pl__hill pl__hill--a"></span><span class="pl__hill pl__hill--b"></span></div>
<span class="pl__clock" id="clock">0:08</span>
<span class="pl__badge">3:00</span>
<div class="nm">
<div class="nm__list" id="nm">
<b class="nm__i">1 · イントロ</b>
<b class="nm__i">2 · 導入</b>
<b class="nm__i">3 · 実例</b>
<b class="nm__i">4 · まとめ</b>
</div>
</div>
</div>
<div class="sk__row">
<span class="sk__t"><b id="t-now">0:08</b> / 3:00</span>
<div class="sk" id="sk" role="slider" tabindex="0" aria-label="再生位置" aria-valuemin="0" aria-valuemax="180" aria-valuenow="8" aria-valuetext="イントロ チャプター、 0:08">
<div class="ch" id="ch">
<span class="ch__seg" style="--s:0;--e:.1333;--k:7.5"><i class="ch__fill"></i></span>
<span class="ch__seg" style="--s:.1333;--e:.4;--k:3.75"><i class="ch__fill"></i></span>
<span class="ch__seg" style="--s:.4;--e:.7778;--k:2.647"><i class="ch__fill"></i></span>
<span class="ch__seg ch__seg--end" style="--s:.7778;--e:1;--k:4.5"><i class="ch__fill"></i></span>
<span class="ch__head" aria-hidden="true"><i class="ch__dot"></i></span>
</div>
</div>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// --p 는 등록된 사용자 지정 속성 — 키프레임에서 보간된다 (미등록이면 steps 처럼 튄다)
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.0444; }
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(76px, 22vw, 108px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute; inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .13) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.pl__world { position: absolute; inset: 0; }
.pl__sun {
position: absolute; right: 16%; top: 10%;
width: 22px; height: 22px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .25);
}
.pl__hill { position: absolute; bottom: -46%; height: 82%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(23, 20, 26, .15); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(23, 20, 26, .09); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
// ── 챕터 이름 — 한 줄짜리 창으로 4줄을 굴린다. 교체는 steps(1,end) 로 한 프레임 컷 ──
.nm {
position: absolute; left: $sp-2; bottom: $sp-2;
height: 20px;
padding-left: $sp-2; padding-right: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .74);
overflow: hidden;
}
.nm__list { translate: 0 calc(var(--i, 0) * -20px); }
.nm__i {
display: block;
height: 20px;
color: #fff;
font-size: 11px; line-height: 20px; font-weight: 700;
white-space: nowrap;
}
.sk__row {
display: flex; align-items: center; gap: $sp-3;
padding: $sp-3;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, .72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
}
.sk {
position: relative;
display: flex; align-items: center;
flex: 1 1 auto;
box-sizing: border-box;
height: 36px;
padding-left: $sp-2; padding-right: $sp-2;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-sm; }
// 구간은 % 로 자리를 잡는다 — 틈을 margin-right 로 깎아도 경계 좌표가 안 밀려 재생 머리와 딱 맞는다
.ch {
--p: 0.0444;
position: relative;
flex: 1 1 auto;
height: 12px;
}
.ch__seg {
position: absolute; top: 0; bottom: 0;
left: calc(var(--s) * 100%);
right: calc(100% - var(--e) * 100%);
margin-right: $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .16);
overflow: hidden;
}
.ch__seg--end { margin-right: 0; }
.ch__seg:hover { background: rgba(23, 20, 26, .26); }
.ch__fill {
position: absolute; inset: 0;
display: block;
border-radius: $r-pill;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
// 구간 안 진행률 = (p − 시작) × 1/(끝−시작), 0~1 로 잘라낸다
transform: scaleX(clamp(0, calc((var(--p) - var(--s)) * var(--k)), 1));
}
.ch__head {
position: absolute; top: -6px; bottom: -6px;
left: calc(var(--p) * 100%);
width: 4px;
border-radius: $r-pill;
background: $subject-ink;
transform: translateX(-50%);
pointer-events: none;
}
.ch__dot {
position: absolute; left: 50%; top: -4px;
width: 12px; height: 12px;
border-radius: 50%;
background: $stage-paper;
box-shadow: 0 2px 6px rgba(23, 20, 26, .45);
transform: translateX(-50%);
}
// ── 자동 루프: 재생 머리가 바를 지나며 구간을 차례로 채우고, 경계를 넘을 때마다 이름이 갈린다 ──
.pl.is-demo .ch {
animation-name: ch-p;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ch-p {
0% { --p: 0.0444; }
80% { --p: 0.94; }
86% { --p: 0.94; }
87% { --p: 0.0444; }
100% { --p: 0.0444; }
}
.pl.is-demo .nm__list {
animation-name: nm-roll, nm-rise;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
// 경계 시각: --p 가 .1333 / .4 / .7778 을 지나는 순간 (7.9% · 31.8% · 65.5%), 복귀는 87%
@keyframes nm-roll {
0% { translate: 0 0; }
7.9% { translate: 0 -20px; }
31.8% { translate: 0 -40px; }
65.5% { translate: 0 -60px; }
87% { translate: 0 0; }
100% { translate: 0 0; }
}
@keyframes nm-rise {
0% { transform: translateY(0); }
7.8% { transform: translateY(0); }
7.9% { transform: translateY(4px); }
15.9% { transform: translateY(0); }
31.7% { transform: translateY(0); }
31.8% { transform: translateY(4px); }
39.8% { transform: translateY(0); }
65.4% { transform: translateY(0); }
65.5% { transform: translateY(4px); }
73.5% { transform: translateY(0); }
86.9% { transform: translateY(0); }
87% { transform: translateY(4px); }
95% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.sk__row { gap: $sp-2; padding: $sp-2; }
.sk { height: 32px; }
.nm { bottom: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .ch, .pl.is-demo .nm__list, .pl__stripes { animation: none !important; }
.ch__fill, .ch__head { transition: none; }
}@property --p {
syntax: "<number>";
inherits: true;
initial-value: 0.0444;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(76px, 22vw, 108px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.13) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.pl__world {
position: absolute;
inset: 0;
}
.pl__sun {
position: absolute;
right: 16%;
top: 10%;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.25);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 82%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(23, 20, 26, 0.15);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(23, 20, 26, 0.09);
}
.pl__clock, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.nm {
position: absolute;
left: 8px;
bottom: 8px;
height: 20px;
padding-left: 8px;
padding-right: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.74);
overflow: hidden;
}
.nm__list {
translate: 0 calc(var(--i, 0) * -20px);
}
.nm__i {
display: block;
height: 20px;
color: #fff;
font-size: 11px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.sk__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, 0.72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
}
.sk {
position: relative;
display: flex;
align-items: center;
flex: 1 1 auto;
box-sizing: border-box;
height: 36px;
padding-left: 8px;
padding-right: 8px;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 8px;
}
.ch {
--p: 0.0444;
position: relative;
flex: 1 1 auto;
height: 12px;
}
.ch__seg {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--s) * 100%);
right: calc(100% - var(--e) * 100%);
margin-right: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
overflow: hidden;
}
.ch__seg--end {
margin-right: 0;
}
.ch__seg:hover {
background: rgba(23, 20, 26, 0.26);
}
.ch__fill {
position: absolute;
inset: 0;
display: block;
border-radius: 999px;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(clamp(0, (var(--p) - var(--s)) * var(--k), 1));
}
.ch__head {
position: absolute;
top: -6px;
bottom: -6px;
left: calc(var(--p) * 100%);
width: 4px;
border-radius: 999px;
background: #17141a;
transform: translateX(-50%);
pointer-events: none;
}
.ch__dot {
position: absolute;
left: 50%;
top: -4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.45);
transform: translateX(-50%);
}
.pl.is-demo .ch {
animation-name: ch-p;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ch-p {
0% {
--p: 0.0444;
}
80% {
--p: 0.94;
}
86% {
--p: 0.94;
}
87% {
--p: 0.0444;
}
100% {
--p: 0.0444;
}
}
.pl.is-demo .nm__list {
animation-name: nm-roll, nm-rise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes nm-roll {
0% {
translate: 0 0;
}
7.9% {
translate: 0 -20px;
}
31.8% {
translate: 0 -40px;
}
65.5% {
translate: 0 -60px;
}
87% {
translate: 0 0;
}
100% {
translate: 0 0;
}
}
@keyframes nm-rise {
0% {
transform: translateY(0);
}
7.8% {
transform: translateY(0);
}
7.9% {
transform: translateY(4px);
}
15.9% {
transform: translateY(0);
}
31.7% {
transform: translateY(0);
}
31.8% {
transform: translateY(4px);
}
39.8% {
transform: translateY(0);
}
65.4% {
transform: translateY(0);
}
65.5% {
transform: translateY(4px);
}
73.5% {
transform: translateY(0);
}
86.9% {
transform: translateY(0);
}
87% {
transform: translateY(4px);
}
95% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.sk__row {
gap: 8px;
padding: 8px;
}
.sk {
height: 32px;
}
.nm {
bottom: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .ch, .pl.is-demo .nm__list, .pl__stripes {
animation: none !important;
}
.ch__fill, .ch__head {
transition: none;
}
}// 실제 동작: チャプター 눈금을 누르면 그 チャプター 시작으로 건너뛰고, ←→ 5초 · Home/End 로도 움직인다.
var root = document.getElementById('pl'), sk = document.getElementById('sk'), ch = document.getElementById('ch'),
nm = document.getElementById('nm'), clock = document.getElementById('clock'), now = document.getElementById('t-now');
var DUR = 180, CH = [[0, 'イントロ'], [24, '導入'], [72, '実例'], [140, 'まとめ']], t = 8;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function idx(s) { var i = 0, k; for (k = 0; k < CH.length; k++) { if (s >= CH[k][0]) i = k; } return i; }
function label() {
var i = idx(t);
clock.textContent = fmt(t);
now.textContent = fmt(t);
sk.setAttribute('aria-valuenow', Math.floor(t));
sk.setAttribute('aria-valuetext', CH[i][1] + ' チャプター、 ' + fmt(t));
return i;
}
function paint() {
ch.style.setProperty('--p', (t / DUR).toFixed(4));
nm.style.setProperty('--i', label());
}
// 자동 루프 중엔 CSS 가 --p 를 움직인다 — 글자는 그리기 직전(rAF)에 그 값을 읽어 같은 프레임에 맞춘다.
// setInterval 로 읽으면 프레임 사이에 값이 어긋나 시계가 再生 머리와 따로 논다 (실측 2026-09-12: 2:49 인데 머리는 イントロ)
var prev = 0;
requestAnimationFrame(function tick(ms) {
if (root.classList.contains('is-demo')) {
var v = parseFloat(getComputedStyle(ch).getPropertyValue('--p'));
if (v === v) { t = v * DUR; label(); }
} else if (ms - prev > 80) {
prev = ms; t = (t + .08) % DUR; paint();
}
requestAnimationFrame(tick);
});
function take() { root.classList.remove('is-demo'); }
[].forEach.call(ch.querySelectorAll('.ch__seg'), function (el, i) {
el.addEventListener('click', function () { take(); t = CH[i][0]; paint(); sk.focus({ preventScroll: true }); });
});
sk.addEventListener('keydown', function (e) {
var k = e.key;
if (k === 'ArrowRight') { t = Math.min(DUR - 1, t + 5); }
else if (k === 'ArrowLeft') { t = Math.max(0, t - 5); }
else if (k === 'Home') { t = 0; }
else if (k === 'End') { t = DUR - 1; }
else { return; }
take(); paint(); e.preventDefault();
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChapterMarkers.module.scss";
export type Chapter = { at: number; name: string };
export type ChapterMarkersProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
/** 챕터 시작 시각과 이름 */
chapters?: Chapter[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type NameVars = CSSProperties & { "--i"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault: () => void };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function clamp01(v: number) {
return Math.max(0, Math.min(1, v));
}
export default function ChapterMarkers({
total = 180,
start = 8,
chapters = [
{ at: 0, name: "인트로" },
{ at: 24, name: "설치" },
{ at: 72, name: "예제" },
{ at: 140, name: "정리" },
],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ChapterMarkersProps) {
const [t, setT] = useState(start);
const skRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 rAF(실시간 1배) 를 setInterval(250ms, +0.25초) 로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.25) % total), 250);
return () => clearInterval(id);
}, [total]);
// 구간은 % 로 자리를 잡는다 — s(시작) · e(끝) · k = 1/(e−s) 로 구간 안 진행률을 낸다
const segs = chapters.map((c, i) => {
const s = c.at / total;
const e = (i + 1 < chapters.length ? chapters[i + 1].at : total) / total;
return { at: c.at, name: c.name, s, e, k: 1 / (e - s) };
});
let i = 0;
for (let k = 0; k < chapters.length; k++) if (t >= chapters[k].at) i = k;
const p = t / total;
const nameVars: NameVars = { "--i": String(i) };
return (
<figure className={styles.player} style={vars}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<div className={styles.nm}>
<div className={styles.nmList} style={nameVars}>
{chapters.map((c, k) => (
<b key={c.at} className={styles.nmItem}>
{k + 1} · {c.name}
</b>
))}
</div>
</div>
</div>
<div className={styles.row}>
<span className={styles.rowTime}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<div
ref={skRef}
className={styles.sk}
role="slider"
tabIndex={0}
aria-label="재생 위치"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={Math.floor(t)}
aria-valuetext={`${chapters[i].name} 챕터, ${fmt(t)}`}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowRight") { setT((v) => Math.min(total - 1, v + 5)); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setT((v) => Math.max(0, v - 5)); e.preventDefault(); }
else if (e.key === "Home") { setT(0); e.preventDefault(); }
else if (e.key === "End") { setT(total - 1); e.preventDefault(); }
}}
>
<div className={styles.ch}>
{segs.map((g, k) => (
<span
key={g.at}
className={`${styles.seg} ${k === segs.length - 1 ? styles.segEnd : ""}`}
style={{ left: `${(g.s * 100).toFixed(4)}%`, right: `${(100 - g.e * 100).toFixed(4)}%` }}
onClick={() => {
setT(g.at);
skRef.current?.focus({ preventScroll: true });
}}
>
<i
className={styles.segFill}
style={{ transform: `scaleX(${clamp01((p - g.s) * g.k).toFixed(4)})` }}
/>
</span>
))}
<span className={styles.head} style={{ left: `${(p * 100).toFixed(4)}%` }} aria-hidden="true">
<i className={styles.dot} />
</span>
</div>
</div>
</div>
</figure>
);
}06 · チャプターマーカーシークバー — 計測 累積面積 29.851% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
区間の塗りは「(現在地 − 区間の始まり) × 区間の逆数」を 0 から 1 のあいだに収めて出しているので、章が何本あっても計算式は1本です。
.ch__seg {
position: absolute; top: 0; bottom: 0;
left: calc(var(--s) * 100%);
right: calc(100% - var(--e) * 100%);
margin-right: $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .16);
overflow: hidden;
}
.ch__fill {
position: absolute; inset: 0;
border-radius: $r-pill;
background: $color;
transform-origin: left center;
transform: scaleX(clamp(0, calc((var(--p) - var(--s)) * var(--k)), 1));
}
07ミニプレーヤー(PiP風)
何が見えるか
ミニボタンを押すとプレーヤーが右下の小さなカードへ縮んで浮かび、元の場所には破線の枠と「ミニプレーヤーで再生中」の一行が残ります。閉じるボタンで元の大きさに戻ります。

<main class="stage">
<div class="pip is-demo" id="pip">
<div class="pip__stack">
<p class="pip__ph">ミニプレーヤーで再生中</p>
<figure class="pl" id="pl">
<div class="pl__screen">
<div class="pl__stripes" aria-hidden="true"></div>
<div class="pl__world" aria-hidden="true"><span class="pl__sun"></span><span class="pl__hill pl__hill--a"></span><span class="pl__hill pl__hill--b"></span></div>
<span class="pl__clock" id="clock">0:12</span>
<span class="pl__badge">3:00</span>
<button class="pl__close" type="button" id="pip-close" aria-label="ミニプレーヤーを閉じる">
<svg viewBox="0 0 256 256" width="24" height="24" aria-hidden="true"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg>
</button>
<div class="pl__bar">
<span class="pl__time"><b id="t-now">0:12</b> / 3:00</span>
<button class="pl__mini" type="button" id="pip-mini" aria-label="ミニプレーヤー" aria-pressed="false">
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true"><g fill="currentColor"><path d="M224 64v64h-88v72H40a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 48H40a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16M40 64h176v56h-80a8 8 0 0 0-8 8v64H40Zm176 128h-72v-56h72z"/></g></svg>
</button>
</div>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
</div>
</figure>
</div>
<div class="pip__doc" aria-hidden="true">
<span class="pip__line"></span>
<span class="pip__line pip__line--b"></span>
<span class="pip__line pip__line--c"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
// 미니로 갈 때 쓰는 값 — 레이아웃(top/left/width/height)을 건드리지 않고 transform 하나로만 옮긴다
$mini-scale: .44;
$mini-x: -4px;
$mini-y: 40px;
.pip {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
}
.pip__stack {
position: relative;
height: clamp(96px, 26vw, 132px);
}
// 원래 자리에 남는 자리표시
.pip__ph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
box-sizing: border-box;
margin: 0;
border: 2px dashed rgba(255, 247, 230, .55);
border-radius: $r-card;
background: rgba(23, 20, 26, .16);
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 700;
opacity: 0;
}
// 본문 자리 (글 줄 흉내)
.pip__doc {
display: flex;
flex-direction: column;
gap: $sp-2;
padding-top: $sp-3;
}
.pip__line {
height: 8px;
border-radius: $r-xs;
background: rgba(255, 247, 230, .55);
}
.pip__line--b { width: 86%; }
.pip__line--c { width: 62%; background: rgba(255, 247, 230, .38); }
// ── 플레이어 ──
.pl {
position: absolute;
inset: 0;
box-sizing: border-box;
margin: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
transform-origin: right bottom; // 규칙8: 우하단 도킹 계산 기준
transition: transform $duration $easing;
}
.pl__screen {
position: relative;
height: 100%;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.pl__world { position: absolute; inset: 0; }
.pl__sun {
position: absolute; right: 20%; top: 14%;
width: 26px; height: 26px;
border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .25);
}
.pl__hill { position: absolute; bottom: -46%; height: 82%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(23, 20, 26, .15); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(23, 20, 26, .09); }
.pl__clock, .pl__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.pl__clock { left: $sp-2; color: #fff; }
.pl__badge { right: $sp-2; color: rgba(255, 255, 255, .85); }
.pl__bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2 $sp-3;
}
.pl__time {
color: $subject-ink; // 화면 아래쪽은 크림이라 흰 글자가 1.35~2.10:1 이었다 — 잉크로 8.69~13.49:1
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, .6);
}
.pl__time b { font-weight: 700; }
.pl__mini {
box-sizing: border-box;
width: 36px; height: 36px;
flex: 0 0 auto;
margin-left: auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: $r-control;
background: $color;
color: #fff;
cursor: pointer;
box-shadow: 0 6px 14px rgba(47, 109, 246, .45);
}
.pl__mini:hover { background: rgba(47, 109, 246, .88); }
.pl__mini:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
// 미니 카드 위의 닫기 — 카드가 통째로 축소되므로 미리 키워 둔다 (축소 후 실효 약 33px)
.pl__close {
position: absolute; top: $sp-1; right: $sp-1;
box-sizing: border-box;
width: 40px; height: 40px;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: $r-control;
background: rgba(23, 20, 26, .82);
color: #fff;
cursor: pointer;
transform: scale(1.9);
transform-origin: right top; // 규칙8: 부모 축소 보정 모서리 고정
opacity: 0;
pointer-events: none;
}
.pl__close:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.pl__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: rgba(255, 255, 255, .28); }
.pl__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.07);
}
// ── 사람이 눌렀을 때 (JS 가 .is-demo 를 떼고 .is-mini 를 건다) ──
.pip.is-mini .pl { transform: translate($mini-x, $mini-y) scale($mini-scale); }
.pip.is-mini .pip__ph { opacity: 1; }
.pip.is-mini .pl__close { opacity: 1; pointer-events: auto; }
.pip.is-mini .pl__mini { opacity: 0; pointer-events: none; }
// ── 자동 루프: 플레이어가 오른쪽 아래 조각 카드로 줄었다가 제자리로. transform·opacity 만 ──
.pip.is-demo .pl {
animation-name: pip-shrink;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pip.is-demo .pip__ph {
animation-name: pip-ph;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pip.is-demo .pl__close {
animation-name: pip-close;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pip.is-demo .pl__mini {
animation-name: pip-minibtn;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 줄어드는 구간을 세 프레임(24프레임 기준 4.17% 간격)으로 좁히고, 자리표시·닫기 버튼을 **줄어들기가 끝나는 그 시점**에
// 컷으로 켠다 — 그래야 "미니로 다 줄어든 장면" 이 최대 변화 프레임이 되어 poster 로 뽑힌다.
// 앞판은 자리표시가 줄어드는 도중(34%)에 켜져서 poster 가 어중간한 중간 크기 장면이었다 (실측 2026-09-12)
@keyframes pip-shrink {
0%, 26% { transform: none; }
38%, 70% { transform: translate($mini-x, $mini-y) scale($mini-scale); }
86%, 100% { transform: none; }
}
// 자리표시는 **줄어들기가 시작하는 그 프레임**에 켜고 돌아온 뒤에 끈다. 다 줄어든 뒤에 켜면
// 날아가는 동안 원래 자리가 텅 빈 채로 남아, 중간 프레임(= poster)이 "카드가 붕 떠 있는" 장면이 된다 (실측 2026-09-12)
@keyframes pip-ph {
0%, 25.99% { opacity: 0; }
26%, 85.99% { opacity: 1; }
86%, 100% { opacity: 0; }
}
@keyframes pip-close {
0%, 37.99% { opacity: 0; }
38%, 71.99% { opacity: 1; }
72%, 100% { opacity: 0; }
}
@keyframes pip-minibtn {
0%, 25.99% { opacity: 1; }
26%, 71.99% { opacity: 0; }
72%, 100% { opacity: 1; }
}
@media (max-width: 340px) {
.pip__doc { gap: $sp-1; padding-top: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pip.is-demo .pl, .pip.is-demo .pip__ph, .pip.is-demo .pl__close,
.pip.is-demo .pl__mini, .pl__stripes { animation: none !important; }
.pl { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pip {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
}
.pip__stack {
position: relative;
height: clamp(96px, 26vw, 132px);
}
.pip__ph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
box-sizing: border-box;
margin: 0;
border: 2px dashed rgba(255, 247, 230, 0.55);
border-radius: 16px;
background: rgba(23, 20, 26, 0.16);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
opacity: 0;
}
.pip__doc {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
}
.pip__line {
height: 8px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.55);
}
.pip__line--b {
width: 86%;
}
.pip__line--c {
width: 62%;
background: rgba(255, 247, 230, 0.38);
}
.pl {
position: absolute;
inset: 0;
box-sizing: border-box;
margin: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
transform-origin: right bottom;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl__screen {
position: relative;
height: 100%;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.pl__world {
position: absolute;
inset: 0;
}
.pl__sun {
position: absolute;
right: 20%;
top: 14%;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.25);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 82%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(23, 20, 26, 0.15);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(23, 20, 26, 0.09);
}
.pl__clock, .pl__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.pl__clock {
left: 8px;
color: #fff;
}
.pl__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
}
.pl__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 12px;
}
.pl__time {
color: #17141a;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, 0.6);
}
.pl__time b {
font-weight: 700;
}
.pl__mini {
box-sizing: border-box;
width: 36px;
height: 36px;
flex: 0 0 auto;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 12px;
background: #2f6df6;
color: #fff;
cursor: pointer;
box-shadow: 0 6px 14px rgba(47, 109, 246, 0.45);
}
.pl__mini:hover {
background: rgba(47, 109, 246, 0.88);
}
.pl__mini:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pl__close {
position: absolute;
top: 4px;
right: 4px;
box-sizing: border-box;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 12px;
background: rgba(23, 20, 26, 0.82);
color: #fff;
cursor: pointer;
transform: scale(1.9);
transform-origin: right top;
opacity: 0;
pointer-events: none;
}
.pl__close:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pl__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: rgba(255, 255, 255, 0.28);
}
.pl__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.07);
}
.pip.is-mini .pl {
transform: translate(-4px, 40px) scale(0.44);
}
.pip.is-mini .pip__ph {
opacity: 1;
}
.pip.is-mini .pl__close {
opacity: 1;
pointer-events: auto;
}
.pip.is-mini .pl__mini {
opacity: 0;
pointer-events: none;
}
.pip.is-demo .pl {
animation-name: pip-shrink;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pip.is-demo .pip__ph {
animation-name: pip-ph;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pip.is-demo .pl__close {
animation-name: pip-close;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pip.is-demo .pl__mini {
animation-name: pip-minibtn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes pip-shrink {
0%, 26% {
transform: none;
}
38%, 70% {
transform: translate(-4px, 40px) scale(0.44);
}
86%, 100% {
transform: none;
}
}
@keyframes pip-ph {
0%, 25.99% {
opacity: 0;
}
26%, 85.99% {
opacity: 1;
}
86%, 100% {
opacity: 0;
}
}
@keyframes pip-close {
0%, 37.99% {
opacity: 0;
}
38%, 71.99% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
@keyframes pip-minibtn {
0%, 25.99% {
opacity: 1;
}
26%, 71.99% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
@media (max-width: 340px) {
.pip__doc {
gap: 4px;
padding-top: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pip.is-demo .pl, .pip.is-demo .pip__ph, .pip.is-demo .pl__close,
.pip.is-demo .pl__mini, .pl__stripes {
animation: none !important;
}
.pl {
transition: none;
}
}// 실제 동작: 미니 버튼이 플레이어를 transform 으로 오른쪽 아래 조각 카드로 줄인다. 閉じる·Escape 로 복귀.
var pip = document.getElementById('pip'),
btnMini = document.getElementById('pip-mini'),
btnClose = document.getElementById('pip-close'),
clock = document.getElementById('clock'),
now = document.getElementById('t-now'),
fill = document.getElementById('fill');
var DUR = 180, t = 12, last = Date.now();
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
setInterval(function () {
var n = Date.now();
t = (t + (n - last) / 1000) % DUR;
last = n;
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}, 250);
function setMini(on) {
pip.classList.remove('is-demo');
pip.classList.toggle('is-mini', on);
btnMini.setAttribute('aria-pressed', on ? 'true' : 'false');
}
btnMini.addEventListener('click', function () {
setMini(true);
btnClose.focus({ preventScroll: true });
});
btnClose.addEventListener('click', function () {
setMini(false);
btnMini.focus({ preventScroll: true });
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && pip.classList.contains('is-mini')) {
setMini(false);
btnMini.focus({ preventScroll: true });
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MiniPlayer.module.scss";
export type MiniPlayerProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function MiniIcon() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M224 64v64h-88v72H40a8 8 0 0 1-8-8V64a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" />
<path d="M216 48H40a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V64a16 16 0 0 0-16-16M40 64h176v56h-80a8 8 0 0 0-8 8v64H40Zm176 128h-72v-56h72z" />
</g>
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 256 256" width="24" height="24" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" />
</g>
</svg>
);
}
export default function MiniPlayer({
total = 180,
start = 12,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MiniPlayerProps) {
const [mini, setMini] = useState(false);
const [t, setT] = useState(start);
const miniRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 setInterval(250ms) 를 그대로
useEffect(() => {
let last = Date.now();
const id = setInterval(() => {
const n = Date.now();
const step = (n - last) / 1000;
last = n;
setT((p) => (p + step) % total);
}, 250);
return () => clearInterval(id);
}, [total]);
// 미니로 들어가면 닫기 버튼으로 초점을 옮긴다 (닫을 때 되돌리는 건 close 에서)
useEffect(() => {
if (mini) closeRef.current?.focus({ preventScroll: true });
}, [mini]);
// Escape 로 미니에서 나온다. 진짜 Document PiP 는 iframe 안에서 못 쓴다 — transform 으로 흉내 낸다
useEffect(() => {
if (!mini) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") close();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [mini]);
function close() {
setMini(false);
miniRef.current?.focus({ preventScroll: true });
}
return (
<div className={`${styles.pip} ${mini ? styles.isMini : ""}`} style={vars}>
<div className={styles.stack}>
<p className={styles.ph}>미니 플레이어로 보는 중</p>
<figure className={styles.player}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<button
ref={closeRef}
className={styles.close}
type="button"
aria-label="미니 플레이어 닫기"
onClick={close}
>
<CloseIcon />
</button>
<div className={styles.bar}>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<button
ref={miniRef}
className={styles.mini}
type="button"
aria-label="미니 플레이어"
aria-pressed={mini}
onClick={() => setMini(true)}
>
<MiniIcon />
</button>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
</figure>
</div>
<div className={styles.doc} aria-hidden="true">
<span className={styles.line} />
<span className={`${styles.line} ${styles.lineB}`} />
<span className={`${styles.line} ${styles.lineC}`} />
</div>
</div>
);
}07 · ミニプレーヤー(PiP風) — 計測 累積面積 46.533% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードのどこを見るか
別ウィンドウは出さず、top も width も触らずに transform ひとつで移動と縮小をまとめています。
$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; }
}
08フルスクリーントグル
画面で起きること
拡大ボタンを押すとプレーヤーが一回り大きく広がり、角の丸みが取れて隅のアイコンが縮小マークへ替わります。もう一度押すか Escape を打つと戻り、戻った先でフォーカスはボタンに残ります。

<main class="stage">
<figure class="fs is-demo" id="fs">
<div class="fs__screen">
<div class="fs__stripes" aria-hidden="true"></div>
<div class="fs__world" aria-hidden="true"><span class="fs__sun"></span><span class="fs__hill fs__hill--a"></span><span class="fs__hill fs__hill--b"></span></div>
<span class="fs__clock" id="clock">0:47</span>
<span class="fs__badge">3:00</span>
<span class="fs__hint" aria-hidden="true">Escで解除</span>
<div class="fs__bar">
<span class="fs__time"><b id="t-now">0:47</b> / 3:00</span>
<button class="fsb" type="button" id="fsb" aria-expanded="false" aria-label="フルスクリーン">
<span class="fsb__ic fsb__ic--out" aria-hidden="true"><svg viewBox="0 0 256 256" width="17" height="17"><g fill="currentColor"><path d="M208 48v160H48V48Z" opacity=".2"/><path d="M216 48v40a8 8 0 0 1-16 0V56h-32a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8M88 200H56v-32a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16m120-40a8 8 0 0 0-8 8v32h-32a8 8 0 0 0 0 16h40a8 8 0 0 0 8-8v-40a8 8 0 0 0-8-8M88 40H48a8 8 0 0 0-8 8v40a8 8 0 0 0 16 0V56h32a8 8 0 0 0 0-16"/></g></svg></span>
<span class="fsb__ic fsb__ic--in" aria-hidden="true"><svg viewBox="0 0 256 256" width="17" height="17"><g fill="currentColor"><path d="M208 64v128a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M152 96V48a8 8 0 0 1 16 0v40h40a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8m-56 56H48a8 8 0 0 0 0 16h40v40a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8m112 0h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-40h40a8 8 0 0 0 0-16M96 40a8 8 0 0 0-8 8v40H48a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8"/></g></svg></span>
</button>
</div>
<span class="fs__fillwrap" aria-hidden="true"><span class="fs__fill" id="fill"></span></span>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
// ── 플레이어. 커지는 건 width/height 가 아니라 transform: scale() 하나다 ──
.fs {
position: relative;
box-sizing: border-box;
width: min(300px, 72vw);
margin: 0;
transform: scale(1);
transform-origin: center center;
transition: transform $duration $easing;
}
.fs__screen {
position: relative;
height: clamp(88px, 26vw, 124px);
border-radius: $r-card;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .34), rgba(23, 20, 26, .92));
box-shadow: $shadow-raised;
transition: border-radius $duration $easing;
}
.fs.is-full { transform: scale(1.3); }
.fs.is-full .fs__screen { border-radius: 0; }
.fs__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(255, 255, 255, .13) 0 12px, transparent 12px 32px);
animation-name: fs-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.fs__world { position: absolute; inset: 0; }
.fs__sun {
position: absolute; right: 20%; top: 16%;
width: 26px; height: 26px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .22);
}
.fs__hill { position: absolute; bottom: -46%; height: 80%; border-radius: 50%; }
.fs__hill--a { left: -12%; width: 70%; background: rgba(255, 255, 255, .16); }
.fs__hill--b { right: -16%; width: 60%; background: rgba(255, 255, 255, .1); }
.fs__clock,
.fs__badge {
position: absolute; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 700;
}
.fs__clock { left: $sp-2; }
.fs__badge { right: $sp-2; color: rgba(255, 255, 255, .85); font-weight: 600; }
// 전체화면일 때만 뜨는 안내 — 컷으로 갈아 끼운다
.fs__hint {
position: absolute; left: 50%; top: $sp-2;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(47, 109, 246, .92);
color: #fff;
font-size: 10px; line-height: 14px; font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: translateX(-50%);
}
.fs.is-full .fs__hint { opacity: 1; }
.fs__bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-2 $sp-3;
}
.fs__time {
color: $subject-cream; // 화면 하단 그라디언트가 중간 밝기(#696b79)를 지나 잉크는 3.46:1 — 크림이 두 시점 다 통과(4.57~8.4:1)
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba($subject-ink, .7);
}
.fs__time b { font-weight: 700; }
.fsb {
position: relative;
box-sizing: border-box;
width: 28px; height: 28px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: $r-sm;
background: rgba(255, 255, 255, .16);
color: #fff;
cursor: pointer;
transition: background $duration $easing, transform $duration $easing;
}
.fsb:hover { background: rgba(255, 255, 255, .3); }
.fsb:active { transform: scale(.92); }
.fsb:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.fsb__ic {
position: absolute; inset: 0;
display: inline-flex; align-items: center; justify-content: center;
}
.fsb__ic--in { opacity: 0; }
.fs.is-full .fsb__ic--out { opacity: 0; }
.fs.is-full .fsb__ic--in { opacity: 1; }
.fs__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 4px; background: rgba(255, 255, 255, .26); }
.fs__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.26);
}
// ── 자동 루프: 보통 → 전체화면 → 보통. 아이콘과 안내는 steps(1,end) 로 한 프레임에 갈아 끼운다 ──
.fs.is-demo {
animation-name: fs-zoom;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fs-zoom {
0%, 16% { transform: scale(1); }
38%, 64% { transform: scale(1.3); }
86%, 100% { transform: scale(1); }
}
.fs.is-demo .fs__screen {
animation-name: fs-round;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes fs-round {
0%, 16% { border-radius: $r-card; }
38%, 64% { border-radius: 0; }
86%, 100% { border-radius: $r-card; }
}
.fs.is-demo .fsb__ic--out,
.fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fs.is-demo .fsb__ic--out { animation-name: fs-ic-out; }
.fs.is-demo .fsb__ic--in { animation-name: fs-ic-in; }
.fs.is-demo .fs__hint { animation-name: fs-hint; }
@keyframes fs-ic-out {
0%, 26% { opacity: 1; }
27%, 74% { opacity: 0; }
75%, 100% { opacity: 1; }
}
@keyframes fs-ic-in {
0%, 26% { opacity: 0; }
27%, 74% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes fs-hint {
0%, 26% { opacity: 0; }
27%, 74% { opacity: 1; }
75%, 100% { opacity: 0; }
}
.fs.is-demo .fs__fill {
animation-name: fs-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-fill {
0% { transform: scaleX(.26); }
86%, 100% { transform: scaleX(.62); }
}
@media (max-width: 340px) {
.fs__bar { padding: 0 $sp-1 $sp-2; }
.fs__clock, .fs__badge { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.fs.is-demo, .fs.is-demo .fs__screen, .fs.is-demo .fsb__ic--out, .fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint, .fs.is-demo .fs__fill, .fs__stripes { animation: none !important; }
.fs, .fs__screen, .fsb { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fs {
position: relative;
box-sizing: border-box;
width: min(300px, 72vw);
margin: 0;
transform: scale(1);
transform-origin: center center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fs__screen {
position: relative;
height: clamp(88px, 26vw, 124px);
border-radius: 16px;
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.34), rgba(23, 20, 26, 0.92));
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: border-radius 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fs.is-full {
transform: scale(1.3);
}
.fs.is-full .fs__screen {
border-radius: 0;
}
.fs__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.13) 0 12px, transparent 12px 32px);
animation-name: fs-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.fs__world {
position: absolute;
inset: 0;
}
.fs__sun {
position: absolute;
right: 20%;
top: 16%;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.22);
}
.fs__hill {
position: absolute;
bottom: -46%;
height: 80%;
border-radius: 50%;
}
.fs__hill--a {
left: -12%;
width: 70%;
background: rgba(255, 255, 255, 0.16);
}
.fs__hill--b {
right: -16%;
width: 60%;
background: rgba(255, 255, 255, 0.1);
}
.fs__clock,
.fs__badge {
position: absolute;
top: 8px;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 700;
}
.fs__clock {
left: 8px;
}
.fs__badge {
right: 8px;
color: rgba(255, 255, 255, 0.85);
font-weight: 600;
}
.fs__hint {
position: absolute;
left: 50%;
top: 8px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(47, 109, 246, 0.92);
color: #fff;
font-size: 10px;
line-height: 14px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: translateX(-50%);
}
.fs.is-full .fs__hint {
opacity: 1;
}
.fs__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 12px;
}
.fs__time {
color: #fff7e6;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(23, 20, 26, 0.7);
}
.fs__time b {
font-weight: 700;
}
.fsb {
position: relative;
box-sizing: border-box;
width: 28px;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
cursor: pointer;
transition: background 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.fsb:hover {
background: rgba(255, 255, 255, 0.3);
}
.fsb:active {
transform: scale(0.92);
}
.fsb:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.fsb__ic {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.fsb__ic--in {
opacity: 0;
}
.fs.is-full .fsb__ic--out {
opacity: 0;
}
.fs.is-full .fsb__ic--in {
opacity: 1;
}
.fs__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 4px;
background: rgba(255, 255, 255, 0.26);
}
.fs__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.26);
}
.fs.is-demo {
animation-name: fs-zoom;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes fs-zoom {
0%, 16% {
transform: scale(1);
}
38%, 64% {
transform: scale(1.3);
}
86%, 100% {
transform: scale(1);
}
}
.fs.is-demo .fs__screen {
animation-name: fs-round;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes fs-round {
0%, 16% {
border-radius: 16px;
}
38%, 64% {
border-radius: 0;
}
86%, 100% {
border-radius: 16px;
}
}
.fs.is-demo .fsb__ic--out,
.fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.fs.is-demo .fsb__ic--out {
animation-name: fs-ic-out;
}
.fs.is-demo .fsb__ic--in {
animation-name: fs-ic-in;
}
.fs.is-demo .fs__hint {
animation-name: fs-hint;
}
@keyframes fs-ic-out {
0%, 26% {
opacity: 1;
}
27%, 74% {
opacity: 0;
}
75%, 100% {
opacity: 1;
}
}
@keyframes fs-ic-in {
0%, 26% {
opacity: 0;
}
27%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
@keyframes fs-hint {
0%, 26% {
opacity: 0;
}
27%, 74% {
opacity: 1;
}
75%, 100% {
opacity: 0;
}
}
.fs.is-demo .fs__fill {
animation-name: fs-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes fs-fill {
0% {
transform: scaleX(0.26);
}
86%, 100% {
transform: scaleX(0.62);
}
}
@media (max-width: 340px) {
.fs__bar {
padding: 0 4px 8px;
}
.fs__clock, .fs__badge {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.fs.is-demo, .fs.is-demo .fs__screen, .fs.is-demo .fsb__ic--out, .fs.is-demo .fsb__ic--in,
.fs.is-demo .fs__hint, .fs.is-demo .fs__fill, .fs__stripes {
animation: none !important;
}
.fs, .fs__screen, .fsb {
transition: none;
}
}// 실제 동작: 버튼으로 무대를 채우고 Escape 로 돌아온다. 진짜 Fullscreen API 는 iframe 안에서 못 쓴다 — transform 으로 흉내 낸다.
var root = document.getElementById('fs'),
btn = document.getElementById('fsb'),
clock = document.getElementById('clock'),
now = document.getElementById('t-now'),
fill = document.getElementById('fill');
var DUR = 180, t = 47;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
setInterval(function () {
t = (t + .5) % DUR;
clock.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}, 500);
function full(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-full', on);
btn.setAttribute('aria-expanded', String(on));
btn.setAttribute('aria-label', on ? 'フルスクリーン解除' : 'フルスクリーン');
}
btn.addEventListener('click', function () { full(!root.classList.contains('is-full')); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-full')) {
full(false);
btn.focus({ preventScroll: true });
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FullscreenToggle.module.scss";
export type FullscreenToggleProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function ExpandIcon() {
return (
<svg viewBox="0 0 256 256" width="17" height="17" aria-hidden="true">
<g fill="currentColor">
<path d="M208 48v160H48V48Z" opacity=".2" />
<path d="M216 48v40a8 8 0 0 1-16 0V56h-32a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8M88 200H56v-32a8 8 0 0 0-16 0v40a8 8 0 0 0 8 8h40a8 8 0 0 0 0-16m120-40a8 8 0 0 0-8 8v32h-32a8 8 0 0 0 0 16h40a8 8 0 0 0 8-8v-40a8 8 0 0 0-8-8M88 40H48a8 8 0 0 0-8 8v40a8 8 0 0 0 16 0V56h32a8 8 0 0 0 0-16" />
</g>
</svg>
);
}
function CollapseIcon() {
return (
<svg viewBox="0 0 256 256" width="17" height="17" aria-hidden="true">
<g fill="currentColor">
<path d="M208 64v128a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V64a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2" />
<path d="M152 96V48a8 8 0 0 1 16 0v40h40a8 8 0 0 1 0 16h-48a8 8 0 0 1-8-8m-56 56H48a8 8 0 0 0 0 16h40v40a8 8 0 0 0 16 0v-48a8 8 0 0 0-8-8m112 0h-48a8 8 0 0 0-8 8v48a8 8 0 0 0 16 0v-40h40a8 8 0 0 0 0-16M96 40a8 8 0 0 0-8 8v40H48a8 8 0 0 0 0 16h48a8 8 0 0 0 8-8V48a8 8 0 0 0-8-8" />
</g>
</svg>
);
}
export default function FullscreenToggle({
total = 180,
start = 47,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: FullscreenToggleProps) {
const [full, setFull] = useState(false);
const [t, setT] = useState(start);
const btnRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 setInterval(500ms, +0.5초) 을 그대로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.5) % total), 500);
return () => clearInterval(id);
}, [total]);
// Escape 로 나간다. 진짜 Fullscreen API 는 iframe 안에서 못 쓴다 — transform 으로 흉내 낸다
useEffect(() => {
if (!full) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setFull(false);
btnRef.current?.focus({ preventScroll: true });
}
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [full]);
return (
<figure className={`${styles.player} ${full ? styles.isFull : ""}`} style={vars}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<span className={styles.hint} aria-hidden="true">Esc 로 나가기</span>
<div className={styles.bar}>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<button
ref={btnRef}
className={styles.fsb}
type="button"
aria-expanded={full}
aria-label={full ? "전체화면 나가기" : "전체화면"}
onClick={() => setFull((v) => !v)}
>
<span className={`${styles.ic} ${styles.icOut}`}>
<ExpandIcon />
</span>
<span className={`${styles.ic} ${styles.icIn}`}>
<CollapseIcon />
</span>
</button>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
</div>
</figure>
);
}08 · フルスクリーントグル — 計測 累積面積 56.881% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
読み上げ向けのボタン名も、そのときの状態に合わせて入れ替えています。
function full(on) {
root.classList.remove('is-demo');
root.classList.toggle('is-full', on);
btn.setAttribute('aria-expanded', String(on));
btn.setAttribute('aria-label', on ? 'フルスクリーン解除' : 'フルスクリーン');
}
btn.addEventListener('click', function () { full(!root.classList.contains('is-full')); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && root.classList.contains('is-full')) {
full(false);
btn.focus({ preventScroll: true });
}
});
09キーボードショートカットのオーバーレイ
どんな動きか
「?」ボタンを押すと画面が暗く沈んでショートカット一覧が浮かび、Space・←・→・M・F が紙の上の説明ではなくその場で効きます。

<main class="stage">
<figure class="pl is-demo" id="pl">
<div class="pl__screen">
<div class="pl__stripes" aria-hidden="true"></div>
<div class="pl__world" aria-hidden="true"><span class="pl__sun"></span><span class="pl__hill pl__hill--a"></span><span class="pl__hill pl__hill--b"></span></div>
<span class="pl__clock"><span class="clk" id="clk">0:12</span><span class="clk clk--b" aria-hidden="true">0:07</span></span>
<span class="pl__mute" aria-hidden="true">ミュート</span>
<div class="pl__bar">
<span class="pl__time"><b id="t-now">0:12</b> / 3:00</span>
<button class="qb" type="button" id="qb" aria-label="ショートカット案内" aria-haspopup="dialog" aria-expanded="false">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="17" height="17"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-10.77 20-24 20a8 8 0 0 0-8 8v8a8 8 0 0 0 16 0v-.72c18.24-3.35 32-17.9 32-35.28c0-19.85-17.94-36-40-36m104 56A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
</button>
</div>
<span class="pl__fillwrap" aria-hidden="true"><span class="pl__fill" id="fill"></span></span>
<div class="ovl" id="ovl" role="dialog" aria-modal="true" aria-label="キーボードショートカット">
<div class="ovl__panel">
<div class="ovl__head">
<p class="ovl__title">ショートカット</p>
<button class="ovl__x" type="button" id="ovx" aria-label="閉じる">
<span aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z"/></g></svg></span>
</button>
</div>
<ul class="ovl__list">
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-space" data-k="space">Space</kbd></span><span class="ovl__what">再生・一時停止</span></li>
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-left" data-k="left">←</kbd><kbd class="k-right" data-k="right">→</kbd></span><span class="ovl__what">5秒移動</span></li>
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-m" data-k="m">M</kbd></span><span class="ovl__what">ミュート</span></li>
<li class="ovl__row"><span class="ovl__keys"><kbd class="k-f" data-k="f">F</kbd></span><span class="ovl__what">フルスクリーン</span></li>
</ul>
</div>
</div>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
// ── 플레이어 껍데기 (01 과 같은 무대 장치) ──
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
overflow: hidden;
transform: scale(1);
transform-origin: center center;
transition: transform $dur-base $ease-spring;
}
.pl.is-full { transform: scale(1.06); }
.pl__screen {
position: relative;
height: clamp(124px, 34vw, 142px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, .32), rgba(255, 247, 230, .96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, .14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan { from { transform: translateX(0); } to { transform: translateX(-64px); } }
.pl__world { position: absolute; inset: 0; }
.pl__sun {
position: absolute; right: 18%; top: 12%;
width: 26px; height: 26px; border-radius: 50%;
background: rgba(255, 210, 63, .95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, .25);
}
.pl__hill { position: absolute; bottom: -46%; height: 78%; border-radius: 50%; }
.pl__hill--a { left: -14%; width: 72%; background: rgba(23, 20, 26, .15); }
.pl__hill--b { right: -18%; width: 62%; background: rgba(23, 20, 26, .09); }
// 시계 칩 — 두 시각을 같은 칸에 겹쳐 두고 컷으로 갈아 끼운다 (← → 5초 이동)
.pl__clock {
position: absolute; left: $sp-2; top: $sp-2;
z-index: 2;
display: inline-grid;
padding: $sp-1 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 700;
}
.clk { grid-area: 1 / 1; }
.clk--b { opacity: 0; }
.pl.is-paused .pl__clock { background: $stage-yellow; color: $subject-ink; }
// 음소거 알약 — M 이 먹었다는 증거. 어두운 막 위에 뜬다(z-index)
.pl__mute {
position: absolute; right: $sp-2; top: $sp-2;
z-index: 2;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(194, 54, 26, .94);
color: #fff;
font-size: 10px; line-height: 18px; font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: scale(.7);
transform-origin: right center; // 규칙8: right 로 박아 둔 모서리 알약 — 중심이면 커지며 오른쪽 여백을 먹는다
transition: opacity $duration $easing, transform $duration $easing;
}
.pl.is-mute .pl__mute { opacity: 1; transform: none; }
.pl__bar {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
padding: 0 $sp-2 $sp-3;
}
.pl__time {
color: $subject-ink; // 화면 아래쪽은 크림이라 흰 글자가 1.35~2.10:1 이었다 — 잉크로 8.69~13.49:1
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, .6);
}
.pl__time b { font-weight: 700; }
.qb {
position: relative;
box-sizing: border-box;
width: 28px; height: 28px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
border: 0;
border-radius: 50%;
background: $color;
color: #fff;
cursor: pointer;
box-shadow: 0 4px 10px rgba(47, 109, 246, .45);
transition: transform $duration $easing;
}
.qb:hover { transform: scale(1.08); }
.qb:active { transform: scale(.92); }
.qb:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.pl__fillwrap { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: rgba(255, 255, 255, .28); }
.pl__fill {
display: block; height: 100%;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
transform: scaleX(.08);
}
// ── 단축키 다이얼로그 — 화면을 어둡게 깔고 목록이 뜬다. 화면 상자 안에 갇힌다(BD9) ──
.ovl {
position: absolute; inset: 0;
background: rgba(23, 20, 26, .84);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity $duration $easing;
}
.pl.is-open .ovl, .pl.is-demo .ovl { visibility: visible; }
.pl.is-open .ovl { opacity: 1; pointer-events: auto; }
.ovl__panel {
position: absolute; inset: 0;
box-sizing: border-box;
display: flex; flex-direction: column; justify-content: flex-start;
padding: $sp-8 $sp-3 $sp-3; // 위쪽 $sp-8 은 시계 칩(상단 30px)을 피하는 자리
transform: translateY(10px) scale(.94);
transition: transform $duration $easing;
}
.pl.is-open .ovl__panel { transform: none; }
.ovl__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: $sp-1; }
.ovl__title {
margin: 0;
color: $stage-yellow;
font-size: 10px; line-height: 18px; font-weight: 800;
letter-spacing: .08em;
}
.ovl__x {
box-sizing: border-box;
width: 18px; height: 18px;
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
padding: 0;
border: 0;
border-radius: $r-xs;
background: rgba(255, 255, 255, .16);
color: #fff;
cursor: pointer;
transition: background $duration $easing;
}
.ovl__x:hover { background: rgba(255, 255, 255, .32); }
.ovl__x:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
// 4줄을 세로로 세우면 무대(124px)를 넘는다 — 2×2 격자로 접는다 (BD9)
.ovl__list { display: grid; grid-template-columns: 1fr 1fr; gap: $sp-1 $sp-2; margin: 0; padding: 0; list-style: none; }
.ovl__row { display: grid; grid-template-columns: 56px 1fr; align-items: center; gap: $sp-2; }
.ovl__keys { display: flex; align-items: center; gap: $sp-1; }
.ovl__what {
color: rgba(255, 255, 255, .88);
font-size: 10px; line-height: 16px; font-weight: 600;
}
.ovl kbd {
box-sizing: border-box;
min-width: 22px;
padding: 0 $sp-1;
border: 1px solid rgba(255, 255, 255, .28);
border-radius: $r-xs;
background: rgba(255, 255, 255, .14);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 9px; line-height: 14px; font-weight: 700;
text-align: center;
}
// 사람이 진짜 키를 눌렀을 때 — 한 번만 반짝
.ovl kbd.is-hit {
animation-name: kbd-hit;
animation-duration: $dur-base;
animation-timing-function: $ease-pop;
animation-iteration-count: 1;
}
@keyframes kbd-hit {
0% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
30%, 60% { transform: translateY(2px); background: $stage-yellow; color: $subject-ink; border-color: $stage-yellow; }
100% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
}
// ── 자동 루프 (2s): 오버레이가 열리고 Space → ← → → → M → F 가 차례로 눌린다 ──
// animation- 낱개 속성만 쓴다. 단축을 쓰면 animation-delay 가 0 으로 리셋돼 kbd 다섯 개가 동시에 빛난다 (240 실측)
.pl.is-demo {
animation-name: sc-full;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl {
animation-name: sc-dim;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl__panel {
animation-name: sc-rise;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pl.is-demo .pl__clock {
animation-name: sc-clock;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .clk {
animation-name: sc-clk-a;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .clk--b {
animation-name: sc-clk-b;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .pl__mute {
animation-name: sc-mute;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.pl.is-demo .pl__fill {
animation-name: sc-fill;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
// kbd 다섯 개는 같은 keyframes 를 음수 delay 로 어긋나게 돌린다 (delay 는 반드시 낱개 속성)
.pl.is-demo .ovl kbd {
animation-name: kbd-cycle;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
// 2s 루프 중 220~420ms 구간만 눌린 모양 — 음수 delay 로 다섯 개를 어긋나게 놓는다
@keyframes kbd-cycle {
0%, 10% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
11%, 20% { transform: translateY(2px); background: $stage-yellow; color: $subject-ink; border-color: $stage-yellow; }
21%, 100% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; border-color: rgba(255, 255, 255, .28); }
}
// 다섯 키를 73% 안에 끝낸다 — 오버레이가 닫히는 84~85% 프레임에서는 **어두운 막만** 바뀌어야
// poster 가 "열린 오버레이" 쪽으로 뽑힌다 (poster 는 변한 픽셀 '면적' 이 가장 큰 장면이라,
// 노란 시계·음소거 알약·확대가 닫힘과 같은 프레임에 되돌아오면 그쪽 면적이 더 커진다 — 실측 2026-09-12)
.pl.is-demo .ovl .k-space { animation-delay: -1980ms; }
.pl.is-demo .ovl .k-left { animation-delay: -1700ms; }
.pl.is-demo .ovl .k-right { animation-delay: -1420ms; }
.pl.is-demo .ovl .k-m { animation-delay: -1180ms; }
.pl.is-demo .ovl .k-f { animation-delay: -940ms; }
// 여닫는 opacity 는 한 프레임(24프레임 기준 4.17%) 안으로 좁힌다 — 페이드 중간 프레임이 poster 로 뽑혀
// 오버레이가 반투명하게 시간 글자와 겹쳐 보였다 (실측 2026-09-12). 부드러움은 rise 쪽에만 남긴다
// 페이드 구간이 **샘플 프레임 사이**에 오게 맞춘다. 24프레임은 4.1667% 간격(…8.33·12.5…83.33·87.5…)이라
// 9→10% 와 84→85% 에 두면 반투명한 프레임이 한 장도 안 뽑힌다. 86→88% 였을 때 87.5% 프레임(opacity .25)이
// poster 로 뽑혀 오버레이가 유령처럼 비쳤다 (실측 2026-09-12)
// 여는 쪽은 샘플 프레임 사이(9→10%)에서 **컷**, 닫는 쪽은 84→90% 로 **나눠서** 사라진다.
// 둘 다 컷으로 하면 닫는 프레임의 변화량이 더 커서(그때는 노란 시계·음소거 알약·확대까지 같이 되돌아온다)
// poster 가 "오버레이가 없는 장면" 으로 뽑혔다 — 주인공이 안 보이는 썸네일이 된다 (실측 2026-09-12)
@keyframes sc-dim {
0%, 9% { opacity: 0; }
10%, 84% { opacity: 1; }
85%, 100% { opacity: 0; }
}
@keyframes sc-rise {
0%, 9% { transform: translateY(10px) scale(.94); }
16%, 84% { transform: none; }
85%, 100% { transform: translateY(10px) scale(.94); }
}
// Space — 시계 칩이 노랗게 컷되며 "멈췄다"를 알린다
@keyframes sc-clock {
0%, 21% { background: rgba(23, 20, 26, .62); color: #fff; }
22%, 73% { background: $stage-yellow; color: $subject-ink; }
74%, 100% { background: rgba(23, 20, 26, .62); color: #fff; }
}
// ← → — 두 시각을 한 프레임에 갈아 끼운다
@keyframes sc-clk-a {
0%, 34% { opacity: 1; }
35%, 49% { opacity: 0; }
50%, 100% { opacity: 1; }
}
@keyframes sc-clk-b {
0%, 34% { opacity: 0; }
35%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
// M — 음소거 알약
@keyframes sc-mute {
0%, 53% { opacity: 0; transform: scale(.7); }
58%, 73% { opacity: 1; transform: none; }
74%, 100% { opacity: 0; transform: scale(.7); }
}
// F — 플레이어가 확장 상태로
@keyframes sc-full {
0%, 63% { transform: scale(1); }
69%, 73% { transform: scale(1.06); }
78%, 100% { transform: scale(1); }
}
@keyframes sc-fill {
0% { transform: scaleX(.08); }
22%, 34% { transform: scaleX(.2); }
35%, 49% { transform: scaleX(.14); }
50%, 73% { transform: scaleX(.2); }
78%, 100% { transform: scaleX(.08); }
}
// 좁은 화면 — 여기서는 간격을 올리지 않는다 (BD9 세로 174px)
@media (max-width: 340px) {
.ovl__panel { padding: $sp-6 $sp-2 $sp-2; }
.ovl__list { gap: 0 $sp-1; }
.ovl__row { grid-template-columns: 52px 1fr; gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo, .pl.is-demo .ovl, .pl.is-demo .ovl__panel, .pl.is-demo .pl__clock,
.pl.is-demo .clk, .pl.is-demo .clk--b, .pl.is-demo .pl__mute, .pl.is-demo .pl__fill,
.pl.is-demo .ovl kbd, .ovl kbd.is-hit, .pl__stripes { animation: none !important; }
.pl, .ovl, .ovl__panel, .pl__mute, .qb, .ovl__x { transition: none; }
.pl.is-demo .ovl { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pl {
position: relative;
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
transform: scale(1);
transform-origin: center center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pl.is-full {
transform: scale(1.06);
}
.pl__screen {
position: relative;
height: clamp(124px, 34vw, 142px);
overflow: hidden;
background: linear-gradient(180deg, rgba(47, 109, 246, 0.32), rgba(255, 247, 230, 0.96));
}
.pl__stripes {
position: absolute;
inset: 0 -72px;
background: repeating-linear-gradient(90deg, rgba(23, 20, 26, 0.14) 0 12px, transparent 12px 32px);
animation-name: pl-pan;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes pl-pan {
from {
transform: translateX(0);
}
to {
transform: translateX(-64px);
}
}
.pl__world {
position: absolute;
inset: 0;
}
.pl__sun {
position: absolute;
right: 18%;
top: 12%;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(255, 210, 63, 0.95);
box-shadow: 0 0 0 8px rgba(255, 210, 63, 0.25);
}
.pl__hill {
position: absolute;
bottom: -46%;
height: 78%;
border-radius: 50%;
}
.pl__hill--a {
left: -14%;
width: 72%;
background: rgba(23, 20, 26, 0.15);
}
.pl__hill--b {
right: -18%;
width: 62%;
background: rgba(23, 20, 26, 0.09);
}
.pl__clock {
position: absolute;
left: 8px;
top: 8px;
z-index: 2;
display: inline-grid;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.62);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.clk {
grid-area: 1/1;
}
.clk--b {
opacity: 0;
}
.pl.is-paused .pl__clock {
background: #ffd23f;
color: #17141a;
}
.pl__mute {
position: absolute;
right: 8px;
top: 8px;
z-index: 2;
padding: 0 8px;
border-radius: 999px;
background: rgba(194, 54, 26, 0.94);
color: #fff;
font-size: 10px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: scale(0.7);
transform-origin: right center;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pl.is-mute .pl__mute {
opacity: 1;
transform: none;
}
.pl__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px 12px;
}
.pl__time {
color: #17141a;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
text-shadow: 0 1px 2px rgba(255, 247, 230, 0.6);
}
.pl__time b {
font-weight: 700;
}
.qb {
position: relative;
box-sizing: border-box;
width: 28px;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 50%;
background: #2f6df6;
color: #fff;
cursor: pointer;
box-shadow: 0 4px 10px rgba(47, 109, 246, 0.45);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.qb:hover {
transform: scale(1.08);
}
.qb:active {
transform: scale(0.92);
}
.qb:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.pl__fillwrap {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 6px;
background: rgba(255, 255, 255, 0.28);
}
.pl__fill {
display: block;
height: 100%;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(0.08);
}
.ovl {
position: absolute;
inset: 0;
background: rgba(23, 20, 26, 0.84);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pl.is-open .ovl, .pl.is-demo .ovl {
visibility: visible;
}
.pl.is-open .ovl {
opacity: 1;
pointer-events: auto;
}
.ovl__panel {
position: absolute;
inset: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 32px 12px 12px;
transform: translateY(10px) scale(0.94);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.pl.is-open .ovl__panel {
transform: none;
}
.ovl__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ovl__title {
margin: 0;
color: #ffd23f;
font-size: 10px;
line-height: 18px;
font-weight: 800;
letter-spacing: 0.08em;
}
.ovl__x {
box-sizing: border-box;
width: 18px;
height: 18px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 4px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ovl__x:hover {
background: rgba(255, 255, 255, 0.32);
}
.ovl__x:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.ovl__list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 8px;
margin: 0;
padding: 0;
list-style: none;
}
.ovl__row {
display: grid;
grid-template-columns: 56px 1fr;
align-items: center;
gap: 8px;
}
.ovl__keys {
display: flex;
align-items: center;
gap: 4px;
}
.ovl__what {
color: rgba(255, 255, 255, 0.88);
font-size: 10px;
line-height: 16px;
font-weight: 600;
}
.ovl kbd {
box-sizing: border-box;
min-width: 22px;
padding: 0 4px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 9px;
line-height: 14px;
font-weight: 700;
text-align: center;
}
.ovl kbd.is-hit {
animation-name: kbd-hit;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes kbd-hit {
0% {
transform: translateY(0);
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.28);
}
30%, 60% {
transform: translateY(2px);
background: #ffd23f;
color: #17141a;
border-color: #ffd23f;
}
100% {
transform: translateY(0);
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.28);
}
}
.pl.is-demo {
animation-name: sc-full;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .ovl {
animation-name: sc-dim;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl__panel {
animation-name: sc-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .pl__clock {
animation-name: sc-clock;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .clk {
animation-name: sc-clk-a;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .clk--b {
animation-name: sc-clk-b;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.pl.is-demo .pl__mute {
animation-name: sc-mute;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.pl.is-demo .pl__fill {
animation-name: sc-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl kbd {
animation-name: kbd-cycle;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes kbd-cycle {
0%, 10% {
transform: translateY(0);
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.28);
}
11%, 20% {
transform: translateY(2px);
background: #ffd23f;
color: #17141a;
border-color: #ffd23f;
}
21%, 100% {
transform: translateY(0);
background: rgba(255, 255, 255, 0.14);
color: #fff;
border-color: rgba(255, 255, 255, 0.28);
}
}
.pl.is-demo .ovl .k-space {
animation-delay: -1980ms;
}
.pl.is-demo .ovl .k-left {
animation-delay: -1700ms;
}
.pl.is-demo .ovl .k-right {
animation-delay: -1420ms;
}
.pl.is-demo .ovl .k-m {
animation-delay: -1180ms;
}
.pl.is-demo .ovl .k-f {
animation-delay: -940ms;
}
@keyframes sc-dim {
0%, 9% {
opacity: 0;
}
10%, 84% {
opacity: 1;
}
85%, 100% {
opacity: 0;
}
}
@keyframes sc-rise {
0%, 9% {
transform: translateY(10px) scale(0.94);
}
16%, 84% {
transform: none;
}
85%, 100% {
transform: translateY(10px) scale(0.94);
}
}
@keyframes sc-clock {
0%, 21% {
background: rgba(23, 20, 26, 0.62);
color: #fff;
}
22%, 73% {
background: #ffd23f;
color: #17141a;
}
74%, 100% {
background: rgba(23, 20, 26, 0.62);
color: #fff;
}
}
@keyframes sc-clk-a {
0%, 34% {
opacity: 1;
}
35%, 49% {
opacity: 0;
}
50%, 100% {
opacity: 1;
}
}
@keyframes sc-clk-b {
0%, 34% {
opacity: 0;
}
35%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes sc-mute {
0%, 53% {
opacity: 0;
transform: scale(0.7);
}
58%, 73% {
opacity: 1;
transform: none;
}
74%, 100% {
opacity: 0;
transform: scale(0.7);
}
}
@keyframes sc-full {
0%, 63% {
transform: scale(1);
}
69%, 73% {
transform: scale(1.06);
}
78%, 100% {
transform: scale(1);
}
}
@keyframes sc-fill {
0% {
transform: scaleX(0.08);
}
22%, 34% {
transform: scaleX(0.2);
}
35%, 49% {
transform: scaleX(0.14);
}
50%, 73% {
transform: scaleX(0.2);
}
78%, 100% {
transform: scaleX(0.08);
}
}
@media (max-width: 340px) {
.ovl__panel {
padding: 24px 8px 8px;
}
.ovl__list {
gap: 0 4px;
}
.ovl__row {
grid-template-columns: 52px 1fr;
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo, .pl.is-demo .ovl, .pl.is-demo .ovl__panel, .pl.is-demo .pl__clock,
.pl.is-demo .clk, .pl.is-demo .clk--b, .pl.is-demo .pl__mute, .pl.is-demo .pl__fill,
.pl.is-demo .ovl kbd, .ovl kbd.is-hit, .pl__stripes {
animation: none !important;
}
.pl, .ovl, .ovl__panel, .pl__mute, .qb, .ovl__x {
transition: none;
}
.pl.is-demo .ovl {
opacity: 1;
}
}// 실제 동작: ? 로 ショートカット 다이얼로그를 열고 Space·←·→·M·F 가 그 자리에서 먹는다.
var root = document.getElementById('pl'),
qbtn = document.getElementById('qb'),
ovx = document.getElementById('ovx'),
clk = document.getElementById('clk'),
now = document.getElementById('t-now'),
fill = document.getElementById('fill');
var DUR = 180, t = 12, playing = true, last = Date.now();
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function paint() {
clk.textContent = fmt(t);
now.textContent = fmt(t);
fill.style.transform = 'scaleX(' + (t / DUR).toFixed(4) + ')';
}
paint();
setInterval(function () {
var n = Date.now();
if (playing) { t = (t + (n - last) / 1000) % DUR; paint(); }
last = n;
}, 250);
function wake() { root.classList.remove('is-demo'); }
function openOvl() {
wake(); root.classList.add('is-open');
qbtn.setAttribute('aria-expanded', 'true');
ovx.focus({ preventScroll: true });
}
function closeOvl() {
root.classList.remove('is-open');
qbtn.setAttribute('aria-expanded', 'false');
qbtn.focus({ preventScroll: true });
}
qbtn.addEventListener('click', openOvl);
ovx.addEventListener('click', closeOvl);
function flash(k) {
var el = document.querySelector('.ovl kbd[data-k="' + k + '"]');
if (!el) return;
el.classList.remove('is-hit'); void el.offsetWidth; el.classList.add('is-hit');
}
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { if (root.classList.contains('is-open')) closeOvl(); return; }
var k = e.key === ' ' ? 'space' : e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right'
: (e.key || '').toLowerCase() === 'm' ? 'm' : (e.key || '').toLowerCase() === 'f' ? 'f' : null;
if (!k) return;
e.preventDefault(); wake();
if (k === 'space') { playing = !playing; root.classList.toggle('is-paused', !playing); }
if (k === 'left') { t = Math.max(0, t - 5); paint(); }
if (k === 'right') { t = Math.min(DUR, t + 5); paint(); }
if (k === 'm') root.classList.toggle('is-mute');
if (k === 'f') root.classList.toggle('is-full');
flash(k);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ShortcutOverlay.module.scss";
export type ShortcutOverlayProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// TS7006 방지 — 'react' 의 이벤트 제네릭 대신 필요한 필드만 로컬로 선언한다
type Hit = { id: string; n: number };
type Row = { keys: { id: string; label: string }[]; what: string };
const ROWS: Row[] = [
{ keys: [{ id: "space", label: "Space" }], what: "재생·정지" },
{ keys: [{ id: "left", label: "←" }, { id: "right", label: "→" }], what: "5초 이동" },
{ keys: [{ id: "m", label: "M" }], what: "음소거" },
{ keys: [{ id: "f", label: "F" }], what: "전체화면" },
];
function fmt(s: number) {
const n = Math.floor(s);
return Math.floor(n / 60) + ":" + ("0" + (n % 60)).slice(-2);
}
function QuestionIcon() {
return (
<svg viewBox="0 0 256 256" width="17" height="17" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-10.77 20-24 20a8 8 0 0 0-8 8v8a8 8 0 0 0 16 0v-.72c18.24-3.35 32-17.9 32-35.28c0-19.85-17.94-36-40-36m104 56A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
}
function CloseIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M205.66 194.34a8 8 0 0 1-11.32 11.32L128 139.31l-66.34 66.35a8 8 0 0 1-11.32-11.32L116.69 128L50.34 61.66a8 8 0 0 1 11.32-11.32L128 116.69l66.34-66.35a8 8 0 0 1 11.32 11.32L139.31 128Z" />
</g>
</svg>
);
}
export default function ShortcutOverlay({
total = 180,
start = 12,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ShortcutOverlayProps) {
const [open, setOpen] = useState(false);
const [playing, setPlaying] = useState(true);
const [mute, setMute] = useState(false);
const [full, setFull] = useState(false);
const [t, setT] = useState(start);
const [hit, setHit] = useState<Hit>({ id: "", n: 0 });
const qbRef = useRef<HTMLButtonElement>(null);
const xRef = useRef<HTMLButtonElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — Space 로 멈추면 같이 멈춘다 (바닐라의 setInterval(250ms))
useEffect(() => {
if (!playing) return;
let last = Date.now();
const id = setInterval(() => {
const n = Date.now();
const step = (n - last) / 1000;
last = n;
setT((p) => (p + step) % total);
}, 250);
return () => clearInterval(id);
}, [playing, total]);
// 열리면 닫기 버튼으로 초점을 옮긴다 (되돌리는 건 close 에서)
useEffect(() => {
if (open) xRef.current?.focus({ preventScroll: true });
}, [open]);
// 단축키는 오버레이가 닫혀 있어도 그 자리에서 먹는다. Escape 는 오버레이만 닫는다
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
if (open) close();
return;
}
const k =
e.key === " " ? "space"
: e.key === "ArrowLeft" ? "left"
: e.key === "ArrowRight" ? "right"
: (e.key || "").toLowerCase() === "m" ? "m"
: (e.key || "").toLowerCase() === "f" ? "f"
: "";
if (!k) return;
e.preventDefault();
if (k === "space") setPlaying((v) => !v);
if (k === "left") setT((v) => Math.max(0, v - 5));
if (k === "right") setT((v) => Math.min(total, v + 5));
if (k === "m") setMute((v) => !v);
if (k === "f") setFull((v) => !v);
setHit((h) => ({ id: k, n: h.n + 1 }));
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, total]);
function close() {
setOpen(false);
qbRef.current?.focus({ preventScroll: true });
}
const cls = [
styles.player,
open ? styles.isOpen : "",
playing ? "" : styles.isPaused,
mute ? styles.isMute : "",
full ? styles.isFull : "",
].join(" ");
return (
<figure className={cls} style={vars}>
<div className={styles.screen}>
<div className={styles.stripes} aria-hidden="true" />
<div className={styles.world} aria-hidden="true">
<span className={styles.sun} />
<span className={`${styles.hill} ${styles.hillA}`} />
<span className={`${styles.hill} ${styles.hillB}`} />
</div>
<span className={styles.clock}>{fmt(t)}</span>
<span className={styles.badge}>{fmt(total)}</span>
<span className={styles.mute} aria-hidden="true">음소거</span>
<div className={styles.bar}>
<span className={styles.time}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<button
ref={qbRef}
className={styles.qb}
type="button"
aria-label="단축키 안내"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<QuestionIcon />
</button>
</div>
<span className={styles.fillwrap} aria-hidden="true">
<span className={styles.fill} style={{ transform: `scaleX(${(t / total).toFixed(4)})` }} />
</span>
<div className={styles.ovl} role="dialog" aria-modal="true" aria-label="키보드 단축키 안내">
<div className={styles.panel}>
<div className={styles.head}>
<p className={styles.title}>단축키</p>
<button ref={xRef} className={styles.x} type="button" aria-label="닫기" onClick={close}>
<CloseIcon />
</button>
</div>
<ul className={styles.list}>
{ROWS.map((r) => (
<li key={r.what} className={styles.row}>
<span className={styles.keys}>
{r.keys.map((k) => (
// key 를 바꿔 다시 마운트시켜야 눌림 애니메이션이 매번 처음부터 돈다
<kbd
key={k.id + "-" + (hit.id === k.id ? hit.n : 0)}
className={`${styles.kbd} ${hit.id === k.id ? styles.isHit : ""}`}
>
{k.label}
</kbd>
))}
</span>
<span className={styles.what}>{r.what}</span>
</li>
))}
</ul>
</div>
</div>
</div>
</figure>
);
}09 · キーボードショートカットのオーバーレイ — 計測 累積面積 43.807% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どこを変えるか
自動再生では5つのキーが順に光りますが、その順番は JavaScript ではなく負の animation-delay だけで作っています。
.pl.is-demo .ovl kbd {
animation-name: kbd-cycle;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.pl.is-demo .ovl .k-space { animation-delay: -1980ms; }
.pl.is-demo .ovl .k-left { animation-delay: -1700ms; }
.pl.is-demo .ovl .k-right { animation-delay: -1420ms; }
.pl.is-demo .ovl .k-m { animation-delay: -1180ms; }
.pl.is-demo .ovl .k-f { animation-delay: -940ms; }
どこで壊れるか — 落とし穴
いちばん高くついたのは 03 の音量ポップオーバーでした。最初の版は .vol を flex の並びにして、その子としてポップオーバーを入れていました。すると閉じているあいだも幅 152px の箱がそこに居座り、スピーカーボタンは左端ではなく枠の真ん中あたりまで押し出され、下にあった「1:25 / 3:00」の時刻表示と重なって読めなくなりました。直したのは、ポップオーバーを position: absolute; left: calc(100% + 4px) でボタンの真横へ浮かせ、時刻を右端へ逃がしたことです。開く箱を並びの中に置くと、閉じているあいだもずっとその場所の家賃を払い続けます。
2つ目は 02 のシークバーです。最初の版は塗りだけ transform: scaleX(.18) の固定値で、再生ヘッドが 54% まで進んでも青い区間は 20% のところで止まったままでした。塗り・バッファ・つまみ・ふきだしの4つを --p という数ひとつから計算し直すと、4つが必ず同じ瞬間を指します。ただしこの --p は MDN の @property の解説にあるとおり syntax: "<number>" で登録しておかないと、キーフレームのあいだで補間されず途中で飛びます。数値として登録された変数だけが、アニメーションの対象になります。
3つ目は 05 の字幕です。出ていく文が 25% で消え、入ってくる文が 25% から 30% にかけて浮かび上がる作りにしたところ、字幕のない画面が1枚だけ記録されました。24フレームの収録は 4.1667% 刻みなので、ちょうど 25.0% の位置にサンプルが1枚あり、そこが入れ替えの空白と重なったからです。切り替えを 21.99% から 22% という 0.01% 幅のカットに詰め、なめらかさは translateY の立ち上がりだけに残すと、どのフレームにも字幕が1行あります。ここまでの3つを直したあとのソースは、解凍パスワード 3ekbtjj6 で開く zip に9つ分そろえてあります。
残りは短く書きます。06 は CSS が --p を動かしているのに文字は setInterval の自前の時計で回っていて、再生ヘッドがイントロにいるのに時計は 2:49 を指していました。requestAnimationFrame の中で getComputedStyle から --p を読み、同じフレームに合わせて直しています。08 は requestFullscreen() を呼びません。iframe は allow="fullscreen" が付いていないと要求を黙って断るので、舞台の中で transform: scale(1.3) に置き換えてあり、実ページへ移すときに Fullscreen API をつなげば本物になります。07 と 08 は、scale() で大きくなった箱が overflow: hidden の中でも文書の高さに数えられる点にも引っかかりました。09 で5つのキーを負の delay で順に光らせるときは animation ショートハンドを使えません。ショートハンドは animation-delay を 0 に戻すので、5つが同時に光ります。
アクセシビリティ — reduced-motion で何が残るか
@media (prefers-reduced-motion: reduce) の指定は9つ全部に入れてありますが、そこで止めるのは飾りの繰り返しだけです。背景の縞が流れる動きと自動デモのループは消え、押下・展開・音量・チャプターといった状態の値はそのまま残ります。動きを減らす設定は、情報まで減らしてよいという意味ではないからです。値を持つ 02・03・06 には role="slider" と aria-valuemin aria-valuemax aria-valuenow が付き、MDN の slider ロールの解説が勧めるとおり3つとも aria-valuetext を添えていて、06 はそこへ章の名前まで入れて「導入 チャプター、0:24」のように読み上げます。オンとオフで意味が変わる 05・07 は aria-pressed、再生と一時停止で名前が変わる 01 は aria-label の入れ替え、04 は aria-haspopup="menu" と aria-expanded に加えて各行が role="menuitemradio" と aria-checked、08 は aria-expanded、09 は role="dialog" aria-modal="true" です。
フォーカスを移したり戻したりする 03・04・06・07・08・09 では focus({ preventScroll: true }) を使います。この部品は一覧ページに iframe で埋め込まれるので、素の focus() だと読んでいる人の画面が勝手に下へ引っ張られるからです。マウスなしで完結するのは 02・03・04・06・09 の5つで、↑↓・←→・Home・End・Enter・Space・Escape だけで操作しきれます。同じ場面で使う部品はメディアカテゴリに、押すと返事をする部品はクリックカテゴリにまとめてあります。
FAQ
動画ファイルがないのに、どうして時計が進むのですか
時計の数字は動画の再生位置ではなく、setInterval か requestAnimationFrame で足していくただの秒数です。04 では選んだ倍率をその足し算に掛けているので、2倍を選ぶと数字も倍の速さで増えます。実ページでは、この秒数を <video> の currentTime に差し替えれば、見た目はそのままで本物の再生位置になります。
素の <video controls> ではなく custom video player css にする理由は何ですか
短い動画を1本置くだけならそのままで十分です。自分で組む理由は見た目の好みではなく、標準のコントロールでは出せないものが要るときに生まれます。チャプター名の読み上げ、章の目盛り、ミニプレーヤー、ショートカット一覧などがそれにあたり、その代わりロールもキー操作も自分で引き受けることになります。
スマートフォンの幅でも崩れませんか
崩れませんでした。9つとも幅 320px で測ってはみ出しは 0 です。幅が狭いときは間隔を詰めるのではなく、340px 以下で時刻表示を隠すというように、優先度の低いものから外しています。








