Buttons

Record Button

A record control with a real idle → recording → processing → done state machine, drift-free elapsed timer, and an optional hold-to-record gesture with slide-away cancel.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Loader2, X } from "lucide-react"
import { cn } from "@/lib/utils"

export type RecordButtonPhase = "idle" | "recording" | "processing" | "done"

/**
 * 为什么取消要分原因:消费者对这四种结束方式的处理完全不同 ——
 * `too-short` 是误触(通常什么都不做),`slide-away` 是用户主动反悔(丢弃缓冲区),
 * `escape` 是键盘取消,`interrupted` 是系统抢走了手势(滚动/缩放/来电)。
 */
export type RecordCancelReason = "too-short" | "slide-away" | "escape" | "interrupted"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/record-button.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "RecordButton" component using
lucide-react icons (Check, Loader2, X). No animation or media library — raw
Pointer/Keyboard events, setTimeout, and CSS transitions. The component does
NOT touch getUserMedia/MediaRecorder: capture, encoding and upload are the
consumer's job, wired through onStop.

Contract
- forwardRef<HTMLDivElement> extending React.HTMLAttributes<HTMLDivElement>
  with onCancel omitted (React's DOM onCancel would collide with the
  component's own cancel callback). Rest props spread onto the root.
- Props: mode: "toggle" | "hold" = "toggle";
  onStart?: () => void;
  onStop?: (durationMs: number) => void | Promise<unknown>;
  onCancel?: (reason, durationMs) => void where reason is
  "too-short" | "slide-away" | "escape" | "interrupted";
  minDurationMs = 1000, maxDurationMs = 60000, doneDurationMs = 1600,
  hintDurationMs = 3000, labels?: Partial<Labels>, disabled?: boolean,
  className.
- Phase type: "idle" | "recording" | "processing" | "done". Phase is internal
  state, not a controlled prop — the processing phase is driven by whether
  onStop returns a thenable.
- Clamp every numeric prop: non-finite values fall back to the default,
  maxDurationMs has a 1000ms floor, minDurationMs is additionally clamped to
  <= maxDurationMs (otherwise every take is rejected as too short).
- Labels are one flat bag with two groups: visible status strings (idle,
  recording, processing, done, cancelArmed) and accessible names
  (startAction, stopAction, cancelAction, busyAction, doneAction), plus four
  hint strings. The hints support "{min}" / "{max}" placeholders that get
  replaced with the formatted thresholds.

Behavior
- State machine: idle --start--> recording --stop--> (processing) --> done
  --doneDurationMs--> idle. Cancel from recording goes straight back to idle
  and fires onCancel instead of onStop; the two are mutually exclusive.
- Elapsed time is ALWAYS recomputed from the start timestamp
  (Date.now() - startedAt), never accumulated by adding a step per tick. A
  background tab throttles timers to roughly one callback per minute, so an
  incrementing timer under-counts badly; anchoring to the start instant means
  the first frame after waking already shows the true duration. Re-run the
  same computation on visibilitychange so the user never stares at a stale
  number, and clear the pending timeout at the top of the tick so that extra
  recompute cannot leave a second timer running.
- Tick scheduling: a self-renewing setTimeout aimed at the next whole-second
  boundary relative to the start instant (or at the max-duration deadline,
  whichever comes first) — not a fixed 1000ms setInterval, which accumulates
  drift and can render the same digit twice.
- Max duration: on reaching maxDurationMs the component auto-stops through
  the normal stop path (onStop still fires), reports the duration clamped to
  the limit, and shows the "{max} limit" hint so the stop is explained rather
  than mysterious.
- Minimum take: a stop below minDurationMs produces NO recording — it fires
  onCancel("too-short") instead of onStop, and must show a visible hint
  ("Too short — hold for at least {min}") plus a live-region announcement.
  Silently discarding a 0.2s misfire is the bug this rule exists to prevent.
- Toggle mode: a single onClick handler covers pointer, keyboard and screen
  reader activation (a native <button> is the accessible path). Recording
  stops on the next activation.
- Hold mode: pointerdown starts, pointerup stops. Call setPointerCapture on
  pointerdown, otherwise a finger sliding off the button never delivers
  pointerup and the gesture can never end. Releasing while the pointer sits
  outside the button rect (plus a ~12px slop) CANCELS instead of stopping —
  the iOS voice-message convention — and while the pointer is outside, the
  button switches to an explicit "Release to cancel" affordance (X icon +
  changed label + changed accessible name), not a color-only hint.
- If setPointerCapture is unavailable (older WebViews throw), fall back to
  pointerleave: without capture, a release outside the button never reaches
  it, so the gesture has to end the moment the pointer leaves — otherwise the
  control is stranded in "recording". Guard that handler with the captured
  flag, because a captured gesture must ignore boundary events.
- A pointer-driven click still trails the gesture, and pointer capture
  retargets it to the capturing element — verified: after a captured
  press+release the click arrives on the button itself with detail 1. So in
  hold mode every pointer-driven click must be ignored: accept only clicks
  with event.detail === 0 (keyboard / screen-reader activation) and
  additionally ignore those landing within ~700ms of pointer activity, since
  a few touch stacks also report detail 0. Without this guard the trailing
  click immediately starts a second recording. Keyboard users get
  click-to-toggle in hold mode: holding Space is meaningless when key
  auto-repeat shreds the gesture.
- pointermove must check event.buttons === 0 and finish the gesture there.
  A mouse released outside the browser window never delivers pointerup, and
  without this compensation the button stays stuck in "recording" forever.
  Resolve it with the last position recorded while the button was still held,
  so a release that happened off-target still cancels rather than saving.
- pointercancel (system scroll/zoom stealing the gesture) cancels with reason
  "interrupted"; Escape while recording cancels with reason "escape".
- onStop may return a promise: enter processing, then done on resolve, or back
  to idle with a failure hint on reject. Call it inside try/catch — a
  synchronous throw escapes before Promise.resolve() can capture it, which
  would leave the button pending forever. Tag each take with an incrementing
  run id and drop late resolutions from superseded runs.
- During processing use aria-disabled + a handler guard, never the native
  disabled attribute: the native attribute blurs the button instantly and
  drops keyboard focus to <body> right after the user pressed stop.
- The consumer's disabled prop means "no NEW takes", so it only reaches the
  native attribute while nothing is in flight (idle / done). A take that is
  already recording or processing keeps an enabled button: natively disabling
  it mid-take removes pointer events and focus from the only control that can
  stop it, stranding the recording with the clock still running. The click
  handler follows the same rule — disabled blocks the start path, never the
  stop path — and Escape / pointerup can still end an in-flight take.
- Cleanup: the tick timeout, the hint timeout and the done timeout are all
  cleared on unmount, and the visibilitychange listener is removed with the
  effect that added it. Release pointer capture when the gesture ends.

Rendering & styling
- Root: inline-flex max-w-full items-center gap-3 — a size-12 rounded-full
  bordered button, then a min-w-0 text column (status line + monospace
  tabular-nums timer, and a min-h-4 hint line so the layout never jumps when
  a hint appears). Long custom labels wrap via wrap-anywhere + min-w-0; both
  are required, min-width:auto on a flex child would otherwise push the
  column past the viewport.
- The indicator morphs: a size-6 rounded-full dot at idle becomes a size-5
  rounded-sm square while recording, via
  transition-[width,height,border-radius] with motion-reduce:transition-none.
  Shape carries the state, so the red dot is never the only channel.
- Tokens only: bg-card, border-border, border-destructive/60,
  bg-destructive for the dot, ring-destructive/40 for the pulse,
  text-muted-foreground / text-foreground for copy, focus-visible ring-ring
  with ring-offset-background. No hex, no oklch, no hardcoded radii.
- The recording pulse ring is motion-safe:animate-pulse, so reduced-motion
  users lose the animation but keep the square shape, the status text, the
  running timer and the changed accessible name.
- Accessibility: aria-label on the button changes per phase (Start recording
  / Stop recording / Release to cancel recording / Processing recording /
  Recording saved) and aria-pressed reflects "is recording". The timer sits
  in role="timer" with aria-live="off" (per-second announcements would be
  noise); a separate sr-only role="status" region carries the discrete
  transition announcements. In hold mode the button also needs
  touch-none select-none so the browser cannot claim the gesture as a scroll.

Customization levers
- mode: "toggle" is the safe default for desktop; "hold" is the push-to-talk
  / voice-message feel. Both keep the identical keyboard path.
- minDurationMs / maxDurationMs: the misfire floor and the hard ceiling. Set
  minDurationMs to 0 to accept any take (then the too-short branch simply
  never runs) and raise maxDurationMs for long-form capture.
- doneDurationMs: how long the completed state lingers; pass 0 to keep the
  finished state on screen until the next recording starts.
- Cancel slop: the ~12px tolerance around the button rect decides how twitchy
  the slide-away cancel feels. Widen it for touch-heavy products, or swap the
  rect test for a distance threshold from the press origin if you want an
  iOS-style "slide left to cancel" rail.
- labels: every visible string and every accessible name is overridable, and
  the hint strings interpolate "{min}" / "{max}" — that is the i18n seam.
- Real capture: wire getUserMedia/MediaRecorder in onStart/onStop, or return
  the upload promise from onStop so the processing phase reflects the real
  encode/upload. Keep the media lifecycle outside this component so it stays
  usable for screen, video and canvas capture too.
- Progress affordance: the max-duration deadline is already known, so a
  countdown ring around the button can be driven from the same
  elapsed / maxDurationMs ratio without adding another timer.

Concepts

  • Deadline-anchored timer — elapsed time is recomputed as now - startedAt on every tick instead of accumulated a step at a time. A backgrounded tab throttles timers to about one callback per minute, so an incrementing counter under-reports; anchoring to the start instant makes the first frame after waking already correct, and a visibilitychange recompute means the user never waits for the next tick.
  • Slide-away cancel — in hold mode, the release point decides the outcome: inside the button means stop and keep the take, outside means discard it. It is the same contract as an iOS voice message, and it needs setPointerCapture to work at all, because a finger that has left the element stops delivering pointerup to it. Where capture is unavailable the gesture degrades to cancel-on-leave, which is the most the platform can honestly offer.
  • Lost-pointer compensation — a mouse released outside the browser window never delivers pointerup. Checking buttons === 0 on the next pointermove closes the gesture using the last position recorded while the button was still held, so the control can never be stranded in the recording phase.
  • Minimum-take guard — a stop below the floor produces no recording, but it does produce a visible hint and a live-region announcement. A silently dropped 0.2s misfire looks identical to a broken button; an explained rejection does not.
  • Detail-zero activation — pointer capture redirects clicks to the capturing element, so hold mode has to tell a real keyboard/AT activation from the click that trails its own gesture. event.detail === 0 plus a short post-pointer quiet window is that discriminator, and it is what keeps the keyboard path identical in both modes.
  • Phase from the promise — the processing phase exists only because onStop can return a thenable; the component reads the return value instead of exposing another prop, and a run id makes a superseded take's late resolution a no-op.

On This Page