Bar Visualizer
A voice-agent audio bar visualizer reacting to state (idle/connecting/listening/thinking/speaking). Synthetic demo mode by default; optional live microphone via Web Audio (graceful fallback). Token colours, reduced-motion safe.
Preview
Speaking
Thinking
<div class="grid w-full gap-6 sm:grid-cols-2">
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">{{ __('Speaking') }}</p>
<x-ui.bar-visualizer state="speaking" :bars="15" :demo="true" />
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">{{ __('Thinking') }}</p>
<x-ui.bar-visualizer state="thinking" :bars="15" :demo="true" :center="true" />
</div>
</div>
Installation
php artisan ui:add bar-visualizer
Note
This component ships an Alpine behavior module at
resources/js/ui/bar-visualizer.js. Import it once from your bundle so it registers on alpine:init:
import './bar-visualizer.js';
Registry contract
php artisan ui:add bar-visualizer
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/bar-visualizer.blade.php -
resources/js/ui/bar-visualizer.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
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: Bar Visualizer (`bar-visualizer`)
A voice-agent audio bar visualizer reacting to state (idle/connecting/listening/thinking/speaking). Synthetic demo mode by default; optional live microphone via Web Audio (graceful fallback). Token colours, reduced-motion safe.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add bar-visualizer
```
## Usage
```blade
<div class="grid w-full gap-6 sm:grid-cols-2">
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">{{ __('Speaking') }}</p>
<x-ui.bar-visualizer state="speaking" :bars="15" :demo="true" />
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">{{ __('Thinking') }}</p>
<x-ui.bar-visualizer state="thinking" :bars="15" :demo="true" :center="true" />
</div>
</div>
```
## Props
- `state` (string, default `speaking`)
- `bars` (int, default `15`)
- `demo` (bool, default `true`)
- `mic` (bool, default `false`)
- `minHeight` (int, default `12`)
- `maxHeight` (int, default `100`)
- `center` (bool, default `false`)
- `label` (mixed|null, default `null`)
## Use when
- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Showing that a voice agent is listening, thinking or speaking with a reacting bar animation.
- Decorating a call or dictation surface with an audio level readout.
## Avoid when
- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Charting data; the bars represent audio energy, not values. Use chart or sparkline for real data instead.
- Relying on it to reach the microphone in every browser: live mode falls back to the synthetic animation when Web Audio is unavailable.
## Anti-patterns
- Autoplay with sound
- Audio as the only access to essential information
## Rules
- Use the `<brok:bar-visualizer>` tag (or `<x-ui.bar-visualizer>`) 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/bar-visualizer
- Registry JSON (files, props, contract): https://brokui.dev/r/open/bar-visualizer.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 |
|---|---|---|---|
| state | string | speaking | |
| bars | int | 15 | |
| demo | bool | true | |
| mic | bool | false | |
| minHeight | int | 12 | |
| maxHeight | int | 100 | |
| center | bool | false | |
| label | mixed | null | null |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- state selects the idle, connecting, listening, thinking or speaking animation.
- Demo mode animates without permission; mic asks for the microphone and drives the bars from live input, falling back to demo if refused.
- bars, minHeight and maxHeight size the drawing; the animation stops 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.
- Showing that a voice agent is listening, thinking or speaking with a reacting bar animation.
- Decorating a call or dictation surface with an audio level readout.
Avoid when
- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Charting data; the bars represent audio energy, not values. Use chart or sparkline for real data instead.
- Relying on it to reach the microphone in every browser: live mode falls back to the synthetic animation when Web Audio is unavailable.
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 visualizer is a role="img" with an accessible name that states the current state.
- No information is carried by motion alone: the state is also given in the label.
- 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 a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="bar-visualizer-{{ $record->id }}">
<div class="grid w-full gap-6 sm:grid-cols-2">
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">{{ __('Speaking') }}</p>
<x-ui.bar-visualizer state="speaking" :bars="15" :demo="true" />
</div>
<div class="space-y-2">
<p class="text-xs font-medium text-muted-foreground">{{ __('Thinking') }}</p>
<x-ui.bar-visualizer state="thinking" :bars="15" :demo="true" :center="true" />
</div>
</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([
'state' => 'speaking',
'bars' => 15,
'demo' => true,
'mic' => false,
'minHeight' => 12,
'maxHeight' => 100,
'center' => false,
'label' => null,
])
@php
// A voice-agent audio visualiser: a row of bars whose heights react to an
// agent `state` (idle/connecting/listening/thinking/speaking). By default it
// runs in `demo` mode with synthetic audio so it animates anywhere; set
// `mic` to drive the bars from the live microphone (falls back to demo if
// permission is denied). Colour is token-only (resting bars `bg-border`,
// active bars `bg-primary`); bar heights are bound as inline numeric styles,
// never spliced into utility classes (which the JIT can't see). Reduced
// motion shows a calm static frame.
$states = ['idle', 'connecting', 'listening', 'thinking', 'speaking'];
$state = in_array($state, $states, true) ? $state : 'speaking';
$config = [
'state' => $state,
'bars' => max(3, min(64, (int) $bars)),
'demo' => (bool) $demo,
'mic' => (bool) $mic,
'minHeight' => max(2, min(100, (float) $minHeight)),
'maxHeight' => max(10, min(100, (float) $maxHeight)),
'center' => (bool) $center,
];
$ariaLabel = $label ?? __('Audio visualizer');
@endphp
<div
data-slot="bar-visualizer"
x-data="uiBarVisualizer(@js($config))"
:data-state="state"
role="img"
aria-label="{{ $ariaLabel }}"
{{ $attributes->merge(['class' => 'bg-muted relative flex h-32 w-full justify-center gap-2 overflow-hidden rounded-lg p-6']) }}
:class="center ? 'items-center' : 'items-end'"
>
<template x-for="(h, i) in heights" :key="i">
<div
data-slot="bar-visualizer-bar"
:data-on="isOn(i)"
class="w-full max-w-[12px] min-w-[6px] flex-1 rounded-full transition-[height,background-color] duration-150 ease-out motion-reduce:transition-none"
:class="isOn(i) ? 'bg-primary' : 'bg-border'"
:style="`height: ${h}%`"
></div>
</template>
</div>
/**
* Bar Visualizer behaviour.
*
* Renders `bars` columns whose heights (percent) react to an agent `state`:
* • speaking / listening — a flowing synthetic (or live-mic) envelope;
* • connecting — a pair of highlights sweeping outward;
* • thinking — a pulsing centre highlight;
* • idle — calm, near-flat baseline.
* `isOn(i)` decides whether a bar paints with the primary token (active) or the
* border token (resting); the Blade binds that, plus the numeric height, inline.
*
* Audio source: `demo` (default) animates synthetic data so it works anywhere.
* `mic` opts into the real microphone via Web Audio; if getUserMedia rejects we
* quietly fall back to the synthetic envelope.
*
* Performance & teardown: a single rAF drives everything; reduced-motion paints
* one static frame and never starts the loop. destroy() cancels the frame, stops
* any mic tracks and closes the AudioContext so nothing leaks on unmount.
*
* Self-registers on `alpine:init`. Plain JS — no Blade syntax.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiBarVisualizer', (config = {}) => ({
state: config.state || 'speaking',
count: Math.max(3, Math.min(64, config.bars ?? 15)),
demo: config.demo !== false,
mic: !!config.mic,
minHeight: config.minHeight ?? 12,
maxHeight: config.maxHeight ?? 100,
center: !!config.center,
heights: [],
_highlights: [],
_seqIndex: 0,
_lastSeq: 0,
_start: null,
_raf: null,
_reduced: false,
_viewObserver: null,
_offscreen: false,
// Web Audio (mic mode)
_audioCtx: null,
_analyser: null,
_stream: null,
_freq: null,
init() {
this.heights = new Array(this.count).fill(this.minHeight);
this._reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (this._reduced) {
this.drawStatic();
return;
}
if (this.mic) {
this.setupMic();
}
this._raf = requestAnimationFrame((t) => this.loop(t));
// Pause the rAF loop while fully scrolled out of view (same-tab;
// backgrounded tabs are already throttled by the browser) so
// off-screen instances don't burn GPU/CPU. Resume on re-entry.
// Static/reduced-motion renders never set _raf, so the `&& this._raf`
// guard leaves them untouched.
if (typeof IntersectionObserver !== 'undefined') {
this._viewObserver = new IntersectionObserver((entries) => {
const visible = entries.some((e) => e.isIntersecting);
if (!visible && this._raf) {
cancelAnimationFrame(this._raf);
this._raf = null;
this._offscreen = true;
} else if (visible && this._offscreen) {
this._offscreen = false;
this._raf = requestAnimationFrame((t) => this.loop(t));
}
});
this._viewObserver.observe(this.$root);
}
},
// ms between highlight-sequence advances, by state.
get seqInterval() {
if (this.state === 'connecting') return 2000 / this.count;
if (this.state === 'thinking') return 150;
if (this.state === 'listening') return 500;
return 1000;
},
isOn(i) {
if (this.state === 'speaking') return true;
return this._highlights.includes(i);
},
// The set of highlighted bar indices for the current sequence frame.
computeHighlights() {
const c = this.count;
const center = Math.floor(c / 2);
if (this.state === 'connecting') {
const k = this._seqIndex % c;
return [k, c - 1 - k];
}
if (this.state === 'thinking' || this.state === 'listening') {
return this._seqIndex % 2 === 0 ? [center] : [];
}
return [];
},
// Synthetic envelope (0..1) for a given bar at a given time.
synthetic(i, time) {
if (this.state !== 'speaking' && this.state !== 'listening') {
return 0.06; // calm baseline for idle/connecting/thinking
}
const offset = i * 0.5;
const base = Math.sin(time * 2 + offset) * 0.3 + 0.5;
// Deterministic ripple (no Math.random) keeps the motion smooth.
const ripple = Math.sin(time * 7.3 + i * 1.7) * 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;
// Advance highlight sequence on its interval.
if (ts - this._lastSeq >= this.seqInterval) {
this._seqIndex += 1;
this._lastSeq = ts;
}
this._highlights = this.computeHighlights();
const usingMic = this._analyser && this._freq;
if (usingMic) this._analyser.getByteFrequencyData(this._freq);
const span = this.maxHeight - this.minHeight;
const next = new Array(this.count);
for (let i = 0; i < this.count; i++) {
let v;
if (usingMic) {
// Sample across the spectrum, weighting low-mid bands.
const idx = Math.floor((i / this.count) * (this._freq.length * 0.6));
v = (this._freq[idx] || 0) / 255;
} else {
v = this.synthetic(i, time);
}
next[i] = this.minHeight + v * span;
}
this.heights = next;
this._raf = requestAnimationFrame((t) => this.loop(t));
},
drawStatic() {
// A gentle symmetric arch so the resting state still reads as "audio".
const c = this.count;
const center = (c - 1) / 2;
const span = this.maxHeight - this.minHeight;
this.heights = Array.from({ length: c }, (_, i) => {
const d = 1 - Math.abs(i - center) / (center || 1);
return this.minHeight + d * span * 0.45;
});
this._highlights = this.state === 'speaking' ? [] : [Math.floor(c / 2)];
},
async setupMic() {
if (!navigator.mediaDevices?.getUserMedia) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
});
this._stream = stream;
const Ctx = window.AudioContext || window.webkitAudioContext;
this._audioCtx = new Ctx();
const source = this._audioCtx.createMediaStreamSource(stream);
this._analyser = this._audioCtx.createAnalyser();
this._analyser.fftSize = 64;
this._analyser.smoothingTimeConstant = 0.8;
source.connect(this._analyser);
this._freq = new Uint8Array(this._analyser.frequencyBinCount);
} catch {
// Permission denied / no device — stay on the synthetic envelope.
this._analyser = null;
this._freq = null;
}
},
destroy() {
if (this._viewObserver) { this._viewObserver.disconnect(); this._viewObserver = null; }
if (this._raf) cancelAnimationFrame(this._raf);
this._raf = null;
if (this._stream) {
this._stream.getTracks().forEach((t) => t.stop());
this._stream = null;
}
if (this._audioCtx && this._audioCtx.state !== 'closed') {
this._audioCtx.close();
}
this._audioCtx = null;
this._analyser = null;
this._freq = null;
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.0.4 - 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