동영상 플레이어 만들기 9종 — 영상 파일 없이도 진짜로 돈다
동영상 플레이어 만들기는 브라우저가 기본으로 주는 밋밋한 재생 막대를 버리고 재생 버튼·시간 바·음량·자막을 직접 그려 붙이는 일입니다. 여기 아홉 개는 영상 파일을 하나도 쓰지 않고, 화면 속 풍경까지 CSS 로 그려 그 위에서 움직입니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 재생·일시정지 모프 버튼
- 02 시간 툴팁 달린 시크 바
- 03 볼륨 슬라이더 팝오버
- 04 재생 속도 메뉴
- 05 자막 토글과 자막 스타일
- 06 챕터 마커 시크 바
- 07 미니 플레이어
- 08 풀스크린 토글
- 09 키보드 단축키 안내 오버레이
아홉 개를 늘어놓은 순서는 인기순이 아니라 사람 손이 플레이어를 만지는 차례입니다. 먼저 틀고(01), 지금 어디쯤인지 보며 옮기고(02), 소리를 줄이고(03), 재생 속도를 고릅니다(04). 그다음 셋은 화면 자체를 바꾸는 일입니다 — 자막을 켜고(05), 긴 영상을 챕터로 끊어 건너뛰고(06), 글을 계속 읽으려고 영상을 구석으로 치웁니다(07). 마지막 둘은 반대로 화면을 키우거나(08) 손을 아예 키보드로 옮기는 경우(09)입니다. 아홉 개 다 <video> 태그도 mp4 파일도 쓰지 않습니다. 창밖처럼 흐르는 풍경은 줄무늬 한 겹과 해·언덕 세 덩이를 repeating-linear-gradient 로 밀어 만든 것이고, 시간 글자와 진행 바는 CSS 가 움직이는 값을 그대로 읽어 씁니다. 480×300 무대에서 2초 루프를 24프레임으로 재어 보니 아홉 개 다 20프레임 이상에서 화면이 실제로 바뀌었고(여덟 개는 23프레임 전부), 화면 폭을 320px 로 줄여도 삐져나오는 것이 하나도 없었습니다.
01재생·일시정지 모프 버튼
버튼을 누르면 재생 삼각형이 돌면서 일시정지 두 막대로 갈아입고, 옆에 붙은 시간 표시가 실제로 흘러갑니다. 아이콘 두 장을 같은 자리에 겹쳐 두고 한 장은 rotate(-120deg) scale(.3) 에서, 다른 한 장은 rotate(120deg) scale(.3) 에서 돌아오게 해 한 덩어리가 모양을 바꾸는 것처럼 보이게 했습니다. 자동 시연에서는 상태가 바뀔 때마다 흰 테두리 원이 한 번 퍼지고, 직접 누를 때는 버튼이 살짝 눌려 들어가며 반응을 돌려줍니다.
.pp__ic {
position: absolute; inset: 0;
display: inline-flex; align-items: center; justify-content: center;
transition: opacity $duration $easing, transform $duration $easing;
}
.pp__ic--pause { opacity: 0; transform: rotate(120deg) scale(.3); }
.pl.is-playing .pp__ic--play { opacity: 0; transform: rotate(-120deg) scale(.3); }
.pl.is-playing .pp__ic--pause { opacity: 1; transform: none; }
02시간 툴팁 달린 시크 바
마우스를 올린 자리에 시간 툴팁이 따라붙고, 재생한 구간은 파랗게 차오르며 읽어 온 버퍼는 그보다 18% 앞까지 옅게 깔립니다. 채움·버퍼·손잡이가 --p 숫자 하나에서 나오고 툴팁도 마우스가 올라오기 전에는 같은 값을 읽기 때문에, 이 값을 0.18 에서 0.92 로 밀면 네 가지가 한 몸처럼 따라갑니다.
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.18; }
.sk__buf, .sk__fill {
position: absolute; inset: 0;
border-radius: $r-pill;
transform-origin: left center;
}
.sk__buf { background: rgba(23, 20, 26, .16); transform: scaleX(min(1, calc(var(--p) + .18))); }
.sk__fill { background: $color; transform: scaleX(var(--p)); }
.sk__thumbwrap {
position: absolute; left: 0; right: 0; top: 0; bottom: 0;
pointer-events: none;
transform: translateX(calc((100% - 14px) * var(--p)));
}
@keyframes sk-p {
0% { --p: 0.18; }
78% { --p: 0.92; }
88% { --p: 0.92; }
100% { --p: 0.18; }
}
03볼륨 슬라이더 팝오버
스피커 버튼을 누르면 슬라이더가 버튼 오른쪽으로 튀어나와 오르내리고, 음량이 0 에 닿으면 아이콘이 뮤트 모양으로 바뀝니다. 아이콘 교체에는 steps(1, end) 를 걸어 두 모양이 반투명하게 겹치는 프레임을 없앴습니다.
.vol {
position: absolute; left: $sp-2; bottom: $sp-2;
display: flex; align-items: flex-end; gap: $sp-1;
}
.vol__pop {
position: absolute; left: calc(100% + #{$sp-1}); bottom: 0;
display: flex; align-items: center; gap: $sp-2;
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;
opacity: 0;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
}
.pl.is-open .vol__pop { transform: none; opacity: 1; pointer-events: auto; }
04재생 속도 메뉴
배속 버튼을 누르면 0.5배부터 2배까지 여섯 줄이 위로 펼쳐지고, 고른 배속대로 아래 진행 바의 기울기가 실제로 달라집니다. 고른 줄을 표시하는 파란 막대는 줄마다 배경을 켜고 끄는 대신 --i 값만 바꿔 17px 단위로 미끄러집니다.
.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;
}
@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); }
}
@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); }
}
05자막 토글과 자막 스타일
CC 버튼을 누르면 자막이 통째로 꺼지고, 켜져 있는 동안에는 네 문장이 아래에서 떠오르며 차례로 갈아 끼워집니다. 바탕칩과 외곽선, 두 스타일을 가르는 것은 background-color 를 지우고 네 방향 text-shadow 를 세운 것뿐입니다.
@keyframes cap-1 {
0%, 21.99% { opacity: 1; transform: none; }
22%, 87.99% { opacity: 0; transform: translateY(10px); }
88% { opacity: 1; transform: translateY(10px); }
93%, 100% { opacity: 1; transform: none; }
}
@keyframes cap-2 {
0%, 21.99% { opacity: 0; transform: translateY(10px); }
22% { opacity: 1; transform: translateY(10px); }
27%, 43.99% { opacity: 1; transform: none; }
44%, 100% { opacity: 0; transform: translateY(10px); }
}
@keyframes cap-3 {
0%, 43.99% { opacity: 0; transform: translateY(10px); }
44% { opacity: 1; transform: translateY(10px); }
49%, 65.99% { opacity: 1; transform: none; }
66%, 100% { opacity: 0; transform: translateY(10px); }
}
06챕터 마커 시크 바
시크 바가 인트로·설치·예제·정리 네 구간으로 끊어져 보이고, 재생 머리가 경계를 넘는 순간 위에 뜬 챕터 이름이 한 프레임에 바뀝니다. 구간 자리는 --s·--e 퍼센트로 잡고 사이 틈은 margin-right 로 깎았기 때문에, 눈에 보이는 틈이 있어도 경계 좌표는 재생 머리와 어긋나지 않습니다. 눈금을 누르면 그 챕터 시작으로 건너뜁니다.
.ch__seg {
position: absolute; top: 0; bottom: 0;
left: calc(var(--s) * 100%);
right: calc(100% - var(--e) * 100%);
margin-right: $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .16);
overflow: hidden;
}
.ch__fill {
position: absolute; inset: 0;
display: block;
border-radius: $r-pill;
background: $color;
transform-origin: left center;
transform: scaleX(clamp(0, calc((var(--p) - var(--s)) * var(--k)), 1));
}
07미니 플레이어
미니 버튼을 누르면 플레이어가 0.44배 카드로 줄어들어 오른쪽 아래에 뜨고, 원래 자리에는 점선 자리 표시가 남습니다. 새 창을 띄우는 것이 아니라 translate(-4px, 40px) scale(.44) 하나로 옮기는 것이라 주변 글줄은 한 픽셀도 밀리지 않습니다.
$mini-scale: .44;
$mini-x: -4px;
$mini-y: 40px;
.pl {
position: absolute;
inset: 0;
transform-origin: right bottom;
transition: transform $duration $easing;
}
.pip.is-mini .pl { transform: translate($mini-x, $mini-y) scale($mini-scale); }
.pip.is-mini .pip__ph { opacity: 1; }
.pip.is-mini .pl__close { opacity: 1; pointer-events: auto; }
.pip.is-mini .pl__mini { opacity: 0; pointer-events: none; }
@keyframes pip-shrink {
0%, 26% { transform: none; }
38%, 70% { transform: translate($mini-x, $mini-y) scale($mini-scale); }
86%, 100% { transform: none; }
}
08풀스크린 토글
확장 버튼을 누르면 플레이어가 1.3배로 커지면서 모서리 둥글기가 0 으로 펴지고, 버튼 안 아이콘이 축소 모양으로 바뀝니다. 커지는 일은 width 와 height 가 아니라 transform: scale() 이 맡기 때문에 옆 요소의 레이아웃을 다시 계산하지 않습니다.
.fs {
position: relative;
box-sizing: border-box;
width: min(300px, 72vw);
transform: scale(1);
transform-origin: center center;
transition: transform $duration $easing;
}
.fs.is-full { transform: scale(1.3); }
.fs.is-full .fs__screen { border-radius: 0; }
@keyframes fs-zoom {
0%, 16% { transform: scale(1); }
38%, 64% { transform: scale(1.3); }
86%, 100% { transform: scale(1); }
}
@keyframes fs-round {
0%, 16% { border-radius: $r-card; }
38%, 64% { border-radius: 0; }
86%, 100% { border-radius: $r-card; }
}
09키보드 단축키 안내 오버레이
? 버튼을 누르면 화면이 어두워지며 단축키 네 줄이 2×2 격자로 뜨고, Space·좌우 화살표·M·F 가 목록 안에만 적혀 있는 것이 아니라 그 자리에서 실제로 듣습니다. 자동 재생에서는 다섯 개의 키 모양이 같은 kbd-cycle 키프레임을 음수 animation-delay 로 어긋나게 돌려 차례로 눌립니다.
.pl.is-demo .ovl kbd {
animation-name: kbd-cycle;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes kbd-cycle {
0%, 10% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; }
11%, 20% { transform: translateY(2px); background: $stage-yellow; color: $subject-ink; }
21%, 100% { transform: translateY(0); background: rgba(255, 255, 255, .14); color: #fff; }
}
.pl.is-demo .ovl .k-space { animation-delay: -1980ms; }
.pl.is-demo .ovl .k-left { animation-delay: -1700ms; }
.pl.is-demo .ovl .k-right { animation-delay: -1420ms; }
.pl.is-demo .ovl .k-m { animation-delay: -1180ms; }
.pl.is-demo .ovl .k-f { animation-delay: -940ms; }
어디서 깨지는가 — 함정 다섯
첫 판에서 가장 크게 틀어진 것은 03번 음량이었습니다. 팝오버를 스피커 버튼과 같은 flex 줄 안에 넣어 두었더니, 닫혀 있을 때도 152px 폭을 그대로 차지해서 버튼이 왼쪽 끝이 아니라 화면 한가운데에 섰고 오른쪽 아래의 "1:24 / 3:00" 시간 글자와 겹쳤습니다. 닫힌 상자는 보이지 않을 뿐 자리는 내놓지 않습니다. position: absolute; left: calc(100% + 4px) 로 버튼 옆에 띄우고 시간 글자를 오른쪽으로 보내고 나서야 버튼이 제자리에 붙었습니다.
둘째는 02번 시크 바에서 나왔습니다. 파란 채움을 transform: scaleX(.18) 고정값으로 두고 손잡이만 움직였더니, 재생 머리는 54%까지 갔는데 채워진 구간은 20% 에 멈춰 있는 장면이 나왔습니다. 눈으로 보면 "재생이 안 된 곳을 재생 머리가 지나간" 꼴이라 한눈에 이상합니다. 채움·버퍼·손잡이·툴팁을 전부 --p 하나에서 계산하도록 고쳤는데, 여기에는 조건이 하나 붙습니다. --p 를 @property 로 syntax: "<number>" 라고 등록해 두지 않으면 브라우저는 그 값을 그냥 글자로 보고 키프레임 사이를 보간하지 않습니다. 등록 전에는 바가 중간에 툭툭 끊겨 뛰었습니다. 06번 챕터 바도 같은 등록을 씁니다.
셋째는 자막입니다. 05번의 첫 판은 나가는 문장이 25% 에서 사라지고 들어오는 문장이 25% 에서 30% 사이에 떠오르게 되어 있었습니다. 2초를 12fps 로 뽑으면 프레임 간격이 정확히 4.1667% 라 25.0% 지점이 샘플에 그대로 걸렸고, 그 한 장이 자막이 하나도 없는 빈 화면이었습니다. 그래서 opacity 는 21.99% 에서 22% 로 0.01% 폭만 주어 컷으로 갈아 끼우고, 부드러움은 translateY 로 떠오르는 쪽에만 남겼습니다. 겹치는 프레임도 비는 프레임도 없어진 것은 이 뒤였습니다. 같은 문제가 06번에도 있었는데 그쪽은 방향이 달랐습니다. CSS 가 --p 를 움직이는데 시간 글자만 setInterval 로 따로 세었더니, 재생 머리는 첫 챕터에 있는데 시간 글자는 2:49 를 가리켰습니다. requestAnimationFrame 안에서 getComputedStyle 로 --p 를 읽어 같은 프레임에 맞추자 둘이 붙었습니다.
넷째는 크기입니다. scale() 로 키운 상자는 overflow: hidden 이 걸려 있어도 브라우저가 재는 문서 높이에는 커진 크기 그대로 잡힙니다. 눈으로는 무대 안에 얌전히 들어가 있는데 폭을 320px 로 줄여 재면 세로 스크롤이 생기는 식이라, 화면만 봐서는 원인이 안 보입니다. 07·08번이 나란히 여기 걸렸습니다. 08번은 1.3배로 커질 자리를 미리 비워 두려고 플레이어 폭을 min(300px, 72vw) 로 좁히고 나서야 무대 안쪽에 들어왔습니다. 07번은 줄어드는 쪽인데도 문서 높이를 늘렸는데, 카드가 transform-origin: right bottom 을 축 삼아 아래로 40px 내려가면서 그만큼의 자리를 문서 아래쪽에 새로 만들었기 때문입니다.
다섯째는 차례입니다. 09번의 키 다섯 개는 같은 키프레임 하나를 음수 animation-delay 로 -1980ms 부터 -940ms 까지 어긋나게 돌려, Space·왼쪽·오른쪽·M·F 순으로 눌리게 해 두었습니다. 이 다섯 줄을 animation 단축 한 줄로 합치면 단축에 적지 않은 animation-delay 가 전부 0 으로 초기화됩니다. 어긋나게 준 값이 통째로 지워지니 다섯 개가 같은 프레임에 함께 빛나고, 하나씩 눌리는 그림이 사라집니다. 눈에 보이는 결과는 "키가 순서대로 안 눌린다" 인데 원인은 단축 속성 한 줄이라, 키프레임 쪽만 들여다보면 하루를 써도 안 나옵니다. animation-name 과 animation-duration 처럼 개별 속성으로만 적어야 지연이 살아남습니다. 여기까지 고친 아홉 벌이 그대로 zip 에 들어 있고, 그 압축 비밀번호는 3ekbtjj6 입니다.
접근성 — reduced-motion 에서 무엇이 남는가
움직임을 줄여 달라는 설정에서 끄는 것은 장식용 반복뿐입니다. 창밖 줄무늬 패닝과 자동 시연 루프는 멈추지만, 눌림·열림·음량·챕터 같은 상태 값은 그대로 남아 지금 무엇이 켜져 있는지 화면에서 계속 읽힙니다. 아홉 개 모두 @media (prefers-reduced-motion: reduce) 블록이 있고, 그 안에서 animation: none 과 transition: none 을 같이 끕니다.
| 번호 | 붙은 역할·상태 | 움직임을 줄였을 때 남는 것 |
|---|---|---|
| 01 | aria-label 이 재생과 일시정지로 교체 |
아이콘은 도는 과정 없이 바로 바뀐 모양 |
| 02 · 03 | role="slider" + aria-valuemin/max/now |
채움과 손잡이가 값 위치에 그대로 서 있음 |
| 04 | aria-haspopup="menu" · role="menuitemradio" |
열린 메뉴와 고른 줄의 체크 |
| 05 | aria-pressed (CC 와 스타일 두 칸) |
켜진 자막 문장과 고른 스타일 |
| 06 | aria-valuetext 로 "설치 챕터, 0:24" |
네 구간과 재생 머리 위치 |
| 07 | aria-pressed + aria-label="미니 플레이어" |
줄어든 카드와 점선 자리 표시 |
| 08 | aria-expanded |
커진 화면과 축소 아이콘 |
| 09 | role="dialog" aria-modal="true" |
열린 목록과 키 모양 |
숫자만 읽어 주면 모자랄 때가 있습니다. 06번이 aria-valuetext 에 "설치 챕터, 0:24" 처럼 챕터 이름을 실어 보내는 이유가 그것이고, 쓰인 속성들의 정의는 MDN 의 slider 역할 문서에 있습니다. 04·06·09번은 마우스 없이도 끝까지 갑니다. 초점을 돌려놓을 때 focus({ preventScroll: true }) 를 쓰는 것은 부품이 iframe 안에 박혀 있어, 옵션 없이 부르면 읽던 화면이 저 혼자 뛰기 때문입니다.
영상·오디오 자리에서 쓰는 부품은 미디어 카테고리에, 눌러야 반응하는 부품은 클릭 카테고리에 모아 두었습니다.
FAQ
영상 파일이 없는 컨트롤을 진짜 <video> 위에 그대로 올릴 수 있나요?
껍데기는 그대로 쓸 수 있습니다. 풍경 요소를 <video> 로 갈아 끼우고 진행 값 --p 와 시간 글자를 timeupdate 이벤트에서 채우면 됩니다. 바꾸는 것은 값을 어디서 받아 오느냐뿐입니다.
08번 전체화면 버튼은 왜 진짜 전체화면이 아닌가요?
이 페이지에 실린 데모가 iframe 안에서 돌기 때문입니다. iframe 은 allow="fullscreen" 을 받지 않으면 requestFullscreen() 요청이 조용히 거절되므로, 무대 안에서 transform: scale(1.3) 으로 같은 느낌만 흉내 냈습니다. 실제 페이지에 옮길 때는 Fullscreen API 를 부르고, 이 코드의 아이콘 교체와 aria-expanded 처리는 그대로 두면 됩니다.
숫자 하나로 여러 요소를 움직이는 방식이 성능에 불리하지 않나요?
--p 가 바뀔 때마다 그 값을 읽는 요소의 스타일이 다시 계산되는 것은 맞습니다. 다만 계산 결과가 들어가는 자리는 transform 과 opacity 뿐이라 레이아웃을 다시 재는 단계가 없고, 한 부품이 읽는 요소도 네 개 안쪽입니다.