Subframe
Components & interactionsHold-to-record voice button

Push to talk

A momentary recording control captures a local voice note and hands off to playback on release.

Enable the microphone, hold to record, release to finish, then play the take.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
"use client"
import * as React from "react"
function useReduced() {
  const [reduced, setReduced] = React.useState(true)
  React.useEffect(() => {
    const media = matchMedia("(prefers-reduced-motion: reduce)"),
      update = () => setReduced(media.matches)
    update()
    media.addEventListener("change", update)
    return () => media.removeEventListener("change", update)
  }, [])
  return reduced
}

export default function Example({ active = true }: { active?: boolean }) {
  const [ready, setReady] = React.useState(false),
    [busy, setBusy] = React.useState(false),
    [recording, setRecording] = React.useState(false),
    [seconds, setSeconds] = React.useState(0),
    [src, setSrc] = React.useState(""),
    [playing, setPlaying] = React.useState(false),
    [notice, setNotice] = React.useState("Record a local voice note")
  const stream = React.useRef<MediaStream | null>(null),
    recorder = React.useRef<MediaRecorder | null>(null),
    audio = React.useRef<HTMLAudioElement>(null),
    url = React.useRef(""),
    generation = React.useRef(0),
    discard = React.useRef(false),
    takeId = React.useRef(0),
    reduced = useReduced()
  const stop = React.useCallback((cancel = false) => {
    discard.current = cancel
    if (cancel) takeId.current++
    const r = recorder.current
    if (r?.state === "recording") r.stop()
    setRecording(false)
  }, [])
  const release = React.useCallback(() => {
    generation.current++
    stop(true)
    stream.current?.getTracks().forEach((t) => t.stop())
    stream.current = null
    audio.current?.pause()
    setReady(false)
    setBusy(false)
  }, [stop])
  React.useEffect(() => {
    if (!active) release()
  }, [active, release])
  React.useEffect(
    () => () => {
      generation.current++
      discard.current = true
      const r = recorder.current
      if (r?.state === "recording") r.stop()
      stream.current?.getTracks().forEach((t) => t.stop())
      if (url.current) URL.revokeObjectURL(url.current)
    },
    [],
  )
  async function enable() {
    setBusy(true)
    const gen = ++generation.current
    try {
      if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) throw Error()
      const s = await navigator.mediaDevices.getUserMedia({ audio: true })
      if (gen !== generation.current) {
        s.getTracks().forEach((t) => t.stop())
        return
      }
      stream.current = s
      setReady(true)
      setBusy(false)
      setNotice("Hold to record · release to finish")
    } catch {
      if (gen === generation.current) {
        setBusy(false)
        setNotice("Microphone unavailable or permission denied.")
      }
    }
  }
  function start() {
    if (!active || !ready || !stream.current || recorder.current?.state === "recording") return
    audio.current?.pause()
    discard.current = false
    const gen = generation.current,
      take = ++takeId.current
    try {
      const r = new MediaRecorder(stream.current),
        chunks: Blob[] = []
      recorder.current = r
      r.ondataavailable = (e) => {
        if (e.data.size) chunks.push(e.data)
      }
      r.onstop = () => {
        if (discard.current || gen !== generation.current || take !== takeId.current) return
        if (url.current) URL.revokeObjectURL(url.current)
        url.current = URL.createObjectURL(new Blob(chunks, { type: r.mimeType }))
        setSrc(url.current)
        setNotice("Saved on this device only")
      }
      r.start()
      setSeconds(0)
      setRecording(true)
    } catch {
      setNotice("Recording is not supported by this browser.")
    }
  }
  React.useEffect(() => {
    if (!recording) return
    const started = performance.now()
    const id = setInterval(() => {
      const elapsed = (performance.now() - started) / 1000
      setSeconds(elapsed)
      if (elapsed >= 30) stop()
    }, 100)
    return () => clearInterval(id)
  }, [recording, stop])
  async function playback() {
    if (!audio.current) return
    if (playing) {
      audio.current.pause()
      return
    }
    try {
      await audio.current.play()
    } catch {
      setNotice("This browser could not play the take.")
    }
  }
  return (
    <div className="sf-push-to-talk" data-paused={!active || reduced}>
      <section className="recorder">
        <div className="take">
          <span className="light" data-on={recording} />
          <output>
            {seconds.toFixed(1).padStart(4, "0")}
            <small>s</small>
          </output>
          <span className="limit">/ 30</span>
        </div>
        <button
          className="hold"
          disabled={!active || !ready}
          data-recording={recording}
          onPointerDown={(e) => {
            e.preventDefault()
            e.currentTarget.setPointerCapture(e.pointerId)
            start()
          }}
          onPointerUp={() => stop()}
          onPointerCancel={() => stop(true)}
          onLostPointerCapture={() => {
            if (recorder.current?.state === "recording") stop(true)
          }}
          onKeyDown={(e) => {
            if ([" ", "Enter"].includes(e.key)) {
              e.preventDefault()
              if (!e.repeat) start()
            }
            if (e.key === "Escape") stop(true)
          }}
          onKeyUp={(e) => {
            if ([" ", "Enter"].includes(e.key)) {
              e.preventDefault()
              stop()
            }
          }}
          onBlur={() => {
            if (recorder.current?.state === "recording") stop(true)
          }}
        >
          <svg viewBox="0 0 24 24" aria-hidden>
            <rect x="9" y="3" width="6" height="12" rx="3" />
            <path d="M6 10v2a6 6 0 0 0 12 0v-2M12 18v3" />
          </svg>
          {recording ? "Release to finish" : "Hold to talk"}
          <span className="key" aria-hidden>
            space
          </span>
        </button>
        <p role="status">{notice}</p>
        <footer>
          <button disabled={!active || busy} onClick={() => (ready ? release() : void enable())}>
            {busy ? "Opening…" : ready ? "Disable microphone" : "Enable microphone"}
          </button>
          {src && (
            <button disabled={!active || recording} onClick={() => void playback()}>
              {playing ? "Pause take" : "Play take"}
            </button>
          )}
        </footer>
        <audio
          ref={audio}
          src={src || undefined}
          onPlay={() => setPlaying(true)}
          onPause={() => setPlaying(false)}
          onEnded={() => setPlaying(false)}
        />
      </section>
      <style>{`.sf-push-to-talk{width:100%;height:100%;min-height:260px;box-sizing:border-box;display:grid;grid-template-columns:minmax(0,1fr);place-items:center;padding:16px;background:#f4f4f3;color:#191a1b;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;isolation:isolate;overflow:hidden;container-type:inline-size}.sf-push-to-talk *{box-sizing:border-box}.sf-push-to-talk button,.sf-push-to-talk input,.sf-push-to-talk select,.sf-push-to-talk textarea{font:inherit;color:inherit}.sf-push-to-talk button{cursor:pointer;touch-action:manipulation}.sf-push-to-talk button:disabled{cursor:default;opacity:.45}.sf-push-to-talk :focus-visible{outline:2px solid #2767dc;outline-offset:3px}.sf-push-to-talk input[type=range]{min-height:44px;accent-color:currentColor;min-width:0}.sf-push-to-talk .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-push-to-talk[data-paused=true] *{animation-play-state:paused!important}@media(prefers-reduced-motion:reduce){.sf-push-to-talk *{animation:none!important;transition:none!important;scroll-behavior:auto!important}}.sf-push-to-talk{background:#1c211c;color:#edf4de;padding:24px}.sf-push-to-talk .recorder{width:min(100%,375px)}.sf-push-to-talk .take{display:flex;align-items:center;gap:13px;margin-bottom:26px}.sf-push-to-talk .light{width:7px;height:7px;border-radius:50%;background:#4d584b}.sf-push-to-talk .light[data-on=true]{background:#c7ed58;box-shadow:0 0 13px #c7ed5860}.sf-push-to-talk output{font-size:43px;line-height:1;font-weight:300;letter-spacing:-1px;font-variant-numeric:tabular-nums}.sf-push-to-talk small{font-size:13px;margin-left:5px;color:#9eac91}.sf-push-to-talk .limit{font-size:11px;color:#61735c;margin-left:auto}.sf-push-to-talk .hold{width:100%;height:64px;border:1px solid #5f704a;background:#2b3426;border-radius:12px;color:#deedc5;padding:0 17px;display:flex;align-items:center;gap:13px;text-align:left;touch-action:none;user-select:none;font-size:15px;transition:background 140ms,color 140ms}.sf-push-to-talk .hold[data-recording=true]{background:#c7ec63;color:#27331c;border-color:#c7ec63}.sf-push-to-talk .hold svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round}.sf-push-to-talk .key{margin-left:auto;border:1px solid currentColor;opacity:.45;border-radius:3px;padding:2px 5px;font:9px ui-monospace,monospace}.sf-push-to-talk p{font-size:11px;color:#889780;margin:13px 0;min-height:16px}.sf-push-to-talk footer{display:flex;justify-content:space-between;gap:8px}.sf-push-to-talk footer button{border:0;background:none;color:#b3c696;font-size:11px;min-height:44px;padding:0}.sf-push-to-talk .hold:disabled{opacity:.55}`}</style>
    </div>
  )
}

Where it works well

Leave a short voice message without a persistent recording mode.

Reduced motion

Recording feedback changes state without color tweening. Actual capture and playback remain opt-in.

Things to know

Requires microphone permission and MediaRecorder support. Takes are capped at 30 seconds, stored only in memory and discarded on unmount.

More examples