Voice Note
import { useEffect, useMemo, useRef, useState } from 'react';
const css = `
.card-voice-note {
display: flex;
align-items: center;
gap: 10px;
width: 252px;
padding: 10px 14px 10px 10px;
border-radius: 22px 22px 22px 6px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-voice-note__play {
display: grid;
place-items: center;
flex: none;
width: 38px;
height: 38px;
padding: 0;
border: none;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
cursor: pointer;
transition: transform 0.2s cubic-bezier(0.34, 1.6, 0.64, 1);
}
.card-voice-note__play:active { transform: scale(0.9); }
.card-voice-note__play:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.card-voice-note__play svg { width: 16px; height: 16px; fill: currentColor; }
.card-voice-note__wave { flex: 1; display: flex; align-items: center; gap: 2px; height: 30px; cursor: pointer; }
.card-voice-note__bar { flex: 1; border-radius: 2px; background: light-dark(#cbced3, #393c41); transition: background-color 0.15s ease; }
.card-voice-note__bar[data-on] { background: light-dark(#373a40, #f1f2f3); }
.card-voice-note__time { width: 30px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; } @media (prefers-reduced-motion: reduce) {
.card-voice-note__play, .card-voice-note__bar { transition: none; }
}
`;
const LEN = 9; // seconds
const BARS = 34;
export function VoiceNoteCard() {
const [playing, setPlaying] = useState(false);
const [pos, setPos] = useState(0); // 0–1
const raf = useRef(0);
const heights = useMemo(() => {
let seed = 7;
const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
// Speech-like envelope: louder in phrases, quiet between them.
return Array.from({ length: BARS }, (_, i) => 0.18 + Math.abs(Math.sin(i * 0.42)) * 0.6 * (0.5 + rand() * 0.5));
}, []);
useEffect(() => {
if (!playing) return;
let last = performance.now();
const tick = (now: number) => {
const dt = (now - last) / 1000;
last = now;
setPos((p) => {
const next = p + dt / LEN;
if (next >= 1) {
setPlaying(false);
return 0;
}
return next;
});
raf.current = requestAnimationFrame(tick);
};
raf.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf.current);
}, [playing]);
const secs = Math.floor((playing || pos > 0 ? pos : 1) * LEN);
return (
<>
<style>
{css}
</style>
<div className="card-voice-note" role="group" aria-label="Voice note, 9 seconds">
<button type="button" className="card-voice-note__play" aria-label={playing ? 'Pause' : 'Play'} onClick={() => setPlaying((p) => !p)}>
<svg viewBox="0 0 24 24" aria-hidden="true">
{playing ? <path d="M7 5h4v14H7zM13 5h4v14h-4z" /> : <path d="M8 5v14l11-7z" />}
</svg>
</button>
{/* Click the waveform to seek. */}
<div
className="card-voice-note__wave"
aria-hidden="true"
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
setPos(Math.min(0.999, Math.max(0, (e.clientX - r.left) / r.width)));
}}
>
{heights.map((h, i) => (
<span key={i} className="card-voice-note__bar" data-on={i / BARS < pos || undefined} style={{ height: `${(h * 100).toFixed(0)}%` }} />
))}
</div>
<span className="card-voice-note__time" aria-hidden="true">
0:{String(secs).padStart(2, '0')}
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardVoiceNote(el) on the mounted element instead.
<div class="card-voice-note" role="group" aria-label="Voice note, 9 seconds">
<button type="button" class="card-voice-note__play" aria-label="Play">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5v14l11-7z"></path>
</svg>
</button>
<div class="card-voice-note__wave" aria-hidden="true">
<span class="card-voice-note__bar" style="height:18%"></span>
<span class="card-voice-note__bar" style="height:41%"></span>
<span class="card-voice-note__bar" style="height:47%"></span>
<span class="card-voice-note__bar" style="height:53%"></span>
<span class="card-voice-note__bar" style="height:70%"></span>
<span class="card-voice-note__bar" style="height:58%"></span>
<span class="card-voice-note__bar" style="height:41%"></span>
<span class="card-voice-note__bar" style="height:29%"></span>
<span class="card-voice-note__bar" style="height:29%"></span>
<span class="card-voice-note__bar" style="height:46%"></span>
<span class="card-voice-note__bar" style="height:62%"></span>
<span class="card-voice-note__bar" style="height:67%"></span>
<span class="card-voice-note__bar" style="height:70%"></span>
<span class="card-voice-note__bar" style="height:45%"></span>
<span class="card-voice-note__bar" style="height:34%"></span>
<span class="card-voice-note__bar" style="height:19%"></span>
<span class="card-voice-note__bar" style="height:40%"></span>
<span class="card-voice-note__bar" style="height:42%"></span>
<span class="card-voice-note__bar" style="height:66%"></span>
<span class="card-voice-note__bar" style="height:62%"></span>
<span class="card-voice-note__bar" style="height:67%"></span>
<span class="card-voice-note__bar" style="height:49%"></span>
<span class="card-voice-note__bar" style="height:24%"></span>
<span class="card-voice-note__bar" style="height:29%"></span>
<span class="card-voice-note__bar" style="height:53%"></span>
<span class="card-voice-note__bar" style="height:63%"></span>
<span class="card-voice-note__bar" style="height:67%"></span>
<span class="card-voice-note__bar" style="height:63%"></span>
<span class="card-voice-note__bar" style="height:59%"></span>
<span class="card-voice-note__bar" style="height:40%"></span>
<span class="card-voice-note__bar" style="height:19%"></span>
<span class="card-voice-note__bar" style="height:36%"></span>
<span class="card-voice-note__bar" style="height:60%"></span>
<span class="card-voice-note__bar" style="height:56%"></span>
</div>
<span class="card-voice-note__time" aria-hidden="true">0:09</span>
</div>.card-voice-note {
display: flex;
align-items: center;
gap: 10px;
width: 252px;
padding: 10px 14px 10px 10px;
border-radius: 22px 22px 22px 6px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-voice-note__play {
display: grid;
place-items: center;
flex: none;
width: 38px;
height: 38px;
padding: 0;
border: none;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
cursor: pointer;
transition: transform 0.2s cubic-bezier(0.34, 1.6, 0.64, 1);
}
.card-voice-note__play:active { transform: scale(0.9); }
.card-voice-note__play:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.card-voice-note__play svg { width: 16px; height: 16px; fill: currentColor; }
.card-voice-note__wave { flex: 1; display: flex; align-items: center; gap: 2px; height: 30px; cursor: pointer; }
.card-voice-note__bar { flex: 1; border-radius: 2px; background: light-dark(#cbced3, #393c41); transition: background-color 0.15s ease; }
.card-voice-note__bar[data-on] { background: light-dark(#373a40, #f1f2f3); }
.card-voice-note__time { width: 30px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; text-align: right; } @media (prefers-reduced-motion: reduce) {
.card-voice-note__play, .card-voice-note__bar { transition: none; }
}function initCardVoiceNote(root) {
var play = root.querySelector('.card-voice-note__play');
var waveEl = root.querySelector('.card-voice-note__wave');
var bars = root.querySelectorAll('.card-voice-note__bar');
var time = root.querySelector('.card-voice-note__time');
if (!play || !waveEl || !bars.length || !time) return;
var LEN = 9; // seconds
var PLAY = 'M8 5v14l11-7z';
var PAUSE = 'M7 5h4v14H7zM13 5h4v14h-4z';
var icon = play.querySelector('path');
var playing = false;
var pos = 0; // 0-1
var raf = 0;
var last = 0;
function render() {
bars.forEach(function (b, i) { b.toggleAttribute('data-on', i / bars.length < pos); });
var secs = Math.floor((playing || pos > 0 ? pos : 1) * LEN);
time.textContent = '0:' + String(secs).padStart(2, '0');
play.setAttribute('aria-label', playing ? 'Pause' : 'Play');
icon.setAttribute('d', playing ? PAUSE : PLAY);
}
function tick(now) {
pos += (now - last) / 1000 / LEN;
last = now;
if (pos >= 1) {
pos = 0;
playing = false;
render();
return;
}
render();
raf = requestAnimationFrame(tick);
}
function toggle() {
playing = !playing;
cancelAnimationFrame(raf);
if (playing) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
render();
}
// Click the waveform to seek.
function seek(e) {
var r = waveEl.getBoundingClientRect();
pos = Math.min(0.999, Math.max(0, (e.clientX - r.left) / r.width));
render();
}
play.addEventListener('click', toggle);
waveEl.addEventListener('click', seek);
return function () {
cancelAnimationFrame(raf);
play.removeEventListener('click', toggle);
waveEl.removeEventListener('click', seek);
};
}
document.querySelectorAll('.card-voice-note').forEach(initCardVoiceNote);Goal: Create a "Voice Note" card.
Visual behavior: A voice-note bubble: press play and the waveform fills from left to right as it plays while the timer counts, click anywhere on the waveform to seek, and it stops and resets at the end.
Technique:
- Thirty-four bars with seeded, speech-like heights (louder phrases, quiet gaps) sit in a flex row.
- While playing, a rAF loop advances the position by elapsed time over the note's 9 seconds; bars left of the position switch to the bright tone.
- Clicking the waveform maps the click's x to a position; the play button swaps its icon and label, and playback stops and resets at the end.
Accessibility: The play button is a real button whose label switches between Play and Pause; the waveform is aria-hidden. Under prefers-reduced-motion the bars change colour instantly.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Voice Note" card.
Visual behavior: A voice-note bubble: press play and the waveform fills from left to right as it plays while the timer counts, click anywhere on the waveform to seek, and it stops and resets at the end.
Technique:
- Thirty-four bars with seeded, speech-like heights (louder phrases, quiet gaps) sit in a flex row.
- While playing, a rAF loop advances the position by elapsed time over the note's 9 seconds; bars left of the position switch to the bright tone.
- Clicking the waveform maps the click's x to a position; the play button swaps its icon and label, and playback stops and resets at the end.
Accessibility: The play button is a real button whose label switches between Play and Pause; the waveform is aria-hidden. Under prefers-reduced-motion the bars change colour instantly.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A voice-note bubble: press play and the waveform fills from left to right as it plays while the timer counts, click anywhere on the waveform to seek, and it stops and resets at the end.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, fill, state change, card
- Techniques
- raf, event position, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The play button is a real button whose label switches between Play and Pause; the waveform is aria-hidden. Under prefers-reduced-motion the bars change colour instantly.