Skip to content
Product block

Voice Message Player

A voice note you can navigate: a waveform that is a real seek control, a speed button, a transcript, and a played state that survives finishing.

271 lineslucide-reactAdded 13 Sept 2026
  • voice
  • audio
  • player
  • transcript
  • messaging

What's included

  • components/voice-message-player.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add voice-message-player

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Voice messages

Priya Raman09:41

0:34 / 0:34

Quick one about the Monday run — the driver can take the chairs and two of the desks, but not the third. If you want all three desks together it has to be Wednesday. Let me know before four and I will hold the slot.

You09:48

0:00 / 0:12

Chairs and the two desks on Monday is fine. Put the third one on the Wednesday run with the lamps.

The transcript is not a courtesy. It is what makes a voice note searchable, quotable and usable by someone who cannot play audio right now — on a train, in a meeting, or at all.

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/voice-message-player.tsx
'use client'

/**
 * <VoiceMessagePlayer> — a voice note you can actually navigate.
 *
 * A voice message is the least scannable thing a chat can contain. You cannot
 * skim it, you cannot search it, and you cannot tell from the outside whether
 * the useful part is at 0:04 or 1:40. Most implementations ship a play button
 * and a duration and leave it there.
 *
 * FOUR THINGS THAT MAKE ONE USABLE
 *
 *  - **A waveform that is a seek control.** Not an animated decoration.
 *    Clicking or dragging it moves the playhead, and it is a real
 *    `<input type="range">` underneath so it works from the keyboard and
 *    announces its position. A canvas-with-pointer-events version loses both.
 *  - **A speed control.** 1×, 1.5×, 2×. A two-minute note at 2× is a
 *    one-minute note, and this single button is why people tolerate voice
 *    messages at all.
 *  - **A transcript.** The accessibility requirement and, in practice, the
 *    feature everybody uses: it makes the content searchable, skimmable and
 *    quotable. Collapsed by default, with the first line showing.
 *  - **Played state that persists.** The bar stays filled after it finishes,
 *    so a thread of six notes shows which one you stopped at.
 *
 * THE WAVEFORM IS DETERMINISTIC, NOT RANDOM. Generated from the message id, so
 * the same note draws the same shape on every render — a `Math.random()`
 * waveform changes between the server and the client, which React reports as a
 * hydration mismatch and then discards the subtree.
 *
 * ACCESSIBILITY: the scrubber is a labelled range input with `aria-valuetext`
 * in minutes and seconds, because "37" announced bare is not a position; the
 * transcript is a real disclosure with `aria-expanded` and `aria-controls`;
 * play state is in the button's accessible name, not only in the glyph.
 */

import * as React from 'react'
import { FileText, Pause, Play, SkipBack, SkipForward } from 'lucide-react'

export interface VoiceMessage {
  id: string
  author: string
  mine?: boolean
  at: string
  seconds: number
  transcript: string
  played?: boolean
}

export interface VoiceMessagePlayerProps {
  messages?: VoiceMessage[]
  speeds?: number[]
  className?: string
}

const DEFAULT_MESSAGES: VoiceMessage[] = [
  {
    id: 'v1',
    author: 'Priya Raman',
    at: '09:41',
    seconds: 34,
    played: true,
    transcript:
      'Quick one about the Monday run — the driver can take the chairs and two of the desks, but not the third. If you want all three desks together it has to be Wednesday. Let me know before four and I will hold the slot.',
  },
  {
    id: 'v2',
    author: 'You',
    mine: true,
    at: '09:48',
    seconds: 12,
    transcript:
      'Chairs and the two desks on Monday is fine. Put the third one on the Wednesday run with the lamps.',
  },
]

/** Deterministic from the id — a random waveform is a hydration mismatch. */
function bars(id: string, count: number) {
  let hash = 0
  for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
  return Array.from({ length: count }, () => {
    hash = (hash * 1103515245 + 12345) >>> 0
    return 20 + ((hash >>> 16) % 80)
  })
}

function clock(seconds: number) {
  const m = Math.floor(seconds / 60)
  const s = Math.floor(seconds % 60)
  return `${m}:${String(s).padStart(2, '0')}`
}

function spoken(seconds: number) {
  const m = Math.floor(seconds / 60)
  const s = Math.floor(seconds % 60)
  return m > 0 ? `${m} minutes ${s} seconds` : `${s} seconds`
}

function Note({ message, speeds }: { message: VoiceMessage; speeds: number[] }) {
  const uid = React.useId()
  const [position, setPosition] = React.useState(message.played ? message.seconds : 0)
  const [playing, setPlaying] = React.useState(false)
  const [speed, setSpeed] = React.useState(1)
  const [showTranscript, setShowTranscript] = React.useState(false)

  const shape = React.useMemo(() => bars(message.id, 42), [message.id])
  const progress = message.seconds === 0 ? 0 : position / message.seconds

  React.useEffect(() => {
    if (!playing) return
    const tick = window.setInterval(() => {
      setPosition((p) => {
        const next = p + 0.25 * speed
        if (next >= message.seconds) {
          setPlaying(false)
          return message.seconds
        }
        return next
      })
    }, 250)
    return () => window.clearInterval(tick)
  }, [playing, speed, message.seconds])

  function toggle() {
    // Restart when it has run to the end, rather than doing nothing.
    if (position >= message.seconds) setPosition(0)
    setPlaying((p) => !p)
  }

  function nudge(delta: number) {
    setPosition((p) => Math.min(message.seconds, Math.max(0, p + delta)))
  }

  return (
    <div className={`flex ${message.mine ? 'justify-end' : 'justify-start'}`}>
      <div
        className={`w-full max-w-sm rounded-2xl p-3 ${
          message.mine ? 'rounded-ee-sm bg-primary/10' : 'rounded-es-sm bg-muted'
        }`}
      >
        <p className="text-xs font-medium">
          {message.author}
          <span className="ms-2 font-normal text-muted-foreground">{message.at}</span>
        </p>

        <div className="mt-2 flex items-center gap-2">
          <button
            type="button"
            onClick={toggle}
            className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            {playing ? (
              <Pause aria-hidden className="h-4 w-4" />
            ) : (
              <Play aria-hidden className="h-4 w-4" />
            )}
            {/* State in the name, not only in the glyph. */}
            <span className="sr-only">
              {playing ? 'Pause' : 'Play'} the voice message from {message.author}
            </span>
          </button>

          <div className="relative min-w-0 flex-1">
            {/* Decorative; the range below is the real control. */}
            <div aria-hidden className="flex h-8 items-center gap-px">
              {shape.map((height, i) => (
                <span
                  key={i}
                  style={{ height: `${height}%` }}
                  className={`w-full rounded-full transition-colors ${
                    i / shape.length <= progress ? 'bg-primary' : 'bg-foreground/20'
                  }`}
                />
              ))}
            </div>
            <label htmlFor={`${uid}-seek`} className="sr-only">
              Position in the message from {message.author}
            </label>
            <input
              id={`${uid}-seek`}
              type="range"
              min={0}
              max={message.seconds}
              step={0.5}
              value={position}
              aria-valuetext={`${spoken(position)} of ${spoken(message.seconds)}`}
              onChange={(e) => setPosition(Number(e.target.value))}
              className="absolute inset-x-0 top-1/2 h-8 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent opacity-0 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            />
          </div>

          <span className="shrink-0 font-mono text-[11px] text-muted-foreground">
            {clock(position)} / {clock(message.seconds)}
          </span>
        </div>

        <div className="mt-2 flex flex-wrap items-center gap-1.5">
          <button
            type="button"
            onClick={() => nudge(-10)}
            className="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <SkipBack aria-hidden className="h-3 w-3" />
            10s
          </button>
          <button
            type="button"
            onClick={() => nudge(10)}
            className="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <SkipForward aria-hidden className="h-3 w-3" />
            10s
          </button>

          {/* The control that makes voice notes tolerable. */}
          <button
            type="button"
            onClick={() => setSpeed((s) => speeds[(speeds.indexOf(s) + 1) % speeds.length] ?? 1)}
            className="rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            {speed}×<span className="sr-only"> playback speed, tap to change</span>
          </button>

          <button
            type="button"
            onClick={() => setShowTranscript((v) => !v)}
            aria-expanded={showTranscript}
            aria-controls={`${uid}-transcript`}
            className="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <FileText aria-hidden className="h-3 w-3" />
            Transcript
          </button>
        </div>

        <div id={`${uid}-transcript`} className="mt-2">
          <p
            className={`break-words text-xs text-muted-foreground ${
              showTranscript ? '' : 'line-clamp-2'
            }`}
          >
            {message.transcript}
          </p>
        </div>
      </div>
    </div>
  )
}

export function VoiceMessagePlayer({
  messages = DEFAULT_MESSAGES,
  speeds = [1, 1.5, 2],
  className = '',
}: VoiceMessagePlayerProps) {
  return (
    <section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
      <div className="mx-auto max-w-2xl space-y-3 rounded-2xl border border-border bg-card p-4">
        <h2 className="text-sm font-semibold">Voice messages</h2>
        {messages.map((message) => (
          <Note key={message.id} message={message} speeds={speeds} />
        ))}
        <p className="pt-1 text-xs text-muted-foreground">
          The transcript is not a courtesy. It is what makes a voice note
          searchable, quotable and usable by someone who cannot play audio right
          now — on a train, in a meeting, or at all.
        </p>
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/voice-message-player.tsx and import it where you need the section:

import { VoiceMessagePlayer } from '@/components/voice-message-player'

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
messagesVoiceMessage[]DEFAULT_MESSAGES
speedsnumber[][1, 1.5, 2]
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

voice-message-player.html
<!--
  Voice Message Player — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section class="bg-background px-4 py-10 sm:px-6 ">
  <div class="mx-auto max-w-2xl space-y-3 rounded-2xl border border-border bg-card p-4">
    <h2 class="text-sm font-semibold">Voice messages</h2>
    <div class="flex justify-start">
      <div class="w-full max-w-sm rounded-2xl p-3 rounded-es-sm bg-muted">
        <p class="text-xs font-medium">Priya Raman<span class="ms-2 font-normal text-muted-foreground">09:41</span></p>
        <div class="mt-2 flex items-center gap-2">
          <button type="button" class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-play h-4 w-4" aria-hidden="true">
              <polygon points="6 3 20 12 6 21 6 3"></polygon>
            </svg>
            <span class="sr-only">Play the voice message from Priya Raman</span>
          </button>
          <div class="relative min-w-0 flex-1">
            <div aria-hidden="true" class="flex h-8 items-center gap-px">
              <span style="height:69%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:50%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:34%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:74%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:21%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:58%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:60%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:54%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:41%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:69%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:81%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:21%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:48%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:63%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:32%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:22%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:57%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:82%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:54%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:77%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:56%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:23%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:90%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:33%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:28%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:30%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:95%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:56%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:33%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:99%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:69%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:59%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:89%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:81%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:96%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:46%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:66%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:27%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:71%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:94%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:54%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:48%" class="w-full rounded-full transition-colors bg-primary"></span>
            </div>
            <label for="_R_6_-seek" class="sr-only">Position in the message from Priya Raman</label>
            <input id="_R_6_-seek" type="range" min="0" max="34" step="0.5" aria-valuetext="34 seconds of 34 seconds" class="absolute inset-x-0 top-1/2 h-8 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent opacity-0 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" value="34" />
          </div>
          <span class="shrink-0 font-mono text-[11px] text-muted-foreground">0:34 / 0:34</span>
        </div>
        <div class="mt-2 flex flex-wrap items-center gap-1.5">
          <button type="button" class="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-skip-back h-3 w-3" aria-hidden="true"><polygon points="19 20 9 12 19 4 19 20"></polygon><line x1="5" x2="5" y1="19" y2="5"></line></svg>10s</button>
          <button type="button" class="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-skip-forward h-3 w-3" aria-hidden="true"><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" x2="19" y1="5" y2="19"></line></svg>10s</button>
          <button type="button" class="rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">1×<span class="sr-only"> playback speed, tap to change</span></button>
          <button type="button" aria-expanded="false" aria-controls="_R_6_-transcript" class="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text h-3 w-3" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>Transcript</button>
        </div>
        <div id="_R_6_-transcript" class="mt-2">
          <p class="break-words text-xs text-muted-foreground line-clamp-2">Quick one about the Monday run — the driver can take the chairs and two of the desks, but not the third. If you want all three desks together it has to be Wednesday. Let me know before four and I will hold the slot.</p>
        </div>
      </div>
    </div>
    <div class="flex justify-end">
      <div class="w-full max-w-sm rounded-2xl p-3 rounded-ee-sm bg-primary/10">
        <p class="text-xs font-medium">You<span class="ms-2 font-normal text-muted-foreground">09:48</span></p>
        <div class="mt-2 flex items-center gap-2">
          <button type="button" class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-play h-4 w-4" aria-hidden="true">
              <polygon points="6 3 20 12 6 21 6 3"></polygon>
            </svg>
            <span class="sr-only">Play the voice message from You</span>
          </button>
          <div class="relative min-w-0 flex-1">
            <div aria-hidden="true" class="flex h-8 items-center gap-px">
              <span style="height:28%" class="w-full rounded-full transition-colors bg-primary"></span>
              <span style="height:36%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:88%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:90%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:45%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:44%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:58%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:63%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:25%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:64%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:45%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:81%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:78%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:79%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:72%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:63%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:51%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:91%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:35%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:89%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:80%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:63%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:98%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:87%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:81%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:88%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:85%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:57%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:38%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:84%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:96%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:33%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:67%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:37%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:60%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:77%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:47%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:29%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:21%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:82%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:80%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
              <span style="height:42%" class="w-full rounded-full transition-colors bg-foreground/20"></span>
            </div>
            <label for="_R_a_-seek" class="sr-only">Position in the message from You</label>
            <input id="_R_a_-seek" type="range" min="0" max="12" step="0.5" aria-valuetext="0 seconds of 12 seconds" class="absolute inset-x-0 top-1/2 h-8 w-full -translate-y-1/2 cursor-pointer appearance-none bg-transparent opacity-0 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" value="0" />
          </div>
          <span class="shrink-0 font-mono text-[11px] text-muted-foreground">0:00 / 0:12</span>
        </div>
        <div class="mt-2 flex flex-wrap items-center gap-1.5">
          <button type="button" class="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-skip-back h-3 w-3" aria-hidden="true"><polygon points="19 20 9 12 19 4 19 20"></polygon><line x1="5" x2="5" y1="19" y2="5"></line></svg>10s</button>
          <button type="button" class="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-skip-forward h-3 w-3" aria-hidden="true"><polygon points="5 4 15 12 5 20 5 4"></polygon><line x1="19" x2="19" y1="5" y2="19"></line></svg>10s</button>
          <button type="button" class="rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-semibold transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">1×<span class="sr-only"> playback speed, tap to change</span></button>
          <button type="button" aria-expanded="false" aria-controls="_R_a_-transcript" class="inline-flex items-center gap-1 rounded-lg border border-border bg-card px-2 py-1 text-[11px] font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text h-3 w-3" aria-hidden="true"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>Transcript</button>
        </div>
        <div id="_R_a_-transcript" class="mt-2">
          <p class="break-words text-xs text-muted-foreground line-clamp-2">Chairs and the two desks on Monday is fine. Put the third one on the Wednesday run with the lamps.</p>
        </div>
      </div>
    </div>
    <p class="pt-1 text-xs text-muted-foreground">The transcript is not a courtesy. It is what makes a voice note searchable, quotable and usable by someone who cannot play audio right now — on a train, in a meeting, or at all.</p>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

8 more blocks in Communication

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Communication

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Communication blocks

View category
Open the full page for this block

Support Chat Widget

The corner launcher, with real availability instead of a stock reassurance, article deflection before the form, no self-opening, and a way to make it go away.

Communication307 lines1 dep
Open the full page for this block

Message Bubble Thread

Bubbles with the parts that get skipped: per-message delivery state, grouping by author and time, day separators, and a retry on the failed bubble itself.

Communication325 lines1 dep

Attachment Bubbles

Five kinds of thing that is not text - image, document, link, location and an upload still in flight - each answering the question its own kind raises.

Communication305 lines1 dep
Open the full page for this block

Inbox Thread List

The list half of a two-pane inbox, with unread and selected as visibly different states and bulk selection as a mode rather than a hover checkbox.

Communication333 lines1 dep
Open the full page for this block

Inbox Reading Pane

The reading pane with the composer always present, quoted history collapsed behind a count, and an internal-note mode whose send button names the recipient.

Communication311 lines1 dep
Open the full page for this block

Video Call Layout

The call grid with the state that makes it usable - active speaker, per-tile connection quality, hide self-view, and the you-are-muted nudge.

Communication286 lines1 dep
Open the full page for this block

Call Device Settings

The green room, where every picker has a test beside it - an input meter, a test tone, a camera preview - and blocked permission is a state, not an error.

Communication315 lines1 dep
Open the full page for this block

Support Ticket Thread

The customer side of a ticket, where a named owner and a deadline replace Status: Open - including the waiting-on-you state that stops the argument about response times.

Communication340 lines1 dep