04Playback speed menu
The speed button opens a list from 0.5x to 2x, and the clock beside it genuinely speeds up or slows down to match the pick. A check lands only on the chosen row.
Best forCourses and tutorials where every viewer picks their own pace
aria-haspopupcheckEscape

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