Podcast Chapters
An episode player with chapters: the current one fills as it plays, tap to jump, skip back or ahead, change speed.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/podcast-chapters.json'use client'
import { useEffect, useRef, useState } from 'react'
import './podcast-chapters.css'
export interface Chapter { title: string; start: number }
export interface PodcastChaptersProps {
chapters: Chapter[]
duration: number
title?: string
show?: string
marks?: boolean
fill?: 'dark' | 'light'
className?: string
}
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
const SPEEDS = [1, 1.25, 1.5, 2, 0.8]
/** Episode player with chapter navigation. */
export function PodcastChapters({ chapters, duration, title = 'Episode', show = 'Podcast', marks = true, fill = 'dark', className }: PodcastChaptersProps) {
const [t, setT] = useState(chapters[1]?.start ?? 0)
const [play, setPlay] = useState(false)
const [spd, setSpd] = useState(1)
const bar = useRef<HTMLDivElement>(null)
const drag = useRef(false)
useEffect(() => {
if (!play) return
let raf = 0, last = performance.now()
const step = (now: number) => { setT((x) => { const n = x + ((now - last) / 1000) * spd * 8; if (n >= duration) { setPlay(false); return duration } return n }); last = now; raf = requestAnimationFrame(step) }
raf = requestAnimationFrame(step)
return () => cancelAnimationFrame(raf)
}, [play, spd, duration])
const cur = chapters.reduce((a, c, i) => (t >= c.start ? i : a), 0)
const end = (i: number) => chapters[i + 1]?.start ?? duration
const seek = (e: React.PointerEvent) => { const r = bar.current!.getBoundingClientRect(); setT(Math.max(0, Math.min(duration, ((e.clientX - r.left) / r.width) * duration))) }
const skip = (d: number) => setT((x) => Math.max(0, Math.min(duration, x + d)))
return (
<div className={['sd-pod', className].filter(Boolean).join(' ')} data-fill={fill} tabIndex={-1}
onKeyDown={(e) => { if (e.target !== e.currentTarget) return; if (e.key === ' ') { e.preventDefault(); setPlay(!play) } if (e.key === 'ArrowLeft') skip(-15); if (e.key === 'ArrowRight') skip(30) }}>
<div className="sd-pod__h"><span className="sd-pod__art" aria-hidden>{show.slice(0, 2)}</span><div><b>{title}</b><small>{show} · {chapters[cur].title}</small></div></div>
<div ref={bar} className="sd-pod__scrub" role="slider" tabIndex={0} aria-label="Position" aria-valuemin={0} aria-valuemax={duration} aria-valuenow={Math.round(t)} aria-valuetext={`${mmss(t)}, ${chapters[cur].title}`}
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = true; seek(e) }} onPointerMove={(e) => drag.current && seek(e)} onPointerUp={() => (drag.current = false)}
onKeyDown={(e) => { if (e.key === 'ArrowLeft') { e.preventDefault(); skip(-15) } if (e.key === 'ArrowRight') { e.preventDefault(); skip(30) } }}>
<span className="sd-pod__rail"><i style={{ width: `${(t / duration) * 100}%` }} /></span>
{marks && chapters.slice(1).map((c) => <span key={c.start} className="sd-pod__mk" style={{ left: `${(c.start / duration) * 100}%` }} />)}
<span className="sd-pod__th" style={{ left: `${(t / duration) * 100}%` }} />
</div>
<div className="sd-pod__times"><span>{mmss(t)}</span><span>−{mmss(duration - t)}</span></div>
<div className="sd-pod__ctl">
<button type="button" className="sd-pod__spd" aria-label={`Speed ${spd}x. Change`} onClick={() => setSpd(SPEEDS[(SPEEDS.indexOf(spd) + 1) % SPEEDS.length])}>{spd}×</button>
<button type="button" className="sd-pod__sk" aria-label="Back 15 seconds" onClick={() => skip(-15)}><svg viewBox="0 0 24 24" aria-hidden><path d="M5 12a7 7 0 1 0 2-5M7 3v4h4" /></svg><span>15</span></button>
<button type="button" className="sd-pod__pl" aria-label={play ? 'Pause' : 'Play'} aria-pressed={play} onClick={() => setPlay(!play)}><svg viewBox="0 0 20 20" aria-hidden>{play ? <path d="M5 3h3.5v14H5zM11.5 3H15v14h-3.5z" /> : <path d="M6 3v14l11-7z" />}</svg></button>
<button type="button" className="sd-pod__sk" aria-label="Forward 30 seconds" onClick={() => skip(30)}><svg viewBox="0 0 24 24" aria-hidden><path d="M19 12a7 7 0 1 1-2-5M17 3v4h-4" /></svg><span>30</span></button>
<span style={{ minWidth: 44 }} />
</div>
<ol className="sd-pod__list" aria-label="Chapters">
{chapters.map((c, i) => (
<li key={c.start}>
<button type="button" className="sd-pod__c" aria-current={i === cur} onClick={() => { setT(c.start); setPlay(true) }}>
<span>{mmss(c.start)}</span>{c.title}
{i === cur && <i style={{ width: `${((t - c.start) / (end(i) - c.start)) * 100}%` }} />}
</button>
</li>
))}
</ol>
</div>
)
}.sd-pod { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-pod[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-pod :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-pod :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-pod .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-pod { width: min(100%, 330px); padding: 16px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); outline: 0; }
.sd-pod__h { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
.sd-pod__art { width: 52px; height: 52px; flex: none; border-radius: 12px; background: var(--strong); color: var(--on); display: grid; place-items: center; font-weight: 800; font-size: 18px; letter-spacing: -0.04em; }
.sd-pod__h b { display: block; font-size: 14.5px; line-height: 1.3; }
.sd-pod__h small { font-size: 12px; color: var(--mute); }
.sd-pod__scrub { position: relative; height: 22px; touch-action: none; cursor: pointer; outline: 0; }
.sd-pod__scrub:focus-visible { box-shadow: 0 0 0 2px var(--fg); border-radius: 6px; }
.sd-pod__rail { position: absolute; left: 0; right: 0; top: 9px; height: 4px; border-radius: 99px; background: var(--soft); overflow: hidden; }
.sd-pod__rail i { display: block; height: 100%; background: var(--fg); }
.sd-pod__mk { position: absolute; top: 7px; width: 2px; height: 8px; margin-left: -1px; background: var(--bg); }
.sd-pod__th { position: absolute; top: 5px; width: 12px; height: 12px; margin-left: -6px; border-radius: 99px; background: var(--fg); }
.sd-pod__times { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-pod__ctl { display: flex; align-items: center; justify-content: center; gap: 18px; margin: 10px 0 14px; }
.sd-pod__sk { position: relative; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 99px; }
.sd-pod__sk:hover { background: var(--soft); }
.sd-pod__sk svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; }
.sd-pod__sk span { position: absolute; font-size: 10.5px; font-weight: 700; top: 14px; }
.sd-pod__pl { display: grid; place-items: center; width: 54px; height: 54px; border-radius: 99px; background: var(--strong) !important; color: var(--on) !important; }
.sd-pod__pl svg { width: 20px; height: 20px; fill: currentColor; }
.sd-pod__spd { min-width: 44px; padding: 6px 8px; border-radius: 99px; font-size: 12px; font-weight: 700; box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-pod__list { list-style: none; margin: 0; padding: 0; max-height: 196px; overflow-y: auto; }
.sd-pod__c { position: relative; display: flex; gap: 12px; width: 100%; padding: 9px 10px; border-radius: 10px; text-align: left; font-size: 13.5px; overflow: hidden; }
.sd-pod__c:hover { background: var(--soft); }
.sd-pod__c[aria-current='true'] { background: var(--soft); font-weight: 600; }
.sd-pod__c span { width: 40px; flex: none; color: var(--mute); font-variant-numeric: tabular-nums; font-weight: 400; }
.sd-pod__c i { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--fg); }
@media (prefers-reduced-motion: reduce) { .sd-pod *, .sd-pod *::before, .sd-pod *::after { animation: none !important; transition: none !important; } }Overview
The chapter list doubles as navigation: each row shows its start time and a thin progress line while it plays. The scrubber marks chapter boundaries.
Skip back 15s and forward 30s, and cycle playback speed. Time is simulated in the demo; wire it to an audio element in production.
When to use
- Podcast apps, course lessons, long talks.
When not to use
- Music tracks with no structure.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/podcast-chapters.json$
npx shadcn@latest add https://sundaeui.dev/r/podcast-chapters.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/podcast-chapters.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/podcast-chapters.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy podcast-chapters.tsx and podcast-chapters.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { PodcastChapters } from '@/components/sundae/podcast-chapters'
<PodcastChapters />Props
| Prop | Type | Default |
|---|---|---|
chapters*Chapter starts in seconds. | { title: string; start: number }[] | — |
duration*Episode length. | number | — |
titleEpisode title. | string | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Chapters are buttons with their start time; the current one has aria-current.
- The scrubber is a slider with time text.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-pod; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Space
- Play / pause
- ← / →
- Back 15s / forward 30s
FAQ
- Is Podcast Chapters free?
- Yes. Podcast Chapters is free for personal and commercial projects, with full source on this page.
- How do I install Podcast Chapters?
- Run npx shadcn@latest add https://sundaeui.dev/r/podcast-chapters.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Podcast Chapters support keyboard and screen readers?
- Chapters are buttons with their start time; the current one has aria-current. The scrubber is a slider with time text.
- Does Podcast Chapters respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Mini Player
Components · Expressive Voice Note
Components · RefinedLightbox Gallery
Components · Refined- Pro
Voice Recorder
Components · Refined Mixer Faders
Components · Refined- Pro
Glass Control Center
Components · Refined