Voice Note
A chat voice-message bubble: play / pause, a waveform that fills as it plays and can be scrubbed, plus 1× / 1.5× / 2×.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/voice-note.json'use client'
import { useEffect, useRef, useState } from 'react'
import './voice-note.css'
export interface VoiceNoteProps {
duration: number
peaks: number[]
fromMe?: boolean
sentAt?: string
onSeek?: (s: number) => void
fill?: 'dark' | 'light'
className?: string
}
const SPEEDS = [1, 1.5, 2]
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`
/** Voice message bubble with scrubbable waveform. */
export function VoiceNote({ duration, peaks, fromMe = false, sentAt = '9:41', onSeek, fill = 'dark', className }: VoiceNoteProps) {
const [t, setT] = useState(0)
const [playing, setPlaying] = useState(false)
const [spd, setSpd] = useState(0)
const wave = useRef<HTMLDivElement>(null)
const drag = useRef(false)
useEffect(() => {
if (!playing) return
let raf = 0, last = performance.now()
const tick = (now: number) => {
const dt = ((now - last) / 1000) * SPEEDS[spd]; last = now
setT((x) => { const n = x + dt; if (n >= duration) { setPlaying(false); return 0 } return n })
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [playing, spd, duration])
const seek = (cx: number) => { const r = wave.current!.getBoundingClientRect(); const s = Math.max(0, Math.min(1, (cx - r.left) / r.width)) * duration; setT(s); onSeek?.(s) }
const p = t / duration
return (
<div className={['sd-vn', className].filter(Boolean).join(' ')} data-fill={fill} data-me={fromMe || undefined}>
<button type="button" className="sd-vn__play" aria-label={playing ? 'Pause voice message' : 'Play voice message'} aria-pressed={playing} onClick={() => setPlaying(!playing)}>
<svg viewBox="0 0 14 14" aria-hidden>{playing ? <path d="M3.5 2.5h2.5v9H3.5zM8 2.5h2.5v9H8z" /> : <path d="M4 2.2v9.6L11.8 7z" />}</svg>
</button>
<div className="sd-vn__mid">
<div
ref={wave}
className="sd-vn__wave"
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(t)}
aria-valuetext={`${mmss(t)} of ${mmss(duration)}`}
onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); drag.current = true; seek(e.clientX) }}
onPointerMove={(e) => drag.current && seek(e.clientX)}
onPointerUp={() => { drag.current = false }}
onKeyDown={(e) => {
if (e.key === ' ') { e.preventDefault(); setPlaying(!playing) }
const d = e.key === 'ArrowRight' ? 5 : e.key === 'ArrowLeft' ? -5 : 0
if (d) { e.preventDefault(); setT((x) => Math.max(0, Math.min(duration, x + d))) }
}}
>
{peaks.map((h, i) => <i key={i} data-on={i / peaks.length < p || undefined} style={{ height: `${Math.max(10, h * 100)}%` }} />)}
</div>
<div className="sd-vn__meta"><span>{playing || t > 0 ? mmss(duration - t) : mmss(duration)}</span><span>{sentAt}</span></div>
</div>
<button type="button" className="sd-vn__spd" aria-label={`Playback speed ${SPEEDS[spd]}x`} onClick={() => setSpd((spd + 1) % SPEEDS.length)}>{SPEEDS[spd]}×</button>
</div>
)
}.sd-vn { --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-vn[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-vn button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-vn :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-vn .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-vn { display: flex; align-items: center; gap: 10px; width: min(100%, 300px); padding: 10px 14px 10px 10px; border-radius: 22px 22px 22px 6px; background: var(--soft); box-shadow: inset 0 0 0 1px var(--line); font-variant-numeric: tabular-nums; }
.sd-vn[data-me] { border-radius: 22px 22px 6px 22px; background: var(--strong); color: var(--on); box-shadow: none; --fg: var(--on); --mute: color-mix(in srgb, var(--on) 50%, transparent); --soft: color-mix(in srgb, var(--on) 12%, transparent); }
.sd-vn__play { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 99px; background: var(--fg) !important; color: var(--bg) !important; flex: none; transition: transform 140ms; }
.sd-vn[data-me] .sd-vn__play { color: var(--strong) !important; }
.sd-vn__play:active { transform: scale(0.9); }
.sd-vn__play svg { width: 14px; height: 14px; fill: currentColor; }
.sd-vn__mid { flex: 1; min-width: 0; }
.sd-vn__wave { display: flex; align-items: center; gap: 2px; height: 30px; cursor: pointer; touch-action: none; outline: 0; border-radius: 6px; }
.sd-vn__wave:focus-visible { box-shadow: 0 0 0 2px var(--fg); }
.sd-vn__wave i { flex: 1; min-height: 3px; border-radius: 2px; background: var(--mute); opacity: 0.6; transition: background-color 120ms, opacity 120ms; }
.sd-vn__wave i[data-on] { background: var(--fg); opacity: 1; }
.sd-vn__meta { display: flex; justify-content: space-between; margin-top: 3px; font-size: 11.5px; color: var(--mute); }
.sd-vn__spd { flex: none; height: 26px; min-width: 38px; padding: 0 8px; border-radius: 99px; font-size: 11.5px; font-weight: 650; box-shadow: inset 0 0 0 1px var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-vn *, .sd-vn *::before, .sd-vn *::after { animation: none !important; transition: none !important; } }Overview
The waveform doubles as the progress bar: played bars turn solid, and dragging across it seeks. The timer counts down the remaining time.
A speed chip cycles 1×, 1.5× and 2×. The demo simulates playback; pass a real audio element’s time to drive it.
When to use
- Chat apps, support threads, async standups.
When not to use
- Music or long-form audio: use a full player.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/voice-note.json$
npx shadcn@latest add https://sundaeui.dev/r/voice-note.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/voice-note.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/voice-note.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 voice-note.tsx and voice-note.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 { VoiceNote } from '@/components/sundae/voice-note'
<VoiceNote />Props
| Prop | Type | Default |
|---|---|---|
duration*Length in seconds. | number | — |
peaks*Waveform heights 0–1. | number[] | — |
onSeekCalled when scrubbing. | (s: number) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Play is a toggle button; the waveform is a slider with the time as aria-valuetext.
- Arrow keys seek 5 seconds.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-vn; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Space
- Play / pause
- ← / →
- Seek 5s
FAQ
- Is Voice Note free?
- Yes. Voice Note is free for personal and commercial projects, with full source on this page.
- How do I install Voice Note?
- Run npx shadcn@latest add https://sundaeui.dev/r/voice-note.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Voice Note support keyboard and screen readers?
- Play is a toggle button; the waveform is a slider with the time as aria-valuetext. Arrow keys seek 5 seconds.
- Does Voice Note respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →- Pro
Voice Recorder
Components · Refined Podcast Chapters
Components · Refined- Pro
Mini Player
Components · Expressive Message Thread
Components · RefinedEmoji Picker
Components · RefinedMixer Faders
Components · Refined