Subframe
Components & interactionsVoice picker

Voice audition

A sculpted three-voice tray separates narrator selection from tactile playback controls.

Select a narrator or press its play key to audition the shared phrase.

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"

const LINE = "A little room for a new perspective."
export default function VoiceAudition({ active = true }: { active?: boolean }) {
  const [voices, setVoices] = React.useState<SpeechSynthesisVoice[]>([])
  const [selected, setSelected] = React.useState("")
  const [playing, setPlaying] = React.useState("")
  const [started, setStarted] = React.useState(false)
  const [notice, setNotice] = React.useState("")
  const [word, setWord] = React.useState(-1)
  const utterance = React.useRef<SpeechSynthesisUtterance | null>(null)
  const stop = React.useCallback(() => {
    if (utterance.current) {
      utterance.current = null
      window.speechSynthesis.cancel()
    }
    setPlaying("")
    setStarted(false)
    setWord(-1)
  }, [])
  React.useEffect(() => {
    if (!("speechSynthesis" in window)) {
      setNotice("System speech is unavailable.")
      return
    }
    const load = () => {
      const available = speechSynthesis.getVoices()
      const english = available.filter((v) => v.lang.startsWith("en"))
      const pool = english.length >= 3 ? english : available
      const next: SpeechSynthesisVoice[] = []
      const primary = pool.find((v) => v.default) || pool[0]
      if (primary) next.push(primary)
      for (const voice of pool) {
        if (next.length === 3) break
        if (!next.some((v) => v.lang === voice.lang)) next.push(voice)
      }
      for (const voice of pool) {
        if (next.length === 3) break
        if (!next.some((v) => v.voiceURI === voice.voiceURI)) next.push(voice)
      }
      setVoices(next)
      setSelected((s) => (next.some((v) => v.voiceURI === s) ? s : next[0]?.voiceURI || ""))
    }
    load()
    speechSynthesis.addEventListener("voiceschanged", load)
    return () => {
      speechSynthesis.removeEventListener("voiceschanged", load)
      if (utterance.current) {
        utterance.current = null
        speechSynthesis.cancel()
      }
    }
  }, [])
  React.useEffect(() => {
    if (!active) stop()
  }, [active, stop])
  React.useEffect(() => {
    const hide = () => {
      if (document.hidden) stop()
    }
    document.addEventListener("visibilitychange", hide)
    return () => document.removeEventListener("visibilitychange", hide)
  }, [stop])
  const preview = (v: SpeechSynthesisVoice) => {
    if (!active) return
    if (playing === v.voiceURI) {
      stop()
      return
    }
    if (!utterance.current && speechSynthesis.speaking) {
      setNotice("Another speech preview is playing.")
      return
    }
    stop()
    setNotice("")
    const u = new SpeechSynthesisUtterance(LINE)
    u.voice = v
    u.rate = 0.94
    u.onstart = () => {
      if (utterance.current === u) setStarted(true)
    }
    u.onboundary = (e) => {
      if (utterance.current === u) setWord(e.charIndex)
    }
    u.onend = () => {
      if (utterance.current === u) {
        utterance.current = null
        setPlaying("")
        setStarted(false)
        setWord(-1)
      }
    }
    u.onerror = (e) => {
      if (utterance.current === u) {
        utterance.current = null
        setPlaying("")
        setStarted(false)
        setWord(-1)
        if (e.error !== "interrupted" && e.error !== "canceled") setNotice("This system voice could not play.")
      }
    }
    utterance.current = u
    setPlaying(v.voiceURI)
    speechSynthesis.speak(u)
  }
  return (
    <section className="sf-voice-audition" data-paused={!active}>
      <div className="sf-va-tray">
        <div className="sf-va-heading">
          <span>Narrator</span>
          <span className="sf-va-device">System voices</span>
        </div>
        <div className="sf-va-voices">
          {[0, 1, 2].map((i) => {
            const v = voices[i],
              chosen = !!v && selected === v.voiceURI,
              busy = !!v && playing === v.voiceURI
            return (
              <div className="sf-va-row" key={v?.voiceURI || i} data-selected={chosen} data-playing={busy && started}>
                <button
                  className="sf-va-choose"
                  type="button"
                  disabled={!active || !v}
                  aria-pressed={chosen}
                  aria-label={v ? "Choose " + v.name : "No voice available"}
                  onClick={() => v && setSelected(v.voiceURI)}
                >
                  <span className="sf-va-indicator" aria-hidden="true" />
                  <span>
                    <strong>{v?.name || "Voice unavailable"}</strong>
                    <small>{v?.lang || "—"}</small>
                  </span>
                </button>
                <button
                  className="sf-va-play"
                  type="button"
                  disabled={!active || !v}
                  aria-pressed={busy}
                  aria-label={(busy ? "Stop " : "Preview ") + (v?.name || "voice")}
                  onClick={() => v && preview(v)}
                >
                  {busy ? (
                    <span className="sf-va-levels" aria-hidden="true">
                      {[0, 1, 2, 3, 4].map((n) => (
                        <i key={n} style={{ animationDelay: n * -0.13 + "s" }} />
                      ))}
                    </span>
                  ) : (
                    <svg viewBox="0 0 16 16" aria-hidden="true">
                      <path d="m6 4 6 4-6 4z" />
                    </svg>
                  )}
                </button>
              </div>
            )
          })}
        </div>
        <p className="sf-va-line" aria-label={LINE}>
          {LINE.split(" ").map((w, i, a) => {
            const start = a.slice(0, i).join(" ").length + (i ? 1 : 0)
            return (
              <span key={i} data-current={word >= start && word < start + w.length}>
                {w}{" "}
              </span>
            )
          })}
        </p>
        <p className="sf-va-notice" role="status">
          {notice || (!voices.length ? "No system voices are available in this browser." : "")}
        </p>
      </div>
      <style>{`.sf-voice-audition{width:100%;height:100%;display:grid;place-items:center;overflow:hidden;background:#e4e8eb;color:#29313a;font:13px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.sf-voice-audition *{box-sizing:border-box}.sf-va-tray{width:390px}.sf-va-heading{display:flex;justify-content:space-between;align-items:center;margin:0 7px 13px;font-size:12px;font-weight:500}.sf-va-device{font-size:10px;color:#828d98;font-weight:400}.sf-va-voices{display:flex;flex-direction:column;gap:9px}.sf-va-row{height:65px;display:flex;align-items:center;gap:10px;padding:0 12px 0 3px;border-radius:15px;background:linear-gradient(130deg,#eff2f4,#e0e5e9);box-shadow:0 2px 3px #a5afb744,inset 0 1px 0 #fff9;border:1px solid #edf1f5;transition:box-shadow 150ms}.sf-va-row[data-selected=true]{background:linear-gradient(130deg,#e2e8ed,#dae1e7);box-shadow:inset 2px 2px 4px #9ba8b43b,inset -1px -1px 1px #fff8;border-color:#dce2e7}.sf-voice-audition button{font:inherit;color:inherit;cursor:pointer;touch-action:manipulation}.sf-va-choose{height:100%;display:flex;align-items:center;gap:16px;min-width:0;flex:1;padding:0 17px;border:0;background:none;text-align:left;border-radius:12px}.sf-va-indicator{width:7px;height:7px;border-radius:50%;flex-shrink:0;background:#b3bdc6;box-shadow:inset 0 1px 1px #62738255,0 1px 0 #fff8}.sf-va-row[data-selected=true] .sf-va-indicator{background:#3474c1;box-shadow:inset 0 1px 1px #255493,0 0 5px #568fbc40,0 1px 0 #fff8}.sf-va-choose>span:last-child{min-width:0}.sf-va-choose strong{font-size:14px;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block;letter-spacing:-.1px}.sf-va-choose small{display:block;font-size:10px;color:#8b95a0;margin-top:4px}.sf-va-play{width:44px;height:44px;border-radius:50%;flex-shrink:0;display:grid;place-items:center;border:1px solid #eff3f6;background:linear-gradient(145deg,#f8fafb,#d9e0e6);box-shadow:2px 3px 5px #99a6b34a,-1px -1px 2px #fff9;transition:transform 120ms,box-shadow 120ms}.sf-va-play svg{width:20px;height:20px;fill:#566c80}.sf-va-play[aria-pressed=true],.sf-va-play:active{background:linear-gradient(145deg,#d3dde5,#e6ecf0);box-shadow:inset 2px 2px 4px #7b91a14a,inset -1px -1px 2px #ffffff70;transform:translateY(1px)}.sf-va-levels{display:flex;gap:2px;height:15px;align-items:center}.sf-va-levels i{height:9px;width:2px;background:#357ab3;border-radius:1px}.sf-va-row[data-playing=true] .sf-va-levels i{animation:sf-va-level 700ms ease-in-out infinite alternate}.sf-va-line{margin:20px 0 0;text-align:center;color:#687886;font-size:11px;letter-spacing:.04px}.sf-va-line span[data-current=true]{color:#2f6296}.sf-va-notice{font-size:10px;color:#7b8792;margin:8px 0 0;min-height:12px;text-align:center}.sf-voice-audition :focus-visible{outline:2px solid #367aac;outline-offset:3px}.sf-voice-audition button:disabled{opacity:.45;cursor:default}.sf-voice-audition[data-paused=true] *{animation-play-state:paused!important}@keyframes sf-va-level{to{height:3px}}@media(prefers-reduced-motion:reduce){.sf-voice-audition *{animation:none!important;transition:none!important}}`}</style>
    </section>
  )
}

Where it works well

Compare the same line across available system voices before choosing a narrator.

Reduced motion

Playback state stays legible through a static pressed key; animated bars stop. Real speech still plays on request.

Things to know

Uses up to three available system voices, preferring different available English locales. Voice names, quality, availability and word-boundary events vary by browser/device. Bars indicate playback, not measured amplitude.

More examples