Voice Dictator
A voice-dictation widget: a circular mic toggle centred inside a halftone dot-cloud radial audio visualiser, with an aria-live transcript region below. Clicking the mic toggles listening; in demo mode (default, no permission needed) the dot-cloud pulses outward and a faux transcript types in word-by-word. With the live prop it progressively enhances to real SpeechRecognition when available. Token-derived colours, reduced-motion static ring.
Preview
<div class="bg-card flex min-h-72 w-full items-center justify-center rounded-xl border p-8 dark">
<x-ui.voice-dictator class="max-w-sm" />
</div>
Installation
php artisan ui:add voice-dictator
Note
This component ships an Alpine behavior module at
resources/js/ui/voice-dictator.js. Import it once from your bundle so it registers on alpine:init:
import './voice-dictator.js';
Registry contract
php artisan ui:add voice-dictator
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/voice-dictator.blade.php -
resources/js/ui/voice-dictator.js
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Voice Dictator (`voice-dictator`)
A voice-dictation widget: a circular mic toggle centred inside a halftone dot-cloud radial audio visualiser, with an aria-live transcript region below. Clicking the mic toggles listening; in demo mode (default, no permission needed) the dot-cloud pulses outward and a faux transcript types in word-by-word. With the live prop it progressively enhances to real SpeechRecognition when available. Token-derived colours, reduced-motion static ring.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add voice-dictator
```
## Usage
```blade
<div class="bg-card flex min-h-72 w-full items-center justify-center rounded-xl border p-8 dark">
<x-ui.voice-dictator class="max-w-sm" />
</div>
```
## Props
- `placeholder` (mixed|null, default `null`)
- `transcript` (mixed|null, default `null`)
- `live` (bool, default `false`)
## Use when
- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Letting someone dictate text with a single mic toggle and a visible transcript.
- Demonstrating a voice input without asking for microphone permission.
## Avoid when
- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Request microphone access only after explicit user intent and treat permission denial as a normal recoverable state.
- Production dictation on every browser: live mode depends on SpeechRecognition and degrades to demo where it is missing.
- Recording audio to store or play back; pair with audio-player for the resulting file, since this produces a transcript, not a recording.
## Anti-patterns
- Autoplay with sound
- Audio as the only access to essential information
## Rules
- Use the `<brok:voice-dictator>` tag (or `<x-ui.voice-dictator>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/voice-dictator
- Registry JSON (files, props, contract): https://brokui.dev/r/open/voice-dictator.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| placeholder | mixed | null | null | |
| transcript | mixed | null | null | |
| live | bool | false |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Clicking the mic toggles listening; the dot-cloud pulses while listening and settles when stopped.
- In demo mode a faux transcript types in word by word; with live set, real SpeechRecognition fills the transcript where available.
- The ring is static under prefers-reduced-motion.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.
Guidance
Play, inspect, record, or dictate temporal audio.
Use when
- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Letting someone dictate text with a single mic toggle and a visible transcript.
- Demonstrating a voice input without asking for microphone permission.
Avoid when
- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Request microphone access only after explicit user intent and treat permission denial as a normal recoverable state.
- Production dictation on every browser: live mode depends on SpeechRecognition and degrades to demo where it is missing.
- Recording audio to store or play back; pair with audio-player for the resulting file, since this produces a transcript, not a recording.
Use instead
- Silent scannable controls
- Transcript or text input
Anti-patterns
- Autoplay with sound
- Audio as the only access to essential information
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The mic is a button whose aria-pressed and label change with the listening state.
- The transcript is a polite live region, so dictated text is announced as it arrives.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<div class="bg-card flex min-h-72 w-full items-center justify-center rounded-xl border p-8 dark">
<x-ui.voice-dictator class="max-w-sm" />
</div>
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
// Helper text shown in the transcript region before any speech is captured.
'placeholder' => null,
// The faux transcript spoken in demo mode. Pass an array of words/tokens or a
// string (split on whitespace). A sensible default is supplied.
'transcript' => null,
// Progressive enhancement: when true AND the browser exposes SpeechRecognition,
// listening drives the transcript from the real microphone. Falls back to the
// synthetic demo otherwise. DEFAULT false → always the synthetic demo so it
// animates anywhere with no permission prompt.
'live' => false,
])
@php
// A voice-dictation widget: a circular mic toggle centred inside a halftone
// dot-cloud radial visualiser, with an aria-live transcript region below.
// Clicking the mic toggles listening; in demo mode (default) the dot-cloud
// pulses outward and a faux transcript types in word-by-word. Colour is
// token-only — the canvas resolves --primary/--accent/--foreground at runtime
// (see voice-dictator.js), never hard-coded. Reduced motion paints a calm
// static dot ring and reveals the transcript instantly instead of typing.
$defaultTranscript = [
__('Generate'), __('a'), __('warm,'), __('cinematic'), __('portrait'),
__('of'), __('a'), __('lighthouse'), __('keeper'), __('at'), __('dawn,'),
__('soft'), __('volumetric'), __('light'), __('and'), __('gentle'), __('mist.'),
];
if (is_string($transcript)) {
$words = array_values(array_filter(preg_split('/\s+/', trim($transcript)) ?: [], fn ($w) => $w !== ''));
} elseif (is_array($transcript)) {
$words = array_values(array_filter(array_map('strval', $transcript), fn ($w) => trim($w) !== ''));
} else {
$words = [];
}
if (count($words) === 0) {
$words = $defaultTranscript;
}
$placeholderText = $placeholder ?? __('Generate expressive prompts by speaking naturally.');
$config = [
'words' => array_values($words),
'live' => (bool) $live,
'placeholder' => $placeholderText,
'startLabel' => __('Start dictation'),
'stopLabel' => __('Stop dictation'),
];
@endphp
<div
data-slot="voice-dictator"
x-data="uiVoiceDictator(@js($config))"
:data-listening="listening"
{{ $attributes->merge(['class' => 'flex w-full flex-col items-center gap-6']) }}
>
{{-- Radial dot-cloud visualiser. The canvas is decorative (aria-hidden); the
mic button sits centred above it and remains the only focusable control. --}}
<div class="relative grid aspect-square w-full max-w-[18rem] place-items-center">
<canvas
x-ref="canvas"
aria-hidden="true"
class="pointer-events-none absolute inset-0 block size-full text-primary"
></canvas>
<x-ui.button
type="button"
@click="toggle()"
::aria-pressed="listening ? 'true' : 'false'"
::aria-label="listening ? micStopLabel : micStartLabel"
data-slot="voice-dictator-mic"
class="relative size-20 rounded-full p-0 shadow-lg transition-transform duration-200 ease-out hover:scale-105 motion-reduce:transition-none [&_svg]:size-7"
::class="listening ? 'motion-safe:animate-pulse motion-reduce:animate-none' : ''"
>
{{-- Microphone glyph (idle) / stop glyph (listening). Decorative; the
accessible name is carried by the bound aria-label above. --}}
<svg
x-show="!listening"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
>
<path d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v1a7 7 0 0 1-14 0v-1" />
<path d="M12 18v4M8 22h8" />
</svg>
<svg
x-show="listening" x-cloak
viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
>
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
</x-ui.button>
</div>
{{-- Transcript region: live so assistive tech announces words as they arrive.
Shows helper text until listening produces output, then the running
transcript with a soft caret while active. --}}
<div
data-slot="voice-dictator-transcript"
role="status"
aria-live="polite"
class="min-h-[3.5rem] w-full max-w-prose text-center text-base leading-relaxed text-balance"
:class="text ? 'text-foreground' : 'text-muted-foreground'"
>
<span x-show="!text" x-text="placeholder"></span>
<span x-show="text" x-cloak><span x-text="text"></span><span
x-show="listening"
aria-hidden="true"
class="ms-1 inline-block h-[1.1em] w-0.5 motion-safe:animate-pulse rounded-full bg-primary align-text-bottom motion-reduce:animate-none"
></span></span>
</div>
</div>
/**
* Voice Dictator behaviour (radial dot-cloud visualiser + faux transcript).
*
* A voice-dictation widget. Clicking the mic toggles `listening`. While
* listening:
* • the canvas paints a halftone DISC of small dots arranged on concentric
* rings; each dot's radius and opacity pulse outward from the centre on a
* synthetic audio envelope, so the cloud "breathes" like a live mic level;
* • a faux transcript is typed into the transcript region word-by-word on a
* timer. Clicking again stops, freezes the dots to a calm resting ring, and
* leaves whatever was transcribed in place.
*
* Source: `demo` is the DEFAULT (synthetic) — it animates anywhere with no mic
* permission. When `live` is set AND the browser exposes SpeechRecognition, the
* transcript is driven from the real microphone instead (with the same synthetic
* dot-cloud for the visual); any failure quietly falls back to the demo words.
*
* Colour is SEMANTIC: dots are drawn in the root's tokens, resolved at runtime —
* `--primary` (active rings) blended toward `--foreground` (outer rings), with
* `--accent` as a mid stop — so the cloud follows the active theme rather than
* any hard-coded value. Bare HSL channel tokens are wrapped in `hsl(...)`.
*
* Sizing: the canvas backing store is scaled for devicePixelRatio and a
* ResizeObserver rebuilds the dot layout when the box changes, keeping dots crisp.
*
* Reduced motion: a single calm static dot ring is painted with no rAF loop, and
* the transcript appears instantly (no word-by-word typing).
*
* Teardown: destroy() cancels the rAF, clears the typing timer, disconnects the
* ResizeObserver, stops any SpeechRecognition, and stops any MediaStream tracks,
* so nothing leaks when the component unmounts mid-listen.
*
* Self-registers on `alpine:init`. Plain JS — no Blade syntax allowed.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiVoiceDictator', (config = {}) => ({
words: Array.isArray(config.words) && config.words.length > 0
? config.words.slice()
: ['Generate', 'expressive', 'prompts', 'by', 'speaking', 'naturally.'],
live: !!config.live,
// Localized copy is seeded from Blade (wrapped in __()) so JS stays free
// of user-facing strings.
placeholder: config.placeholder || '',
micStartLabel: config.startLabel || 'Start dictation',
micStopLabel: config.stopLabel || 'Stop dictation',
// UI state read by the Blade.
listening: false,
text: '',
_ctx: null,
_dpr: 1,
_dots: [],
_rings: 0,
_cx: 0,
_cy: 0,
_maxR: 0,
_stops: [[0, 0, 0]],
_raf: null,
_ro: null,
_start: null,
_level: 0,
_reduced: false,
// Faux-transcript typing.
_typeTimer: null,
_wordIndex: 0,
// Live SpeechRecognition (opt-in).
_recognition: null,
_stream: null,
init() {
const canvas = this.$refs.canvas;
this._ctx = canvas.getContext('2d');
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
this.build();
this.drawStatic();
let pending = false;
this._ro = new ResizeObserver(() => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
this.build();
if (!this.listening || this._reduced) this.drawStatic();
});
});
this._ro.observe(canvas);
},
// ---- token colour resolution (mirrors pixel-canvas) -------------------
resolveToken(name, root) {
const cs = getComputedStyle(root);
const raw = cs.getPropertyValue(name).trim();
if (raw === '') return null;
const probe = document.createElement('span');
probe.style.color = raw;
// Tokens here are stored as bare HSL channels; wrap before other formats.
if (probe.style.color === '') {
probe.style.color = `hsl(${raw})`;
if (probe.style.color === '') probe.style.color = `oklch(${raw})`;
if (probe.style.color === '') probe.style.color = `rgb(${raw})`;
}
if (probe.style.color === '') return null;
document.body.appendChild(probe);
const resolved = getComputedStyle(probe).color;
document.body.removeChild(probe);
return resolved && resolved !== '' ? resolved : null;
},
toRgb(color) {
const c = this._ctx;
c.save();
c.fillStyle = 'CanvasText';
c.fillStyle = color;
const computed = c.fillStyle;
c.restore();
if (computed.startsWith('#')) {
const hex = computed.slice(1);
const full = hex.length === 3 ? hex.split('').map((h) => h + h).join('') : hex;
return [
parseInt(full.slice(0, 2), 16),
parseInt(full.slice(2, 4), 16),
parseInt(full.slice(4, 6), 16),
];
}
const m = computed.match(/(\d+(?:\.\d+)?)/g);
return m ? [Number(m[0]), Number(m[1]), Number(m[2])] : [0, 0, 0];
},
resolveStops() {
const root = this.$refs.canvas;
const current = getComputedStyle(root).color || 'currentColor';
const primary = this.resolveToken('--primary', root) || current;
const accent = this.resolveToken('--accent', root) || primary;
const foreground = this.resolveToken('--foreground', root) || current;
this._stops = [this.toRgb(primary), this.toRgb(accent), this.toRgb(foreground)];
},
colorAt(t) {
const stops = this._stops;
const clamped = t < 0 ? 0 : t > 1 ? 1 : t;
const scaled = clamped * (stops.length - 1);
const i = Math.min(stops.length - 2, Math.floor(scaled));
const f = scaled - i;
const a = stops[i];
const b = stops[i + 1];
return [
Math.round(a[0] + (b[0] - a[0]) * f),
Math.round(a[1] + (b[1] - a[1]) * f),
Math.round(a[2] + (b[2] - a[2]) * f),
];
},
// ---- dot-cloud layout & rendering -------------------------------------
// Lay out a disc of dots on concentric rings (denser rings outward), sized
// to the box and scaled for DPR.
build() {
const canvas = this.$refs.canvas;
this._dpr = Math.max(1, window.devicePixelRatio || 1);
const cssW = Math.max(1, canvas.clientWidth);
const cssH = Math.max(1, canvas.clientHeight);
canvas.width = Math.round(cssW * this._dpr);
canvas.height = Math.round(cssH * this._dpr);
this._ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0);
this._cx = cssW / 2;
this._cy = cssH / 2;
// Leave room for the centred mic button: dots start beyond an inner gap.
this._maxR = Math.min(cssW, cssH) / 2 - 4;
this.resolveStops();
const dots = [];
const rings = 7;
this._rings = rings;
const innerR = this._maxR * 0.42; // clear zone around the mic button
for (let ring = 0; ring < rings; ring++) {
const rt = ring / (rings - 1);
const radius = innerR + (this._maxR - innerR) * rt;
// More dots on outer rings keeps an even halftone density.
const dotCount = Math.max(6, Math.round(8 + ring * 6));
const phase = ring * 0.5;
for (let k = 0; k < dotCount; k++) {
const angle = (k / dotCount) * Math.PI * 2 + phase;
dots.push({
x: this._cx + Math.cos(angle) * radius,
y: this._cy + Math.sin(angle) * radius,
rt, // 0 (inner) .. 1 (outer): drives colour stop + delay
});
}
}
this._dots = dots;
},
// Synthetic mic-level envelope (0..1) — a smooth, breathing pulse with a
// little deterministic ripple so it reads as live audio, no Math.random.
synthLevel(time) {
const base = Math.sin(time * 2.4) * 0.35 + 0.5;
const ripple = Math.sin(time * 6.1 + 1.3) * 0.12;
return Math.max(0.05, Math.min(1, base + ripple));
},
loop(ts) {
if (this._start == null) this._start = ts;
const time = (ts - this._start) / 1000;
this._level = this.synthLevel(time);
this.draw(time);
this._raf = requestAnimationFrame((t) => this.loop(t));
},
// Render the dot cloud. `time` (sec) drives an outward-travelling pulse;
// `level` (0..1) scales how strongly dots swell. When idle (level≈0) it
// settles to the calm resting ring.
draw(time) {
const ctx = this._ctx;
if (!ctx) return;
const w = this.$refs.canvas.width / this._dpr;
const h = this.$refs.canvas.height / this._dpr;
ctx.clearRect(0, 0, w, h);
const level = this.listening ? this._level : 0;
for (const d of this._dots) {
// A wave travelling outward from the centre: each ring peaks a beat
// after the one inside it, so the pulse visibly radiates.
const wave = Math.sin(time * 4 - d.rt * 4) * 0.5 + 0.5;
const excite = level * wave;
// Dot size: a calm base radius that swells with excitement.
const radius = 1.4 + d.rt * 1.1 + excite * 3.2;
const alpha = 0.18 + d.rt * 0.12 + excite * 0.7;
const [r, g, b] = this.colorAt(d.rt * 0.5 + excite * 0.5);
ctx.globalAlpha = Math.min(1, alpha);
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.beginPath();
ctx.arc(d.x, d.y, radius, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
},
// Calm resting frame (idle / reduced-motion): no pulse.
drawStatic() {
this.draw(0);
},
// ---- toggle + transcript ---------------------------------------------
toggle() {
if (this.listening) {
this.stop();
} else {
this.start();
}
},
start() {
this.listening = true;
this.text = '';
this._wordIndex = 0;
if (!this._reduced) {
this._start = null;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
// Live mode: try real recognition; on any failure, run the demo.
if (this.live && this.startLive()) return;
this.runDemoTranscript();
},
stop() {
this.listening = false;
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
if (this._typeTimer) { clearTimeout(this._typeTimer); this._typeTimer = null; }
this.stopLive();
this.drawStatic();
},
// Type the faux transcript word-by-word. Reduced motion drops the whole
// line in at once instead of animating it.
runDemoTranscript() {
if (this._reduced) {
this.text = this.words.join(' ');
return;
}
const tick = () => {
if (!this.listening || this._wordIndex >= this.words.length) {
this._typeTimer = null;
return;
}
this.text = this.text
? `${this.text} ${this.words[this._wordIndex]}`
: this.words[this._wordIndex];
this._wordIndex += 1;
this._typeTimer = setTimeout(tick, 220);
};
tick();
},
// Progressive enhancement: drive `text` from real SpeechRecognition.
// Returns true if recognition was started; false to fall back to demo.
startLive() {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR || !navigator.mediaDevices?.getUserMedia) return false;
try {
const rec = new SR();
rec.continuous = true;
rec.interimResults = true;
rec.onresult = (event) => {
let out = '';
for (let i = 0; i < event.results.length; i++) {
out += event.results[i][0].transcript;
}
this.text = out.trim();
};
rec.onerror = () => { this.fallbackToDemo(); };
rec.start();
this._recognition = rec;
return true;
} catch {
return false;
}
},
fallbackToDemo() {
this.stopLive();
if (this.listening) this.runDemoTranscript();
},
stopLive() {
if (this._recognition) {
try { this._recognition.stop(); } catch { /* already stopped */ }
this._recognition = null;
}
if (this._stream) {
this._stream.getTracks().forEach((t) => t.stop());
this._stream = null;
}
},
destroy() {
if (this._raf) { cancelAnimationFrame(this._raf); this._raf = null; }
if (this._typeTimer) { clearTimeout(this._typeTimer); this._typeTimer = null; }
if (this._ro) { this._ro.disconnect(); this._ro = null; }
this.stopLive();
this._dots = [];
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0