Hold To Talk
A press-and-hold voice-message button: the strip takes over the composer with a live waveform, sliding up locks the recording hands-free, sliding toward the edge discards it, releasing sends it — and every one of those has a key or a button.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/hold-to-talk.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "HoldToTalk" component — the composer mic
you press and hold to record a voice message, slide up to lock hands-free and
slide toward the leading edge to throw away. React + lucide-react + a
useControllableState hook. No audio APIs of its own: capture, encoding and
upload belong to the host; this owns the gesture, the surface and the timing.
Contract
- "use client". forwardRef<HTMLDivElement, HoldToTalkProps> extending
React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root,
which carries data-phase and data-variant.
- variant: "tray" | "bubble" | "compact" = "tray" — three genuinely different
recording surfaces, not colour swaps. tray covers the composer field with an
opaque strip; bubble leaves the row alone and floats a capsule above it;
compact grows the button itself into a pill and adds no surface at all.
- recording?, defaultRecording = false, onRecordingChange? — the value /
defaultValue / onChange triad over the capture flag, so a host can stop a
recording when a call arrives, or start one from somewhere else. Controlled
means the component only ASKS: if the parent ignores the request nothing
changes on screen, and nothing is committed either.
- onStart?() — fired for gesture and key starts only, never for a host-driven
one (the host already knows, and firing back at it invites a loop).
- onCommit?(durationMs) / onCancel?(durationMs, reason) — exactly one of the two
per session. reason: "slide" | "button" | "key" | "too-short" | "interrupted".
- level?: number (0..1) — measured amplitude, sampled every ~90ms into the
strip. Omitted, a DETERMINISTIC placeholder envelope is drawn (a closed form of
the sample index — never Math.random, never the clock, so screenshots and SSR
are stable). Wire an AnalyserNode to this before shipping.
- bars? — bar count; defaults per variant (tray 22, bubble 26, compact 12)
because each surface has a different width to fill.
- holdDelay = 220 (a press shorter than this is a tap, not a recording),
minDuration = 800 (a release earlier than this discards instead of sending),
maxDuration = 120000 (commits by itself), lockThreshold = 56 (upward px),
cancelThreshold = 96 (leading-edge px). Every number goes through one clamp
helper that rejects NaN — a NaN threshold locks on contact.
- disabled = false + disabledReason — a refusal that answers, not a dead button.
- label (the button's name at rest), labels?: Partial<HoldToTalkLabels> — the
eleven status strings, the only prose in the component, for i18n.
- children — the composer the surface belongs to (a text field, usually).
- Export the phase union "idle" | "recording" | "locked" and the reason union.
Behavior — one pointer, two escapes
- Pointer Events only, never separate mouse/touch handlers. pointerdown records
the origin, takes setPointerCapture on the button and starts the hold timer;
the button carries touch-action: none so the page cannot scroll out from under
the gesture and nothing has to preventDefault a passive listener.
- Movement BEFORE the hold delay is ignored rather than treated as a cancel:
sliding straight up to lock is a normal fast way to start a hands-free message.
- Once armed: dx and dy are clamped to the two escape directions (right and down
are zero progress). cancelProgress = -dx / cancelThreshold, lockProgress =
-dy / lockThreshold. The button follows the finger, the cancel hint slides and
fades with it, the lock rail fills. Cancel wins a tie at 1.0 — discarding must
be the easier escape.
- Reaching lock releases the FINGER from the session, not the recording: capture
is dropped, the rail disappears, the surface swaps the cancel hint for "hands
free" and a 44px discard button appears beside the mic, which is now a send.
- Release: duration >= minDuration commits, anything shorter is refused with a
visible hint and reason "too-short". A press released before the hold delay
commits nothing and shows "hold the mic to record".
- pointercancel (a system gesture, an incoming call) discards — a recording the
browser took away is never sent by accident.
- maxDuration commits by itself from the sampler and announces that it did.
- Track "is a finger still on it", NOT "is it locked". A session nobody is
holding — started from a key, from assistive tech or by the host — is
hands-free by definition, so it can never draw a "slide up to lock" rail no
pointer could satisfy.
- One-shot guarantees are refs read AND written synchronously in the handler:
the gesture record is nulled at the top of the end path (so a pointerup
followed by a pointercancel cannot settle twice), and the auto-commit uses a
closure flag owned by the interval's own effect. settle() also returns early
when nothing is recording.
- The press that ran the gesture must not also be handled as the button's click.
Keep a swallow flag: set it on pointerdown, consume it in onClick, and CLEAR
it on a pointerdown that lands while a session is already running — a lock
slide released in mid-air never produced the click that would have consumed
it, and a stale flag would eat the next send.
- Keyboard map: Enter / Space on the mic starts a hands-free recording and
activates again to send (preventDefault so the key path and the click path
never both fire, and so Space does not scroll; event.repeat is ignored).
Escape anywhere inside discards, stopPropagation so an outer sheet does not
also close. Tab reaches the discard button and the mic. The discard button
hands focus back to the mic before it unmounts — never to <body>.
- Cleanup: the sampler interval lives and dies with the session effect, the hold
timer and the notice timer are cleared on unmount, and pointer capture is
released on the node that took it.
Rendering & styling
- Semantic tokens only, monochrome first: bg-card + border for the surfaces,
text-muted-foreground for hints, bg-foreground/70 (bg-background/70 inside the
inverted pill) for the bars. The mic is the highest-priority element in the
row, so it INVERTS — bg-foreground text-background — instead of taking a
colour. destructive is reserved for the two genuinely destructive signals: the
live dot and the discard button.
- The root pads with env(safe-area-inset-bottom / left / right): it sits on the
bottom edge of a phone, so it owns the inset.
- The composer content is COVERED, never unmounted. On a phone the field usually
holds focus and the software keyboard with it; hiding a focused input drops
focus to <body> and collapses the keyboard mid-gesture.
- The moving pixels are written straight to the DOM: timer textContent, per-bar
scaleY transforms, the drag offset on the button, height on the rail fill, and
data-armed attributes so the "about to fire" styling stays in the class list
(tokens, dark mode, free). React state holds only the phase, the hold flag and
the announcement, so a 90ms sampler and a 60fps drag never re-render the
composer around it.
- Hit areas: mic 56px, discard 44px, everything focus-visible:ring-2 ring-ring.
Nothing depends on hover.
- prefers-reduced-motion is handled in CSS (motion-reduce:transition-none /
animate-none): the pulse and the eases go, the waveform, the timer and the
finger tracking stay — they are information and direct manipulation, not
decoration.
- Accessibility: aria-disabled + a handler guard, never the native disabled
attribute (the browser blurs a node the instant it is disabled). The button's
accessible name follows the phase (hold / release to send / send). Decorative
bars, hints and rails are aria-hidden; a polite sr-only role="status"
announces start, lock, outcome and refusals only — never the drag — and clears
itself so an identical next message is announced again.
Customization levers
- Feel is four numbers: holdDelay (how long is "on purpose"), minDuration (what
counts as a message), lockThreshold and cancelThreshold (how far each escape
is). 220/800/56/96 is the phone default; a walkie-talkie wants holdDelay 0 and
no minDuration; a noisy environment wants a longer cancelThreshold.
- Pick the variant by what must stay readable: tray when the field is
disposable, bubble when it is not, compact when the row is already crowded.
- bars is density; drop it to 8 for a chip-sized strip, raise it for a tablet.
- labels is the i18n seam, and the only prose in the component.
- Style by state through data-phase on the root (idle / recording / locked)
instead of forking the component.
- Swap the surface entirely by keeping the button column and re-skinning the
absolutely positioned strip — its geometry is independent of the children.
- To send audio rather than a duration, keep this as the gesture layer: start
your MediaRecorder in onStart, feed AnalyserNode values into level, and
stop-and-upload in onCommit / discard in onCancel.
- For RTL, mirror the cancel axis by flipping the sign of dx (read the computed
direction once at gesture start) and swap right-full for left-full on the
discard button.Concepts
- Hold delay separates a tap from a hold — a press only becomes a recording after 220ms. Under it there is no session, no timer and no callback, just a hint saying what the button wants; that is what stops a mis-tap in the thumb arc from sending a 40ms message. Movement during those 220ms is deliberately not a cancel, because sliding straight up to lock is a normal fast way to start.
- Two escape axes on one pointer — the same finger that started the capture chooses the outcome: up past
lockThresholdkeeps recording without it, toward the leading edge pastcancelThresholdthrows the take away, and letting go in place sends. Both axes paint continuously (the button follows the finger, the hint fades, the rail fills) so the user can see which one they are about to commit to, and cancel wins a tie because discarding must always be the easier escape. - Locking releases the finger, not the recording — at the lock threshold pointer capture is dropped and the session carries on without a pointer. The component therefore tracks is a finger still on it, never is it locked: a session started from a key, from assistive technology or by the host has no finger by definition, so it renders the hands-free controls rather than a slide-up rail nothing could ever satisfy.
- A refusal is an answer — three of them, each with its own reason on
onCancel: released too fast (too-short), the pointer taken away by the system (interrupted), and a disabled mic that speaks itsdisabledReasoninstead of going quiet.aria-disabledplus a handler guard, never the native attribute, because the browser blurs a node the instant it is disabled and focus would land on<body>. - The composer keeps its focus — the recording surface covers the field, it never unmounts it. On a phone that field is usually focused with the software keyboard open; hiding it mid-gesture would drop focus and collapse the keyboard under the user's thumb. The same rule sends focus back to the mic before the discard button disappears.
- Moving pixels bypass React — the timer text, the per-bar
scaleY, the drag offset and the rail fill are written straight to the DOM, and the “about to fire” styling is adata-armedattribute so it can stay in the class list and keep using tokens. State holds three phases and one announcement, which is what lets a 90ms sampler and a 60fps drag run inside a composer without re-rendering it.
Chat Message Actions
Hold one chat bubble and its quick reactions plus that message's actions come up against it — as an anchored menu, a compact toolbar or a sheet from the bottom edge.
Prompt Rail
A one-line rail of conversation starters that scrolls under the thumb and folds itself away — on a flick, on a key, or the moment the thread starts.