Qure UI
Components

Waveform

Aira's voice, drawn — a bar chart of amplitude that can sit still or move.

'use client'import { Waveform } from '@/registry/qure/ui/waveform'export default function WaveformDemo() {  return <Waveform state="listening" label="Aira is listening" />}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/waveform.json

Usage

import { Waveform } from '@/components/ui/waveform'
<Waveform state="listening" label="Aira is listening" />

Figma ships this as two frames — a regular motif and an irregular one — and a frame is not a component. Measured, it is 28 bars at a uniform 5.92px pitch whose heights are exact eighths of the container. So the thing to build is a bar chart of amplitude, and the two frames become two things you can do with it: sit still, or move.

States

idlethe drawn motif, unmoving
listeningtaking audio in
speakingslower, full travel
'use client'import { Waveform } from '@/registry/qure/ui/waveform'const states = [  { state: 'idle', note: 'the drawn motif, unmoving' },  { state: 'listening', note: 'taking audio in' },  { state: 'speaking', note: 'slower, full travel' },] as constexport default function WaveformState() {  return (    <div className="flex w-full max-w-md flex-col gap-5">      {states.map(s => (        <div key={s.state} className="flex items-center gap-4">          <Waveform state={s.state} />          <span className="text-body-small text-muted-foreground">            <code>{s.state}</code> — {s.note}          </span>        </div>      ))}    </div>  )}

idle is the drawn pattern, unmoving — the right thing before a session starts, or when the microphone is off but the affordance should stay put rather than disappear.

listening and speaking are the same motion at different speeds. The distinction is worth keeping: a person needs to know whether they are being heard or being talked to, and those are opposite instructions about whether to speak.

The bars animate with scaleY rather than height, so 28 of them do not force a relayout on every frame.

From real data

'use client'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import { Waveform } from '@/registry/qure/ui/waveform'/** * Driving it from data rather than from a state. The values here come from a * timer, but the shape is the same one an AnalyserNode gives you: a fixed * number of bins, each 0 to 1, replaced on every frame. */export default function WaveformValues() {  const [running, setRunning] = React.useState(true)  const [values, setValues] = React.useState(() => Array.from({ length: 28 }, () => 0.2))  React.useEffect(() => {    if (!running) return    const id = setInterval(() => {      setValues(prev => prev.map((_, i) => 0.15 + 0.85 * Math.abs(Math.sin(Date.now() / 320 + i / 3))))    }, 90)    return () => clearInterval(id)  }, [running])  return (    <div className="flex flex-col items-center gap-4">      <Waveform values={values} label="Recording a voice note" />      <Button variant="secondary" size="sm" onClick={() => setRunning(r => !r)}>        {running ? 'Pause' : 'Resume'}      </Button>    </div>  )}

values takes amplitudes from 0 to 1, and its length decides the bar count. The shape is the one an AnalyserNode gives you:

const data = new Uint8Array(analyser.frequencyBinCount)
analyser.getByteFrequencyData(data)
setValues([...data].map(v => v / 255))

Values are clamped rather than trusted. A live analyser overshoots, and an unclamped bar taller than its box is exactly how two of the bars in the Figma frame ended up clipped.

Size

sm, 14 bars
md, 28 bars — the Figma size
'use client'import { Waveform } from '@/registry/qure/ui/waveform'export default function WaveformSize() {  return (    <div className="flex flex-col items-start gap-5">      <div className="flex items-center gap-4">        <Waveform size="sm" bars={14} state="listening" />        <code className="text-label-base text-muted-foreground">sm, 14 bars</code>      </div>      <div className="flex items-center gap-4">        <Waveform size="md" state="listening" />        <code className="text-label-base text-muted-foreground">md, 28 bars — the Figma size</code>      </div>    </div>  )}

md is the Figma size, 23px tall with 28 bars. sm is for a toolbar or an inline chip. bars sets the count independently — fewer, wider-spaced bars read better in a narrow slot than 28 crushed together.

In place

0:47
'use client'import { MicIcon, SquareIcon } from 'lucide-react'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import { Waveform } from '@/registry/qure/ui/waveform'/** * In place: a dictation bar on a report. The waveform is the only thing on * screen saying the microphone is live, so it carries a label — and the * elapsed time is there because a waveform says "sound is happening" and * nothing about how long for. */export default function WaveformBar() {  const [recording, setRecording] = React.useState(true)  const [seconds, setSeconds] = React.useState(47)  React.useEffect(() => {    if (!recording) return    const id = setInterval(() => setSeconds(s => s + 1), 1000)    return () => clearInterval(id)  }, [recording])  const mmss = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`  return (    <div className="bg-card flex w-full max-w-md items-center gap-3 rounded-md border p-3">      <MicIcon className="text-muted-foreground size-4 shrink-0" />      <Waveform        state={recording ? 'listening' : 'idle'}        bars={21}        label={recording ? 'Dictation in progress' : undefined}        className="flex-1"      />      <span className="text-label-emphasis text-muted-foreground tabular-nums">{mmss}</span>      <Button        variant="tertiary"        size="icon-sm"        aria-label={recording ? 'Stop dictation' : 'Resume dictation'}        onClick={() => setRecording(r => !r)}      >        {recording ? <SquareIcon /> : <MicIcon />}      </Button>    </div>  )}

A dictation bar on a report. Two things in that example are not decoration:

The waveform carries a label, because here it is the only thing on screen saying the microphone is live. And there is an elapsed time beside it, because a waveform says "sound is happening" and nothing whatever about how long for — someone who walked away needs the number, not the animation.

Accessibility

By default the waveform is aria-hidden. It is a picture of a sound, and a screen reader announcing it adds nothing when the state is already written next to it.

Pass label and it becomes role="img" with that name instead. Do that whenever the waveform is the only indication of a live microphone — which is a thing a person must be able to find out without seeing it.

Never let the animation be the sole signal that recording is happening. Under prefers-reduced-motion the bars hold still, and someone who set that preference would have no way to tell a live microphone from a decorative one. Keep a word, an icon or a timer alongside.

Colour

The peach is --color-aira (#ff7869), and it is deliberately not a design system token. Aira has its own identity colour; qtrack defines it in the app layer rather than the SDS for the same reason. It is named in theme-qure.css so every Aira surface agrees on it instead of each one carrying the hex.

Recolour by setting color — the bars are currentColor:

<Waveform className="text-primary" state="listening" />

API Reference

PropTypeDefaultDescription
state"idle" | "listening" | "speaking""idle"Still, or moving at one of two speeds.
size"sm" | "md""md"16px and 23px tall.
barsnumber28How many, when values is not given.
valuesnumber[]Amplitudes 0–1. Length wins over bars.
labelstringMakes it a role="img" with this name. Omitted, it is aria-hidden.

IDLE_PATTERN exports the seven fractions the drawing uses, if you want to seed an animation from the same shape.

On this page