Skip to content
Brok UI

Loading…

No results

Bar Visualizer

Open source

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.

Version
v1.0.4
Stability
stable
License
MIT
Related
Audio Player
Chart
Sparkline

Preview

Speaking

Thinking

previews.components.bar-visualizer.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/bar-visualizer.blade.php
  • js resources/js/ui/bar-visualizer.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

bar-visualizer.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

bar-visualizer bar-visualizer-bar

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

Audio and voice interaction

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
root bar-visualizer-bar
Theming hooks
bar-visualizer bar-visualizer-bar

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/bar-visualizer.blade.php Blade
@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>
resources/js/ui/bar-visualizer.js JS
/**
 * 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