All patterns

Transcription Word Lock

Each spoken word lands dimmed under a dotted rule, then firms up to solid once the recognizer commits.

aifuturisticminimalautomatic · finite · starter · ~2.4s
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

260 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Transcription Word Lock
 *
 * Speech landing in two stages: each word arrives tentative — dimmed,
 * with a dotted underline — and firms up to solid a beat later, when the
 * recognizer stops second-guessing it.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Word tone is derived from the inherited text color, so the transcript
 * reads correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `text`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TranscriptionWordLockProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** What is being transcribed. Split on whitespace — a word is one tick. */
  text?: string;
  /** Header label while words are still arriving. */
  liveLabel?: string;
  /** Header label once every word has firmed up. */
  finalLabel?: string;
  /** Accent for the live marker. */
  accent?: string;
  /** Fires once the last word locks. */
  onComplete?: () => void;
};

type VariantConfig = {
  /** ms between words — the perceived speaking rate. */
  cadenceMs: number;
  /** ms a word stays tentative before it firms up. */
  lockLagMs: number;
  /** Opacity of a word the recognizer is still unsure about. */
  tentativeOpacity: number;
  /** How long the firm-up takes. */
  lockSeconds: number;
};

// Nothing here moves: words that slide or scale as they firm up would be
// unreadable while they are being read, which is the whole point of a live
// transcript. The only channels are opacity and the underline — the two
// things the eye can absorb without re-fixating.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely a change in weight — for a transcript sitting behind other
  // content, where certainty matters less than legibility.
  subtle: {
    cadenceMs: 105,
    lockLagMs: 300,
    tentativeOpacity: 0.55,
    lockSeconds: 0.34,
  },
  // A clear two-stage read. The all-purpose setting.
  default: {
    cadenceMs: 92,
    lockLagMs: 340,
    tentativeOpacity: 0.42,
    lockSeconds: 0.26,
  },
  // A longer tentative tail and a snappier lock, for a captions panel
  // where the firming-up is the thing being demonstrated.
  playful: {
    cadenceMs: 78,
    lockLagMs: 420,
    tentativeOpacity: 0.32,
    lockSeconds: 0.2,
  },
};

const SAMPLE_TEXT =
  "Let's move the vendor review to Thursday and send the updated invoice before the call so finance can close the month.";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` gives a panel, a border and a tentative underline that
 *  are correctly toned in either theme. The live marker stays literal. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function TranscriptionWordLock({
  variant = "default",
  text = SAMPLE_TEXT,
  liveLabel = "Live",
  finalLabel = "Final",
  accent = "#7C7CF0",
  onComplete,
}: TranscriptionWordLockProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const words = text.trim().split(/\s+/);
  const wordCount = words.length;

  const [arrived, setArrived] = useState(0);
  const [locked, setLocked] = useState(0);
  const done = locked >= wordCount;

  const onCompleteRef = useRef(onComplete);
  useEffect(() => {
    onCompleteRef.current = onComplete;
  }, [onComplete]);

  // Two streams at the same cadence, the second offset by the lock lag:
  // that offset is what keeps a constant tail of unsure words at the write
  // head instead of a backlog that grows without end. The cadence itself
  // survives reduced motion — words landing is the data arriving, not
  // decoration; only the fades below are dropped.
  useEffect(() => {
    if (arrived >= wordCount) return;
    const id = setTimeout(() => setArrived((n) => n + 1), cfg.cadenceMs);
    return () => clearTimeout(id);
  }, [arrived, wordCount, cfg.cadenceMs]);

  useEffect(() => {
    if (locked >= arrived) return;
    const id = setTimeout(
      () => setLocked((n) => n + 1),
      locked === 0 ? cfg.lockLagMs : cfg.cadenceMs
    );
    return () => clearTimeout(id);
  }, [locked, arrived, cfg.cadenceMs, cfg.lockLagMs]);

  useEffect(() => {
    if (done) onCompleteRef.current?.();
  }, [done]);

  const lockTransition = reduceMotion
    ? { duration: 0 }
    : { duration: cfg.lockSeconds, ease: "easeOut" as const };

  return (
    <div
      style={{
        width: 300,
        display: "flex",
        flexDirection: "column",
        gap: 10,
        padding: 14,
        borderRadius: 14,
        background: tone(5),
        border: `1px solid ${tone(11)}`,
        fontSize: 13.5,
      }}
    >
      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
        <svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
          <rect
            x="5.8"
            y="1.8"
            width="4.4"
            height="7.6"
            rx="2.2"
            stroke="currentColor"
            strokeWidth="1.4"
            opacity="0.6"
          />
          <path
            d="M3.4 7.6a4.6 4.6 0 0 0 9.2 0M8 12.2v2"
            stroke="currentColor"
            strokeWidth="1.4"
            strokeLinecap="round"
            opacity="0.6"
          />
        </svg>
        <span style={{ fontSize: 11.5, opacity: 0.55 }}>Transcript</span>

        {/* Both header states share one grid cell, so the word that names
            the state can never shift the row it sits in. */}
        <span
          style={{
            marginLeft: "auto",
            display: "grid",
            justifyItems: "end",
            whiteSpace: "nowrap",
          }}
        >
          <motion.span
            initial={false}
            animate={{ opacity: done ? 0 : 1 }}
            transition={{ duration: 0.2, ease: "easeOut" }}
            style={{
              gridArea: "1 / 1",
              display: "inline-flex",
              alignItems: "center",
              gap: 5,
              fontSize: 11,
              fontWeight: 600,
              color: accent,
            }}
          >
            <span
              aria-hidden
              style={{
                width: 6,
                height: 6,
                borderRadius: "50%",
                background: accent,
              }}
            />
            {liveLabel}
          </motion.span>
          <motion.span
            initial={false}
            animate={{ opacity: done ? 0.6 : 0 }}
            transition={{ duration: 0.24, ease: "easeOut", delay: done ? 0.1 : 0 }}
            style={{ gridArea: "1 / 1", fontSize: 11, fontWeight: 600 }}
          >
            {finalLabel}
          </motion.span>
        </span>
      </div>

      <p
        aria-live="polite"
        style={{
          margin: 0,
          minHeight: 68,
          lineHeight: 1.66,
          wordBreak: "break-word",
        }}
      >
        {words.slice(0, arrived).map((word, index) => {
          const isLocked = index < locked;
          return (
            <span key={index}>
              {/* Each word is its own inline-block so the dotted rule can
                  be positioned under it without leaving the text flow —
                  the words themselves wrap exactly as plain copy would. */}
              <span style={{ position: "relative", display: "inline-block" }}>
                <motion.span
                  initial={reduceMotion ? false : { opacity: 0 }}
                  animate={{ opacity: isLocked ? 1 : cfg.tentativeOpacity }}
                  transition={lockTransition}
                  style={{ display: "inline-block" }}
                >
                  {word}
                </motion.span>
                <motion.span
                  aria-hidden
                  initial={false}
                  animate={{ opacity: isLocked ? 0 : 1 }}
                  transition={lockTransition}
                  style={{
                    position: "absolute",
                    left: 0,
                    right: 0,
                    bottom: 1,
                    borderBottom: `1px dotted ${tone(38)}`,
                  }}
                />
              </span>{" "}
            </span>
          );
        })}
      </p>
    </div>
  );
}

About this pattern

Live speech recognition revises itself constantly, and a transcript that hides this leaves the reader unsure which parts they can trust. Two states carry it: a tentative word is dim with a dotted rule beneath it, a committed one is solid with the rule gone. The tail of unsure words stays a constant few behind the write head, so the boundary between guess and record is always visible. Nothing moves — words that slid or scaled as they firmed up would be unreadable while they are being read, which is the only thing a transcript is for.

Live transcriptionVoice dictationMeeting captionsSpeech input preview

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Ask a follow-up
    AI assistant

    Live meeting transcripts where recent speech is styled as provisional until it settles.

Related patterns