04再生速度メニュー
速度ボタンを押すと0.5倍から2倍までの一覧が開き、選んだ速さで横の再生時計が実際に速くなったり遅くなったりします。選んだ項目にだけチェックが付きます。
使いどころオンライン講座やチュートリアルなど、視聴速度を人それぞれに選ばせる場所
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="再生速度">
<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 に同梱 · video player css で組む カスタム動画プレーヤー 9選 — 動画ファイルなしで動く
このデモが属する記事
video player css で組む カスタム動画プレーヤー 9選 — 動画ファイルなしで動くvideo player css とは、mp4 を1本も置かずに、再生ボタン・シークバー・音量つまみといった操作まわりだけを HTML と CSS で組み立てるやり方です。ここに並ぶ9つは絵ではなく、押せばその場で状態が変わります。 この記事のデモをすべて見る


