03볼륨 슬라이더 팝오버
스피커 버튼을 누르면 슬라이더가 옆에서 튀어나와 실제로 오르내리고, 볼륨이 낮아지면 아이콘이 작은 스피커로 0이면 뮤트로 바뀝니다. 아이콘 교체는 부드럽게 페이드하지 않고 한 번에 갈아 끼웁니다.
쓰임자동 재생되는 배너 영상 옆 소리를 끄거나 줄이는 통로가 컨트롤 안에 있어야 하는 자리
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="음량 조절" 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 원작 · zip 에 SCSS·React 포함 · 동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다
이 데모가 속한 글
동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다동영상 플레이어 만들기는 브라우저가 기본으로 주는 밋밋한 재생 막대를 버리고 재생 버튼·시간 바·음량·자막을 직접 그려 붙이는 일입니다. 여기 아홉 개는 영상 파일을 하나도 쓰지 않고, 화면 속 풍경까지 CSS 로 이 글의 데모 전부 보기


