06Chapter marker seek bar
The bar breaks into ticks per chapter, and crossing a boundary swaps the chapter name above. Clicking a tick actually jumps the playhead to that chapter's start.
Best forReviews and highlight reels where viewers arrive for one specific segment
segmentaria-valuetextclick

<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">0:08</span>
<span class="pl__badge">3:00</span>
<div class="nm">
<div class="nm__list" id="nm">
<b class="nm__i">1 · Intro</b>
<b class="nm__i">2 · Setup</b>
<b class="nm__i">3 · Example</b>
<b class="nm__i">4 · Recap</b>
</div>
</div>
</div>
<div class="sk__row">
<span class="sk__t"><b id="t-now">0:08</b> / 3:00</span>
<div class="sk" id="sk" role="slider" tabindex="0" aria-label="Seek position" aria-valuemin="0" aria-valuemax="180" aria-valuenow="8" aria-valuetext="Intro chapter, 0:08">
<div class="ch" id="ch">
<span class="ch__seg" style="--s:0;--e:.1333;--k:7.5"><i class="ch__fill"></i></span>
<span class="ch__seg" style="--s:.1333;--e:.4;--k:3.75"><i class="ch__fill"></i></span>
<span class="ch__seg" style="--s:.4;--e:.7778;--k:2.647"><i class="ch__fill"></i></span>
<span class="ch__seg ch__seg--end" style="--s:.7778;--e:1;--k:4.5"><i class="ch__fill"></i></span>
<span class="ch__head" aria-hidden="true"><i class="ch__dot"></i></span>
</div>
</div>
</div>
</figure>
</main>@use "../../../../site/static/demo-tokens" as *;
// --p 는 등록된 사용자 지정 속성 — 키프레임에서 보간된다 (미등록이면 steps 처럼 튄다)
@property --p { syntax: "<number>"; inherits: true; initial-value: 0.0444; }
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: $r-card;
background: $stage-paper;
box-shadow: $shadow-raised;
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(76px, 22vw, 108px);
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: 16%; top: 10%;
width: 22px; height: 22px; 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); }
// ── 챕터 이름 — 한 줄짜리 창으로 4줄을 굴린다. 교체는 steps(1,end) 로 한 프레임 컷 ──
.nm {
position: absolute; left: $sp-2; bottom: $sp-2;
height: 20px;
padding-left: $sp-2; padding-right: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .74);
overflow: hidden;
}
.nm__list { translate: 0 calc(var(--i, 0) * -20px); }
.nm__i {
display: block;
height: 20px;
color: #fff;
font-size: 11px; line-height: 20px; font-weight: 700;
white-space: nowrap;
}
.sk__row {
display: flex; align-items: center; gap: $sp-3;
padding: $sp-3;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, .72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px; line-height: 14px; font-weight: 600;
}
.sk {
position: relative;
display: flex; align-items: center;
flex: 1 1 auto;
box-sizing: border-box;
height: 36px;
padding-left: $sp-2; padding-right: $sp-2;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible { outline: 2px solid $color; outline-offset: 2px; border-radius: $r-sm; }
// 구간은 % 로 자리를 잡는다 — 틈을 margin-right 로 깎아도 경계 좌표가 안 밀려 재생 머리와 딱 맞는다
.ch {
--p: 0.0444;
position: relative;
flex: 1 1 auto;
height: 12px;
}
.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__seg--end { margin-right: 0; }
.ch__seg:hover { background: rgba(23, 20, 26, .26); }
.ch__fill {
position: absolute; inset: 0;
display: block;
border-radius: $r-pill;
background: $color;
transform-origin: left center; // 규칙8: 좌변서 차오름
// 구간 안 진행률 = (p − 시작) × 1/(끝−시작), 0~1 로 잘라낸다
transform: scaleX(clamp(0, calc((var(--p) - var(--s)) * var(--k)), 1));
}
.ch__head {
position: absolute; top: -6px; bottom: -6px;
left: calc(var(--p) * 100%);
width: 4px;
border-radius: $r-pill;
background: $subject-ink;
transform: translateX(-50%);
pointer-events: none;
}
.ch__dot {
position: absolute; left: 50%; top: -4px;
width: 12px; height: 12px;
border-radius: 50%;
background: $stage-paper;
box-shadow: 0 2px 6px rgba(23, 20, 26, .45);
transform: translateX(-50%);
}
// ── 자동 루프: 재생 머리가 바를 지나며 구간을 차례로 채우고, 경계를 넘을 때마다 이름이 갈린다 ──
.pl.is-demo .ch {
animation-name: ch-p;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ch-p {
0% { --p: 0.0444; }
80% { --p: 0.94; }
86% { --p: 0.94; }
87% { --p: 0.0444; }
100% { --p: 0.0444; }
}
.pl.is-demo .nm__list {
animation-name: nm-roll, nm-rise;
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
// 경계 시각: --p 가 .1333 / .4 / .7778 을 지나는 순간 (7.9% · 31.8% · 65.5%), 복귀는 87%
@keyframes nm-roll {
0% { translate: 0 0; }
7.9% { translate: 0 -20px; }
31.8% { translate: 0 -40px; }
65.5% { translate: 0 -60px; }
87% { translate: 0 0; }
100% { translate: 0 0; }
}
@keyframes nm-rise {
0% { transform: translateY(0); }
7.8% { transform: translateY(0); }
7.9% { transform: translateY(4px); }
15.9% { transform: translateY(0); }
31.7% { transform: translateY(0); }
31.8% { transform: translateY(4px); }
39.8% { transform: translateY(0); }
65.4% { transform: translateY(0); }
65.5% { transform: translateY(4px); }
73.5% { transform: translateY(0); }
86.9% { transform: translateY(0); }
87% { transform: translateY(4px); }
95% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@media (max-width: 340px) {
.sk__row { gap: $sp-2; padding: $sp-2; }
.sk { height: 32px; }
.nm { bottom: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .ch, .pl.is-demo .nm__list, .pl__stripes { animation: none !important; }
.ch__fill, .ch__head { transition: none; }
}@property --p {
syntax: "<number>";
inherits: true;
initial-value: 0.0444;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pl {
box-sizing: border-box;
width: min(392px, 92vw);
margin: 0;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.pl__screen {
position: relative;
height: clamp(76px, 22vw, 108px);
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: 16%;
top: 10%;
width: 22px;
height: 22px;
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);
}
.nm {
position: absolute;
left: 8px;
bottom: 8px;
height: 20px;
padding-left: 8px;
padding-right: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.74);
overflow: hidden;
}
.nm__list {
translate: 0 calc(var(--i, 0) * -20px);
}
.nm__i {
display: block;
height: 20px;
color: #fff;
font-size: 11px;
line-height: 20px;
font-weight: 700;
white-space: nowrap;
}
.sk__row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
}
.sk__t {
flex: 0 0 auto;
color: rgba(23, 20, 26, 0.72);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11px;
line-height: 14px;
font-weight: 600;
}
.sk {
position: relative;
display: flex;
align-items: center;
flex: 1 1 auto;
box-sizing: border-box;
height: 36px;
padding-left: 8px;
padding-right: 8px;
cursor: pointer;
touch-action: none;
}
.sk:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 8px;
}
.ch {
--p: 0.0444;
position: relative;
flex: 1 1 auto;
height: 12px;
}
.ch__seg {
position: absolute;
top: 0;
bottom: 0;
left: calc(var(--s) * 100%);
right: calc(100% - var(--e) * 100%);
margin-right: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
overflow: hidden;
}
.ch__seg--end {
margin-right: 0;
}
.ch__seg:hover {
background: rgba(23, 20, 26, 0.26);
}
.ch__fill {
position: absolute;
inset: 0;
display: block;
border-radius: 999px;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(clamp(0, (var(--p) - var(--s)) * var(--k), 1));
}
.ch__head {
position: absolute;
top: -6px;
bottom: -6px;
left: calc(var(--p) * 100%);
width: 4px;
border-radius: 999px;
background: #17141a;
transform: translateX(-50%);
pointer-events: none;
}
.ch__dot {
position: absolute;
left: 50%;
top: -4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ffffff;
box-shadow: 0 2px 6px rgba(23, 20, 26, 0.45);
transform: translateX(-50%);
}
.pl.is-demo .ch {
animation-name: ch-p;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes ch-p {
0% {
--p: 0.0444;
}
80% {
--p: 0.94;
}
86% {
--p: 0.94;
}
87% {
--p: 0.0444;
}
100% {
--p: 0.0444;
}
}
.pl.is-demo .nm__list {
animation-name: nm-roll, nm-rise;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes nm-roll {
0% {
translate: 0 0;
}
7.9% {
translate: 0 -20px;
}
31.8% {
translate: 0 -40px;
}
65.5% {
translate: 0 -60px;
}
87% {
translate: 0 0;
}
100% {
translate: 0 0;
}
}
@keyframes nm-rise {
0% {
transform: translateY(0);
}
7.8% {
transform: translateY(0);
}
7.9% {
transform: translateY(4px);
}
15.9% {
transform: translateY(0);
}
31.7% {
transform: translateY(0);
}
31.8% {
transform: translateY(4px);
}
39.8% {
transform: translateY(0);
}
65.4% {
transform: translateY(0);
}
65.5% {
transform: translateY(4px);
}
73.5% {
transform: translateY(0);
}
86.9% {
transform: translateY(0);
}
87% {
transform: translateY(4px);
}
95% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.sk__row {
gap: 8px;
padding: 8px;
}
.sk {
height: 32px;
}
.nm {
bottom: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pl.is-demo .ch, .pl.is-demo .nm__list, .pl__stripes {
animation: none !important;
}
.ch__fill, .ch__head {
transition: none;
}
}// 실제 동작: chapter 눈금을 누르면 그 chapter 시작으로 건너뛰고, ←→ 5초 · Home/End 로도 움직인다.
var root = document.getElementById('pl'), sk = document.getElementById('sk'), ch = document.getElementById('ch'),
nm = document.getElementById('nm'), clock = document.getElementById('clock'), now = document.getElementById('t-now');
var DUR = 180, CH = [[0, 'Intro'], [24, 'Setup'], [72, 'Example'], [140, 'Recap']], t = 8;
function fmt(s) { s = Math.floor(s); return Math.floor(s / 60) + ':' + ('0' + (s % 60)).slice(-2); }
function idx(s) { var i = 0, k; for (k = 0; k < CH.length; k++) { if (s >= CH[k][0]) i = k; } return i; }
function label() {
var i = idx(t);
clock.textContent = fmt(t);
now.textContent = fmt(t);
sk.setAttribute('aria-valuenow', Math.floor(t));
sk.setAttribute('aria-valuetext', CH[i][1] + ' chapter, ' + fmt(t));
return i;
}
function paint() {
ch.style.setProperty('--p', (t / DUR).toFixed(4));
nm.style.setProperty('--i', label());
}
// 자동 루프 중엔 CSS 가 --p 를 움직인다 — 글자는 그리기 직전(rAF)에 그 값을 읽어 같은 프레임에 맞춘다.
// setInterval 로 읽으면 프레임 사이에 값이 어긋나 시계가 Play 머리와 따로 논다 (실측 2026-09-12: 2:49 인데 머리는 Intro)
var prev = 0;
requestAnimationFrame(function tick(ms) {
if (root.classList.contains('is-demo')) {
var v = parseFloat(getComputedStyle(ch).getPropertyValue('--p'));
if (v === v) { t = v * DUR; label(); }
} else if (ms - prev > 80) {
prev = ms; t = (t + .08) % DUR; paint();
}
requestAnimationFrame(tick);
});
function take() { root.classList.remove('is-demo'); }
[].forEach.call(ch.querySelectorAll('.ch__seg'), function (el, i) {
el.addEventListener('click', function () { take(); t = CH[i][0]; paint(); sk.focus({ preventScroll: true }); });
});
sk.addEventListener('keydown', function (e) {
var k = e.key;
if (k === 'ArrowRight') { t = Math.min(DUR - 1, t + 5); }
else if (k === 'ArrowLeft') { t = Math.max(0, t - 5); }
else if (k === 'Home') { t = 0; }
else if (k === 'End') { t = DUR - 1; }
else { return; }
take(); paint(); e.preventDefault();
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChapterMarkers.module.scss";
export type Chapter = { at: number; name: string };
export type ChapterMarkersProps = {
/** 더미 영상 길이(초) */
total?: number;
/** 시작 위치(초) */
start?: number;
/** 챕터 시작 시각과 이름 */
chapters?: Chapter[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type NameVars = 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 clamp01(v: number) {
return Math.max(0, Math.min(1, v));
}
export default function ChapterMarkers({
total = 180,
start = 8,
chapters = [
{ at: 0, name: "인트로" },
{ at: 24, name: "설치" },
{ at: 72, name: "예제" },
{ at: 140, name: "정리" },
],
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ChapterMarkersProps) {
const [t, setT] = useState(start);
const skRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 더미 재생 시계 — 바닐라의 rAF(실시간 1배) 를 setInterval(250ms, +0.25초) 로
useEffect(() => {
const id = setInterval(() => setT((p) => (p + 0.25) % total), 250);
return () => clearInterval(id);
}, [total]);
// 구간은 % 로 자리를 잡는다 — s(시작) · e(끝) · k = 1/(e−s) 로 구간 안 진행률을 낸다
const segs = chapters.map((c, i) => {
const s = c.at / total;
const e = (i + 1 < chapters.length ? chapters[i + 1].at : total) / total;
return { at: c.at, name: c.name, s, e, k: 1 / (e - s) };
});
let i = 0;
for (let k = 0; k < chapters.length; k++) if (t >= chapters[k].at) i = k;
const p = t / total;
const nameVars: NameVars = { "--i": String(i) };
return (
<figure className={styles.player} 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.nm}>
<div className={styles.nmList} style={nameVars}>
{chapters.map((c, k) => (
<b key={c.at} className={styles.nmItem}>
{k + 1} · {c.name}
</b>
))}
</div>
</div>
</div>
<div className={styles.row}>
<span className={styles.rowTime}>
<b>{fmt(t)}</b> / {fmt(total)}
</span>
<div
ref={skRef}
className={styles.sk}
role="slider"
tabIndex={0}
aria-label="재생 위치"
aria-valuemin={0}
aria-valuemax={total}
aria-valuenow={Math.floor(t)}
aria-valuetext={`${chapters[i].name} 챕터, ${fmt(t)}`}
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowRight") { setT((v) => Math.min(total - 1, v + 5)); e.preventDefault(); }
else if (e.key === "ArrowLeft") { setT((v) => Math.max(0, v - 5)); e.preventDefault(); }
else if (e.key === "Home") { setT(0); e.preventDefault(); }
else if (e.key === "End") { setT(total - 1); e.preventDefault(); }
}}
>
<div className={styles.ch}>
{segs.map((g, k) => (
<span
key={g.at}
className={`${styles.seg} ${k === segs.length - 1 ? styles.segEnd : ""}`}
style={{ left: `${(g.s * 100).toFixed(4)}%`, right: `${(100 - g.e * 100).toFixed(4)}%` }}
onClick={() => {
setT(g.at);
skRef.current?.focus({ preventScroll: true });
}}
>
<i
className={styles.segFill}
style={{ transform: `scaleX(${clamp01((p - g.s) * g.k).toFixed(4)})` }}
/>
</span>
))}
<span className={styles.head} style={{ left: `${(p * 100).toFixed(4)}%` }} aria-hidden="true">
<i className={styles.dot} />
</span>
</div>
</div>
</div>
</figure>
);
}06 Chapter marker seek bar — Measured area 29.851% · 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


