03Volume slider popover
Clicking the speaker pops a slider out beside it that really moves, and the icon steps down to a small speaker, then to mute at zero. Icon swaps cut in one frame instead of cross-fading.
Best forAutoplaying banner videos that need their mute-and-adjust path inside the controls
role=slidersteps(1,end)popover

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