Voice note
Press the mic and it grows into a live recording pill; send it and the pill becomes a voice message that rises into the thread.
voice-note/voice-note.tsx- Voice note, 0:06
The bars follow a simulated voice. Nothing is recorded or sent.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A chat or messaging composer, where saying something is quicker than typing it.
- Short takes with a hard limit: the ring shows the time used, and the note sends itself at
maxSeconds. - Recording without holding a button down: one tap starts, Send or Cancel ends, Escape cancels, so it works from a keyboard.
- Showing what was sent as a compact message: its length and a small waveform of the take.
When not to use
- Long recordings, interviews or anything to review before sending: they need pause, playback and trimming. Use a full recorder.
- Dictation, where the words end up as text. Use speech-to-text inside the text field.
- Push-to-talk, where letting go sends: this control takes two taps, start and send.
- On its own when the audio itself is needed: it only measures loudness, and
onSendgets the length and levels. Record with MediaRecorder alongside it. - An assistant’s “listening” indicator, with nothing to send or cancel. Use a status or an orb.
You can add this component with the shadcn CLI or by hand:
Into components/voice-note of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/voice-note.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
'use client';import { useState } from 'react';import { VoiceNote, type Take } from './voice-note';export default function ReplyBar() { const [last, setLast] = useState<Take | null>(null); return ( <> {/* Notes up to 30 s; each comes with its length and levels (no audio). */} <VoiceNote maxSeconds={30} onSend={setLast} autoplay={false} /> {last && <p>Last note: {last.seconds} s</p>} </> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The mic button stretches into a red pill with live bars and a thin ring that runs round its edge as the allowed time fills up, between cancel and send. Cancel folds it back into the mic. Send turns it into a message with the take’s own waveform and duration, which lifts into the conversation above. The bars follow a simulated voice, or your microphone if you allow it, and nothing is recorded or sent.
- One shape morphs through every state. The circle widens into the pill on a spring and turns red, and cancel and send slide out from behind it. On send it widens into the message, which then glides up into the thread with FLIP from the pill’s box.
- Only that plain shape changes size. The mic, the bars, the ring and the message keep their own sizes on top of it, inside a fixed, clipped box, which took the iPhone’s GPU memory from about 100 MB to about 50 MB.
- A requestAnimationFrame loop runs only while recording and on screen: it writes seven bars (a smoothed, scrolling history of levels) straight to the page, sets the dash length of the ring (an SVG path of the pill’s outline), and keeps one level every 100 ms.
- The levels come from a seeded simulated voice, with stressed syllables, gaps and breaths. If the reader opts in, they come from the microphone through an AnalyserNode instead: it is open only during a take the reader started, its tracks stop right afterwards, and nothing is stored or sent.
- A sent note keeps its levels, not audio, summarised into 18 waveform bars. A timer sends the note at the limit, even off screen.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the shadows are built from colour tokens, and the mic’s gloss fades to a faint sheen at night.
| Prop | Type | Default | Description |
|---|---|---|---|
maxSeconds | number | 20 | The longest a note can be: the ring fills up over it, then the note sends itself. |
defaultState | VoiceNoteState | — | Opens on a given moment, held still, and turns the demo off: phase ('idle', 'recording' or 'sending'), seconds into the take, its levels, the notes already in the thread and the microphone ('off', 'on' or 'blocked'). |
onSend | (take: Take) => void | — | Called with each note sent: its length in seconds and its loudness every 100 ms (there is no audio). |
autoplay | boolean | true | While on screen and untouched, a demo pointer records a note and sends it, then records one and cancels it. It never uses the microphone. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react';import { GhostPointer, runDemo } from './ghost-pointer';import { openMicrophone, simulatedVoice, type LevelSource } from './signal';import { useAutoplay } from './use-autoplay';import styles from './voice-note.module.css';/* * Voice note: a recording control. Press the mic and it grows into a red pill with live bars * and a thin ring that runs round its edge as the time allowed fills up, between a cancel and a * send button. Cancel folds it back into the mic; send turns it into a voice message, with its * duration and a small waveform of the take, which then rises into the conversation above. * * - One shape does it all: the pill is the mic button grown (its width eases on a spring and its * colour cross-fades), and it becomes the message before the message lifts off (FLIP: the new * message starts where the pill is, then glides to its place). Only that plain shape changes * size; what shows on it (the mic, the bars, the ring, the message) keeps its own size, so a * phone never has to make their layers again while the shape moves. * - The bars follow a simulated voice (signal.ts), unless the reader lets them follow the * microphone: it then only feeds an analyser that measures loudness, and is open only while a * recording runs. Nothing is recorded, kept or sent./* Voice note (voice-note.tsx). Sizes are in em: the stage sets the font-size from its own width (a container query), so the control grows with the room it has. The composer's phase (idle, recording, sending) is a data attribute that drives the shape; --spring, the easing of the springy transitions, comes from the component. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). */.root { /* The stage, and what is raised on it: the messages, the mic, cancel, send and the toggle. */ --surface: #efeff3; --surface-2: #fff; /* Under the pointer: the mic, cancel and send. */ --hover: #f7f7f9; /* The words and icons; a duration and the caption; a message's waveform. */ --ink: #1c1c1e; --ink-muted: #8e8e93; --wave: #b9b9c0; /* The stage's edge, and the hairline round what is raised on it. */ --line: rgb(0 0 0 / 0.05); --edge: rgb(0 0 0 / 0.06); /* A recording: the bars, the ring, the timer and the mic badge; the badge's disc; the pill, and its edge. *//* * Where the recording's bars get their levels: a simulated voice, or the reader's microphone. * Either one gives a level between 0 and 1, read once a frame while recording. * * The microphone only feeds an analyser that measures how loud it is: nothing is recorded, kept, * played back or sent anywhere, and the stream stops as soon as the recording ends. */export type LevelSource = { /** How loud it is now, from 0 to 1. */ level: (now: number) => number; /** Lets go of whatever the source holds. */ close: () => void;};/** A small seeded random generator (mulberry32), so a take can be replayed. */function random(seed: number) { let state = seed >>> 0; return () => { state = (state + 0x6d2b79f5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296;'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer * fades out. */export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>; /** Presses and lets go; `action` runs as the pointer goes down. */ press: (action: () => void) => Promise<void>;};/** What a stopped demo's pending step rejects with. */class Stopped extends Error {}'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- Mic, cancel and send are labelled buttons. Focus follows the control: to Send when a take starts, back to the mic when it ends, and Escape cancels a recording.
- “Recording.”, “Recording cancelled.” and “Voice note sent, 4 seconds.” are announced. Sent notes form a list, each read as “Voice note, 0:04”.
- “Use microphone” is a toggle (aria-pressed), and the caption always says where the bars come from and that nothing is recorded or sent.
- Under reduced motion states change at once and there is no demo. The level bars and the ring still update, because they carry information.