VoiceInput

Voice input for a conversation, sized to sit in a page. It comes as a bar that takes the place of a composer, or as a panel that holds the exchange. An ambient border turns around it in the brand colour while the person speaks and in the secondary colour while the assistant answers, and its glow follows their voices.

Chat VoiceInput.jsx VoiceInput.d.ts VoiceInput.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

VoiceInput is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { VoiceInput } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Voice input for a conversation, sized to sit in a page. It comes as a bar that takes the place of a composer, or as a panel that holds the exchange. An ambient border turns around it in the brand colour while the person speaks and in the secondary colour while the assistant answers, and its glow follows their voices.

Use it when

  • People can talk to an assistant inside a page or a chat panel, without leaving what they're doing.
  • Voice sits beside typing. Swap the Composer for the bar while the person is speaking.

Don't use it when

  • The conversation should take over the screen, for example a hands-free mode or a call on a phone. Use VoiceOverlay.
  • You only need a microphone button that dictates into a field. Use an IconButton in the field.
  • It's a recording or an audio player. VoiceInput shows a conversation, not a recording.

Example

const [state, setState] = useState("idle");

<VoiceInput
  layout="panel"
  state={state}
  inputStream={micStream}
  outputStream={replyStream}
  transcript={partialTranscript}
  response={reply}
  assistantName="Kiln & Co"
  onToggle={() => (state === "listening" ? stopListening() : startListening())}
  onEnd={endConversation}
/>

Behaviour

  • Controlled. Your app owns the microphone, the speech recognition and the reply. It tells the component which state the conversation is in: idle, listening, thinking, speaking or error. The component draws that state and never changes it itself.
  • The microphone calls onToggle. While listening it's a solid stop button named "Stop listening" and marked aria-pressed. Otherwise it's a microphone named "Speak".
  • End appears when you pass onEnd and the conversation isn't idle.
  • Words. transcript is what the person is saying, live or final. response is the assistant's reply. While listening, only the transcript shows. Otherwise the panel shows both lines, labelled "You" and assistantName, and the bar shows the reply.
  • Tone. tone="spectrum" turns the border into a six-hue wheel instead of the brand colours. Error stays danger. See AmbientBorder.
  • Level. As for AmbientBorder: a level prop first, otherwise inputStream while listening and outputStream while speaking, otherwise a built-in rhythm.

Layouts

  • bar: a pill one control high, with the microphone, one line of words and the status. Put it where the composer goes.
  • panel: a card with the status and End at the top, the exchange in the middle and a large microphone at the foot. Use it in a sidebar, a help panel or an empty chat.

Tokens

  • Layout: --dt-voice-gap, --dt-voice-padding, --dt-voice-panel-padding.
  • Words: --dt-voice-label-fg for the status, --dt-voice-placeholder-fg for the placeholder. Text uses the eyebrow, body-sm and body-lg roles.
  • The border reads every AmbientBorder token, and the buttons read --dt-button-*.

Accessibility

  • The whole input is a group named by label ("Voice input").
  • The status ("Listening", "Thinking", "Speaking", "Didn't catch that") is a polite live region, so a change of state is announced without moving focus.
  • The microphone is a toggle button with aria-pressed and a name that says what it will do.
  • Never let voice be the only way in. Keep a Composer within reach.
  • Under prefers-reduced-motion the border stands still. The status still names the state.

Content

  • placeholder says how to start: "Press the microphone and speak".
  • assistantName is the name people know the assistant by, kept short.
  • On error, show what to try next in response: "I didn't catch that. Try again?"

Props

The typed contract, from VoiceInput.d.ts.

import * as React from "react";
import type { VoiceState, VoiceTone } from "./AmbientBorder";

/**
 * Voice input for a conversation, at the size of a module: a bar where a
 * composer would sit, or a panel that holds the exchange. Controlled: the app
 * owns the microphone, the recognition and the reply, and passes the state.
 * An ambient border turns in the person's colour while they speak and the
 * assistant's while it answers, following the voice's level.
 */
export interface VoiceInputProps extends React.HTMLAttributes<HTMLElement> {
  /** Where the conversation is: idle, listening, thinking, speaking or error. @default "idle" */
  state?: VoiceState;
  /**
   * brand: each state in its own colours, the person in the brand colour and
   * the assistant in the secondary brand colour. spectrum: a six-hue wheel
   * (--dt-voice-spectrum-1 to -6) for every state but error, which stays
   * danger; the state still sets the speed, the glow and the comets.
   * @default "brand"
   */
  tone?: VoiceTone;
  /** The voice's loudness, 0 to 1, from your own meter. Wins over the streams. */
  level?: number;
  /** The person's microphone, read while listening. */
  inputStream?: MediaStream | null;
  /** The assistant's voice, read while speaking. */
  outputStream?: MediaStream | null;
  /** What the person is saying, or said last. */
  transcript?: string;
  /** The assistant's reply, shown while it thinks and speaks. */
  response?: string;
  /** Shown when nothing has been said yet. @default "Press the microphone and speak" */
  placeholder?: string;
  /** Who replies, before their words in the panel. @default "Assistant" */
  assistantName?: string;
  /** bar: one line, pill-shaped, where a composer sits. panel: the exchange in a card, with a large microphone. @default "bar" */
  layout?: "bar" | "panel";
  /** The group's accessible name. @default "Voice input" */
  label?: string;
  /** The microphone button: start listening, or stop. Its pressed state follows listening. */
  onToggle?: () => void;
  /** Shows an End button while the conversation isn't idle. */
  onEnd?: () => void;
  /** Turns the buttons off. @default false */
  disabled?: boolean;
}

export declare function VoiceInput(props: VoiceInputProps): React.JSX.Element;

Tokens it reads

Every custom property VoiceInput.jsx resolves, read from the source: 5 component, 155 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-voice-gapcomponentvar(--dt-space-inline-sm)
--dt-voice-label-fgcomponentvar(--dt-text-secondary)
--dt-voice-paddingcomponentvar(--dt-space-inset-xs)
--dt-voice-panel-paddingcomponentvar(--dt-space-inset-lg)
--dt-voice-placeholder-fgcomponentvar(--dt-text-tertiary)
--dt-layout-stack-groupsemanticvar(--dt-dim-4)
--dt-size-control-lgsemanticvar(--dt-dim-12)
--dt-size-control-smsemanticvar(--dt-dim-8)
--dt-size-icon-mdsemanticvar(--dt-dim-5)
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--dt-text-primary)

Source

React only, no dependencies, every value a token.

import React from "react";
import { AmbientBorder } from "./AmbientBorder.jsx";
import { IconButton } from "../actions/IconButton.jsx";

/* Voice input for a conversation, at the size of a module: a bar that sits
   where a composer would, or a panel that holds the exchange. Controlled: the
   app owns the microphone, the recognition and the reply, and says which
   state the conversation is in; the component draws it, with an ambient
   border that turns in the person's colour while they speak and the
   assistant's while it answers, and follows the voice's level. */

/* A semantic text role, whole: family, size, line, weight and tracking. */
function voiceType(role) {
  return { fontFamily: `var(--dt-text-${role}-family)`, fontSize: `var(--dt-text-${role}-size)`, lineHeight: `var(--dt-text-${role}-line)`, fontWeight: `var(--dt-text-${role}-weight)`, letterSpacing: `var(--dt-text-${role}-tracking)` };
}

const VOICE_LABELS = { idle: "Ready", listening: "Listening", thinking: "Thinking", speaking: "Speaking", error: "Didn't catch that" };

const MIC = ["M12 3a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3Z", "M19 11a7 7 0 0 1-14 0", "M12 18v3"];
const STOP = ["M8 8h8v8H8z"];
const CLOSE = ["M6 6l12 12", "M18 6 6 18"];

function VoiceGlyph({ paths }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false"
      style={{ width: "var(--dt-size-icon-md)", height: "var(--dt-size-icon-md)", display: "block" }}>
      {paths.map((d) => <path key={d} d={d} />)}
    </svg>
  );
}

/* The words on screen: what the person is saying while they speak, the
   assistant's reply while it answers, the last thing said otherwise. */
function linesFor(state, transcript, response) {
  return { you: transcript, them: state === "listening" ? "" : response };
}

export function VoiceInput({
  state = "idle",
  tone = "brand",
  level,
  inputStream,
  outputStream,
  transcript,
  response,
  placeholder = "Press the microphone and speak",
  assistantName = "Assistant",
  layout = "bar",
  label = "Voice input",
  onToggle,
  onEnd,
  disabled = false,
  style,
  ...rest
}) {
  const listening = state === "listening";
  const active = state !== "idle";
  const status = VOICE_LABELS[state] || VOICE_LABELS.idle;
  const { you, them } = linesFor(state, transcript, response);
  const mic = (
    <IconButton
      label={listening ? "Stop listening" : "Speak"}
      aria-pressed={listening}
      variant={listening ? "solid" : "ghost"}
      size={layout === "panel" ? "lg" : "md"}
      disabled={disabled}
      onClick={onToggle}
    >
      <VoiceGlyph paths={listening ? STOP : MIC} />
    </IconButton>
  );
  const end = onEnd && active ? (
    <IconButton label="End voice" size="sm" disabled={disabled} onClick={onEnd}><VoiceGlyph paths={CLOSE} /></IconButton>
  ) : null;
  const statusText = (
    <span role="status" aria-live="polite" style={{ ...voiceType("eyebrow"), color: "var(--dt-voice-label-fg)", textTransform: "uppercase", whiteSpace: "nowrap" }}>
      {status}
    </span>
  );

  if (layout === "panel") {
    return (
      <AmbientBorder state={state} tone={tone} level={level} inputStream={inputStream} outputStream={outputStream} radius="container" role="group" aria-label={label} style={style} {...rest}>
        <div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-layout-stack-group)", padding: "var(--dt-voice-panel-padding)" }}>
          <div style={{ display: "flex", alignItems: "center", gap: "var(--dt-voice-gap)", minHeight: "var(--dt-size-control-sm)" }}>
            <span style={{ flex: 1 }}>{statusText}</span>
            {end}
          </div>
          <div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-layout-stack-related)", minHeight: "calc(var(--dt-size-control-lg) * 2)" }}>
            {you || them ? (
              <>
                {you && <p style={{ ...voiceType("body-sm"), margin: 0, color: "var(--dt-text-secondary)" }}><span style={{ color: "var(--dt-text-tertiary)" }}>You: </span>{you}</p>}
                {them && <p style={{ ...voiceType("body-lg"), margin: 0, color: "var(--dt-text-primary)" }}><span style={{ ...voiceType("body-sm"), color: "var(--dt-text-tertiary)" }}>{assistantName}: </span>{them}</p>}
              </>
            ) : (
              <p style={{ ...voiceType("body-lg"), margin: 0, color: "var(--dt-voice-placeholder-fg)" }}>{placeholder}</p>
            )}
          </div>
          <div style={{ display: "flex", justifyContent: "center" }}>{mic}</div>
        </div>
      </AmbientBorder>
    );
  }

  const line = listening ? you : them || you;
  return (
    <AmbientBorder state={state} tone={tone} level={level} inputStream={inputStream} outputStream={outputStream} radius="pill" role="group" aria-label={label} style={style} {...rest}>
      <div style={{ display: "flex", alignItems: "center", gap: "var(--dt-voice-gap)", padding: "var(--dt-voice-padding)", minHeight: "var(--dt-size-control-lg)", boxSizing: "border-box" }}>
        {mic}
        <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", ...voiceType("body-sm"), color: line ? "var(--dt-text-primary)" : "var(--dt-voice-placeholder-fg)" }}>
          {line || placeholder}
        </span>
        {active && statusText}
        {end}
      </div>
    </AmbientBorder>
  );
}