Skip to content
kitn AI/UI

Audio Visualizer

kai-audio-visualizer

Render live audio (a microphone stream, an <audio>/<video> element, or precomputed levels) as bars, a grid, a ring, a flowing wave, or a glowing aurora. One element, six variants, chosen by variant.

  • Shadow DOM
  • 6 variants
  • Live audio or scripted state
  • Custom GLSL shaders
  • 3 parts

variant, state, size, and the rest of the shape props (bar-count, count, radius, spread, interval, color, complexity, label) are plain scalars: set them as HTML attributes. Live audio is not a scalar. Assign stream, audioElement, and bands in JavaScript, never as attributes. shader (for variant="custom") works the same way.

<kai-audio-visualizer variant="grid" state="listening" size="lg"></kai-audio-visualizer>
const viz = document.querySelector('kai-audio-visualizer');
viz.stream = mediaStream; // a live MediaStream
viz.audioElement = audioEl; // HTMLAudioElement or HTMLVideoElement
viz.bands = [0.2, 0.8, 0.4, 0.5]; // number[], 0..1, skips Web Audio entirely

Only one source wins. Setting bands short-circuits Web Audio entirely, so stream and audioElement are ignored even if you also set them. Streaming levels into bands needs a fresh array reference on every update; mutating the existing array in place will not re-render.

state alone drives a full scripted animation with no audio source at all. idle, connecting, listening, thinking, speaking, and disconnected (connection down: the dead, flat look, matching LiveKit’s disconnected) each get their own timing and pattern per variant (LiveKit’s room-lifecycle names are accepted as aliases). This is what backs <kai-voice-output>: the browser’s speechSynthesis exposes no audio node to analyze, so there is nothing to tap. Set state="speaking" while it talks and skip the audio props entirely.

variant="bar" (default). A compact row of levels, good for a mic button or a slim status strip.

variant="grid". A dot matrix that pulses outward, sized for a call tile.

variant="radial". A ring of spokes around a center point, for wrapping an avatar.

variant="wave". A flowing oscilloscope line, rendered through WebGL.

variant="aurora". A glowing ambient wash, good for a hero-sized idle state.

<kai-voice-input> hands you a transcript, not a stream. It never exposes the raw MediaStream it records with. Request your own with getUserMedia, gated by the mic’s kai-recording-change event so the visualizer’s state and its stream change together:

<kai-voice-input id="mic"></kai-voice-input>
<kai-audio-visualizer id="viz" variant="bar" size="icon"></kai-audio-visualizer>
<script type="module">
import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-voice-input');
const mic = document.getElementById('mic');
const viz = document.getElementById('viz');
let micStream;
mic.addEventListener('kai-recording-change', async (e) => {
viz.state = e.detail.recording ? 'speaking' : 'idle';
if (e.detail.recording) {
// Request processed capture, not bare `audio: true`. The default
// analysis window expects gain-controlled speech (it reads the
// 2-4kHz band and gates the room's noise floor out); these are the
// same capture constraints LiveKit's own client applies to local
// mic tracks.
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
voiceIsolation: true,
},
});
viz.stream = micStream;
} else {
viz.stream = undefined;
micStream?.getTracks().forEach((track) => track.stop());
}
});
</script>

Restyle the bar, cell, or canvas part from outside. A lit bar or cell also carries the highlighted part, as a second token in the same part attribute, so target it by combining both names in one ::part():

/* wrong: a CSS attribute selector cannot follow a pseudo-element, so this
never matches anything -- CSS.supports(selector(...)) returns false for it */
kai-audio-visualizer::part(bar)[data-kai-highlighted="true"] { background: var(--brand); }
/* right */
kai-audio-visualizer::part(bar highlighted) { background: var(--brand); }
kai-audio-visualizer::part(cell highlighted) { background: var(--brand); }

Bar and cell items also carry data-kai-index and data-kai-highlighted ("true"/"false") for reading from inside the shadow root, or from a render-prop when composing the Solid component directly, but a ::part() selector from outside cannot see them.

PartPurposeExample
A single bar in the `bar` variant, or a single spoke in the `radial` variant. Also carries `data-kai-index` and `data-kai-highlighted` ("true"/"false") for use inside the shadow root; to style the lit state from OUTSIDE, combine with the `highlighted` part below rather than an attribute selector.
kai-audio-visualizer::part(bar) { border-radius: 2px }
kai-audio-visualizer::part(bar highlighted) { background: var(--brand) }
A single dot in the `grid` variant. Also carries `data-kai-index` and `data-kai-highlighted` ("true"/"false") for use inside the shadow root; to style the lit state from OUTSIDE, combine with the `highlighted` part below rather than an attribute selector.
kai-audio-visualizer::part(cell) { border-radius: 9999px }
kai-audio-visualizer::part(cell highlighted) { background: var(--brand) }
A second part TOKEN present on a `bar` or `cell` exactly when the sequencer or live audio has it lit, not a standalone styleable element. Combine it in the same `::part()` argument: `::part(bar highlighted)` or `::part(cell highlighted)`. This is the external equivalent of the internal `data-kai-highlighted="true"` attribute, which a `::part()` selector cannot reach (an attribute selector cannot follow a pseudo-element).
kai-audio-visualizer::part(bar highlighted) { background: var(--brand) }
kai-audio-visualizer::part(cell highlighted) { background: var(--brand) }
The WebGL canvas backing the `wave` and `aurora` variants. Restyle its size or radius, or layer a mask/filter, from outside.
kai-audio-visualizer::part(canvas) { border-radius: 0.75rem }

Set variant="custom" and a shader property to render your own fragment shader instead of the built-in geometry:

<kai-audio-visualizer id="viz" variant="custom"></kai-audio-visualizer>
<script type="module">
import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-audio-visualizer');
document.getElementById('viz').shader = {
fragment: `
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = fragCoord / iResolution.xy;
float alpha = smoothstep(1.0, 0.0, length(uv - 0.5) * 2.0) * uIntensity;
fragColor = vec4(uColor * alpha, alpha); // premultiplied, not vec4(uColor, alpha)
}
`,
};
</script>

Your fragment must define mainImage(out vec4 fragColor, in vec2 fragCoord). The canvas declares every uniform below for you and injects them ahead of your source. Redeclaring any of them (uniform float iTime;, uniform vec3 uColor;, and so on) inside fragment is a GLSL redefinition and fails to compile.

UniformGLSL typeWhat it carries
iTimefloatSeconds since the shader mounted.
iResolutionvec2Canvas size in device pixels.
iMousevec4Pointer position in .xy. .zw stays zero; only .xy is implemented.
iFrameintFrame counter, incrementing every draw.
iDatevec4Year, month, day, and seconds into the day.
uColorvec3The color attribute (or the shader default), as 0..1 RGB.
uIntensityfloatAn eased 0..1 value that follows state.
uSpeedfloatAn eased animation speed that follows state.
uComplexityfloatThe complexity attribute, 0..1 (default 0.5).
uVolumefloatA single scalar volume: from live analysis, or the RMS of bands when you supply it directly.
uBands[N]float[N]Per-band levels, N tracking the actual band count (never zero-length).

uBands is ours. LiveKit’s upstream shader path only ever hands a shader a scalar volume, so a spectrum-reactive custom shader is only possible here.

PropertyTypeDefaultNotes
theme'auto'Color mode (`auto` follows prefers-color-scheme).
variant'bar'Look to render: `bar` (default), `grid`, `radial`, `wave`, `aurora`, `custom`. `aura` is accepted as a LiveKit-markup alias for `aurora`. Attribute: `variant`.
state'idle'`idle` (default), `connecting`, `listening`, `thinking`, `speaking`, `disconnected` (connection down: the dead, flat look). LiveKit's room-lifecycle state names are accepted as aliases. Attribute: `state`.
size'md'`icon` | `sm` | `md` (default) | `lg` | `xl`. Attribute: `size`.
barCountBars to draw. Bar and radial only. Attribute: `bar-count`.
countGrid only: rows and columns of the (always square) grid. Attribute: `count`.
radiusRadial only: ring distance from center, in px. Attribute: `radius`.
spreadGrid only: ring distance for the connecting animation, in cells. Attribute: `spread`.
intervalGrid only: ms between scripted frames. Attribute: `interval`.
colorCSS color for the geometry, overriding the inherited `currentColor`. Attribute: `color`.
complexityShader variants only: pattern density, 0..1. Attribute: `complexity`.
labelSetting this makes the element an announced image (`role="img"`) instead of decorative (`aria-hidden`). Attribute: `label`.
streamLive microphone or WebRTC audio to analyze. JS property only.
audioElementAn `<audio>` or `<video>` element to tap for its audio. JS property only.
bandsPre-computed levels, 0..1. Set this and no AudioContext is ever built, which is what keeps headless/SSR rendering and browser-speech-synthesis playback (which exposes no audio node) free of Web Audio entirely. JS property only. A new array reference is required for each update; mutating the existing array in place will not re-render.
shaderCustom fragment shader for `variant="custom"`. JS property only.
animateWhenNotVisibleShader variants only: keep animating while scrolled off screen. Off by default, which stops drawing and releases the WebGL context until the element comes back (browsers ration contexts to roughly 16 a page). Does not override `prefers-reduced-motion`. Attribute: `animate-when-not-visible`.
  • wave, aurora, and custom load behind a dynamic import and fall back to bar if the chunk fails or WebGL is not available. A kai-chat-only page never pays for the shader runtime.

  • Scrolled off screen, a shader variant stops drawing and hands its WebGL context back to the browser, taking it again on the way in. Browsers only allow around 16 live contexts per page and silently kill the oldest past that, so without this a page with a dozen shader tiles ends up with dead canvases. The animation resumes where you left it rather than jumping forward.

  • animate-when-not-visible opts a single element out of that, for a visualizer that has to keep running unseen. It holds its context for as long as it is mounted, so use it on one or two elements, not on a page full of them.

    <kai-audio-visualizer variant="wave" animate-when-not-visible></kai-audio-visualizer>
  • prefers-reduced-motion freezes the scripted animation on all six variants (bar, grid, radial, wave, aurora, and custom): no easing, no pulsing, no drift. It wins over animate-when-not-visible, which only decides whether frames keep being drawn, never what they show.

  • aura is accepted as an alias for aurora, for markup ported from LiveKit.

This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.

AudioVisualizer