Voice Orb
Audio-reactive orb for voice mode whose blurred --chart and --primary color blobs orbit inside a glossy sphere and swell with a 0..1 level prop, easing between idle breathing, listening ripples, a circling thinking arc and churning speaking motion, with a simulate mode that synthesizes speech-like levels and a gentle opacity pulse under reduced motion; pairs with VoiceInput for capture.
Preview
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// karaui.kamisama-v0.my.id/components/agents/voice-orb
import {
AnimatePresence,
type MotionValue,
motion,
useAnimationFrame,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { type CSSProperties, useEffect, useRef } from "react";
import { EASE_IN_OUT, EASE_OUT, SPRING_GLIDE } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type VoiceOrbState = "idle" | "listening" | "thinking" | "speaking";
export interface VoiceOrbProps {
state?: VoiceOrbState;
/** Audio amplitude 0..1 (mic input while listening, output while speaking). */
level?: number;
/** Synthesize a plausible `level` for the current state (no audio needed). */
simulate?: boolean;
/** Diameter in px. Default 160. */
size?: number;
/** Accessible name; the current state is appended. Default "Voice assistant". */
label?: string;
className?: string;
}
const STATE_TEXT: Record<VoiceOrbState, string> = {
idle: "idle",
listening: "listening",
thinking: "thinking",
speaking: "speaking",
};
// Per-state motion character: how fast the blobs orbit, how far they drift
// from center and how much the audio level swells the core.
const STATE_MOTION: Record<VoiceOrbState, { speed: number; drift: number; gain: number }> = {
idle: { speed: 0.25, drift: 0.05, gain: 0 },
listening: { speed: 0.6, drift: 0.08, gain: 0.18 },
thinking: { speed: 1.4, drift: 0.11, gain: 0 },
speaking: { speed: 0.9, drift: 0.1, gain: 0.3 },
};
// Blob layers: token color, orbit phase offset and relative size.
const BLOBS = [
{ color: "var(--chart-1)", phase: 0, scale: 0.78 },
{ color: "var(--chart-2)", phase: 2.1, scale: 0.7 },
{ color: "var(--chart-3)", phase: 4.2, scale: 0.66 },
{ color: "var(--primary)", phase: 1.05, scale: 0.6 },
] as const;
/** Deterministic pseudo-speech envelope: layered sines read as syllables. */
function syntheticLevel(state: VoiceOrbState, t: number) {
if (state === "speaking") {
const syllable = Math.abs(Math.sin(t * 7.3) * Math.sin(t * 2.9 + 1));
const phrase = 0.55 + 0.45 * Math.sin(t * 0.8);
return Math.min(1, 0.15 + syllable * 0.85 * phrase);
}
if (state === "listening") {
const burst = Math.max(0, Math.sin(t * 1.3)) ** 2;
return 0.08 + burst * Math.abs(Math.sin(t * 9.1)) * 0.55;
}
return 0;
}
export function VoiceOrb({
state = "idle",
level = 0,
simulate = false,
size = 160,
label = "Voice assistant",
className,
}: VoiceOrbProps) {
const reduce = useReducedMotion() ?? false;
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const target = useMotionValue(0);
const smooth = useSpring(target, SPRING_GLIDE);
// `clock` integrates speed over time so a state change eases the orbit
// rate instead of jumping the blobs to a new phase; drift and gain ease too.
const clock = useMotionValue(0);
const drift = useMotionValue(STATE_MOTION[state].drift);
const gain = useMotionValue(STATE_MOTION[state].gain);
const speed = useRef(STATE_MOTION[state].speed);
useEffect(() => {
if (!simulate) target.set(Math.max(0, Math.min(1, level)));
}, [level, simulate, target]);
// Motion values update per frame without re-rendering React.
useAnimationFrame((t, delta) => {
if (reduce) return;
const goal = STATE_MOTION[stateRef.current];
const k = Math.min(1, delta / 400);
speed.current += (goal.speed - speed.current) * k;
drift.set(drift.get() + (goal.drift - drift.get()) * k);
gain.set(gain.get() + (goal.gain - gain.get()) * k);
clock.set(clock.get() + (delta / 1000) * speed.current);
if (simulate) target.set(syntheticLevel(stateRef.current, t / 1000));
});
const coreScale = useTransform(() => 0.92 + smooth.get() * gain.get());
const haloScale = useTransform(() => 1 + smooth.get() * gain.get() * 1.4);
const haloOpacity = useTransform(smooth, (l) => 0.35 + l * 0.5);
return (
<div
role="img"
aria-label={`${label}, ${STATE_TEXT[state]}`}
data-state={state}
className={cn("relative isolate inline-flex shrink-0 items-center justify-center", className)}
style={{ width: size, height: size }}
>
{/* Soft halo that breathes with the level. */}
<motion.span
aria-hidden="true"
className="absolute inset-[6%] rounded-full blur-xl"
style={{
background:
"radial-gradient(circle, color-mix(in oklab, var(--primary) 45%, transparent), transparent 70%)",
scale: reduce ? 1 : haloScale,
opacity: reduce ? 0.5 : haloOpacity,
}}
/>
{/* Listening: rings ripple outward. */}
<AnimatePresence>
{state === "listening" && !reduce && (
<motion.span
key="ripples"
aria-hidden="true"
className="absolute inset-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="absolute inset-[14%] rounded-full border border-primary/40"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: [0.9, 1.35], opacity: [0.7, 0] }}
transition={{
duration: 2.1,
ease: EASE_OUT,
repeat: Number.POSITIVE_INFINITY,
delay: i * 0.7,
}}
/>
))}
</motion.span>
)}
</AnimatePresence>
{/* Thinking: a comet arc circles the orb. */}
<AnimatePresence>
{state === "thinking" && (
<motion.span
key="arc"
aria-hidden="true"
className="absolute inset-[4%] rounded-full"
style={
{
background:
"conic-gradient(from 0deg, transparent 0turn, color-mix(in oklab, var(--chart-2) 80%, transparent) 0.22turn, transparent 0.24turn)",
WebkitMask: "radial-gradient(closest-side, transparent calc(100% - 3px), black calc(100% - 2px))",
mask: "radial-gradient(closest-side, transparent calc(100% - 3px), black calc(100% - 2px))",
} as CSSProperties
}
initial={{ opacity: 0 }}
animate={reduce ? { opacity: [0.3, 0.8, 0.3] } : { opacity: 1, rotate: 360 }}
exit={{ opacity: 0, transition: { duration: 0.2 } }}
transition={
reduce
? { duration: 1.8, ease: EASE_IN_OUT, repeat: Number.POSITIVE_INFINITY }
: {
rotate: { duration: 1.1, ease: "linear", repeat: Number.POSITIVE_INFINITY },
opacity: { duration: 0.25 },
}
}
/>
)}
</AnimatePresence>
{/* Core: a clipped sphere holding drifting color blobs. */}
<motion.span
aria-hidden="true"
className="relative size-[72%] overflow-hidden rounded-full bg-card shadow-lg ring-1 ring-foreground/10"
style={{ scale: reduce ? 1 : coreScale }}
animate={
reduce
? { opacity: state === "idle" ? [0.7, 0.85, 0.7] : [0.75, 1, 0.75] }
: { opacity: 1 }
}
transition={
reduce
? { duration: state === "speaking" ? 1.2 : 2.4, ease: EASE_IN_OUT, repeat: Number.POSITIVE_INFINITY }
: { duration: 0.3 }
}
>
{BLOBS.map((blob, i) => (
<Blob
key={blob.color}
index={i}
color={blob.color}
phase={blob.phase}
blobScale={blob.scale}
clock={clock}
level={smooth}
drift={drift}
gain={gain}
still={reduce}
/>
))}
{/* Specular highlight keeps it reading as a sphere. */}
<span
className="absolute inset-0 rounded-full"
style={{
background:
"radial-gradient(circle at 32% 26%, color-mix(in oklab, var(--background) 70%, transparent), transparent 42%)",
}}
/>
</motion.span>
<span className="sr-only" aria-live="polite">
{STATE_TEXT[state]}
</span>
</div>
);
}
function Blob({
index,
color,
phase,
blobScale,
clock,
level,
drift,
gain,
still,
}: {
index: number;
color: string;
phase: number;
blobScale: number;
clock: MotionValue<number>;
level: MotionValue<number>;
drift: MotionValue<number>;
gain: MotionValue<number>;
still: boolean;
}) {
const dir = index % 2 === 0 ? 1 : -1;
// Each blob orbits on its own Lissajous path (percent of the core), and the
// level pushes it outward so loud audio visibly churns the colors.
const x = useTransform(() => {
const t = clock.get() * dir + phase;
const r = (drift.get() + level.get() * gain.get() * 0.35) * 100;
return `${Math.cos(t) * r}%`;
});
const y = useTransform(() => {
const t = clock.get() * 1.3 + phase * 1.7;
const r = (drift.get() + level.get() * gain.get() * 0.35) * 100;
return `${Math.sin(t) * r}%`;
});
const scale = useTransform(() => {
const t = clock.get() * 0.7 + phase;
return blobScale * (1 + 0.08 * Math.sin(t * 2) + level.get() * gain.get() * 0.6);
});
const staticOffset = [
["-18%", "-14%"],
["16%", "-6%"],
["-4%", "18%"],
["10%", "10%"],
][index] ?? ["0%", "0%"];
return (
<motion.span
className="absolute inset-0 m-auto size-full rounded-full mix-blend-normal blur-md"
style={{
background: `radial-gradient(circle, ${color} 0%, color-mix(in oklab, ${color} 50%, transparent) 45%, transparent 70%)`,
x: still ? staticOffset[0] : x,
y: still ? staticOffset[1] : y,
scale: still ? blobScale : scale,
opacity: 0.9,
}}
/>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
useCallback,
useId,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease";
import { cn } from "@/lib/utils";
// SegmentedControl vs Tabs: `Tabs` (tabs.tsx, variants pill/segment/underline)
// switches *panels* with tablist/tab semantics. `SegmentedControl` is a form
// control: it picks one *value* with radiogroup/radio semantics, can post that
// value with a form via `name`, supports disabled options, icon-only options
// and a full-width layout, and its thumb is a raised card surface rather than
// a filled accent pill. Reach for Tabs when each option owns content below it.
export type SegmentedControlSize = "sm" | "md" | "lg";
export interface SegmentedControlOption {
value: string;
label?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
/** Required when the option has only an icon. */
"aria-label"?: string;
}
const trackSize: Record<SegmentedControlSize, string> = {
sm: "h-8 p-0.5 text-xs",
md: "h-10 p-1 text-sm",
lg: "h-12 p-1 text-sm",
};
const itemSize: Record<SegmentedControlSize, string> = {
sm: "gap-1.5 px-2.5 [&_svg]:size-3.5",
md: "gap-2 px-3.5 [&_svg]:size-4",
lg: "gap-2 px-5 [&_svg]:size-[18px]",
};
function useControllable<T>(
value: T | undefined,
defaultValue: T,
onChange: ((next: T) => void) | undefined,
) {
const [internal, setInternal] = useState<T>(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : internal;
const set = useCallback(
(next: T) => {
if (!controlled) setInternal(next);
onChange?.(next);
},
[controlled, onChange],
);
return [current, set] as const;
}
/** Index of the next enabled option from `from` in `dir`, wrapping around. */
function nextEnabled(options: { disabled?: boolean }[], from: number, dir: 1 | -1) {
const n = options.length;
for (let step = 1; step <= n; step++) {
const i = (((from + dir * step) % n) + n) % n;
if (!options[i].disabled) return i;
}
return from;
}
export interface SegmentedControlProps {
options: SegmentedControlOption[];
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
size?: SegmentedControlSize;
/** Stretch to the container width with equal-width segments. */
fullWidth?: boolean;
disabled?: boolean;
/** When set, a hidden input posts the value with the surrounding form. */
name?: string;
"aria-label"?: string;
"aria-labelledby"?: string;
className?: string;
thumbClassName?: string;
}
export function SegmentedControl({
options,
value,
defaultValue,
onValueChange,
size = "md",
fullWidth = false,
disabled = false,
name,
className,
thumbClassName,
...aria
}: SegmentedControlProps) {
const [current, setValue] = useControllable(
value,
defaultValue ?? options.find((o) => !o.disabled)?.value ?? "",
onValueChange,
);
const reduce = useReducedMotion();
const layoutId = useId();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const items = options.map((o) => ({ ...o, disabled: disabled || o.disabled }));
const selectedIndex = items.findIndex((o) => o.value === current);
// Roving tab stop: the checked radio, or the first enabled one when none is.
const tabStop = selectedIndex >= 0 && !items[selectedIndex].disabled
? selectedIndex
: items.findIndex((o) => !o.disabled);
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = nextEnabled(items, index, 1);
break;
case "ArrowLeft":
case "ArrowUp":
next = nextEnabled(items, index, -1);
break;
case "Home":
next = nextEnabled(items, -1, 1);
break;
case "End":
next = nextEnabled(items, items.length, -1);
break;
default:
return;
}
event.preventDefault();
if (next === null || items[next].disabled) return;
// Selection follows focus, as the WAI-ARIA radio group pattern prescribes.
refs.current[next]?.focus();
if (items[next].value !== current) setValue(items[next].value);
};
return (
// layoutRoot keeps the thumb's shared-layout projection local, so scrolled
// or fixed ancestors never leak offsets into its glide.
<motion.div
layoutRoot
role="radiogroup"
aria-disabled={disabled || undefined}
{...aria}
className={cn(
"relative isolate items-stretch rounded-xl border border-border bg-muted",
fullWidth ? "flex w-full" : "inline-flex",
trackSize[size],
disabled && "opacity-60",
className,
)}
>
{name ? <input type="hidden" name={name} value={current} /> : null}
{items.map((option, index) => {
const checked = option.value === current;
const iconOnly = !option.label;
return (
<motion.button
key={option.value}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
role="radio"
aria-checked={checked}
aria-label={option["aria-label"]}
disabled={option.disabled}
tabIndex={index === tabStop ? 0 : -1}
data-state={checked ? "checked" : "unchecked"}
onClick={() => !checked && setValue(option.value)}
onKeyDown={(e) => onKeyDown(e, index)}
whileTap={reduce || option.disabled ? undefined : { scale: 0.96 }}
transition={SPRING_PRESS}
className={cn(
"relative inline-flex min-w-0 select-none items-center justify-center whitespace-nowrap rounded-[9px] font-medium outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-muted",
"disabled:cursor-not-allowed disabled:opacity-50",
fullWidth && "flex-1",
itemSize[size],
iconOnly && "aspect-square px-0",
checked
? "text-foreground"
: "text-muted-foreground enabled:hover:text-foreground",
)}
>
{checked ? (
<motion.span
layoutId={layoutId}
aria-hidden
transition={reduce ? { duration: 0 } : SPRING_LAYOUT}
className={cn(
"absolute inset-0 -z-10 rounded-[9px] border border-border bg-card shadow-sm",
thumbClassName,
)}
/>
) : null}
{option.icon ? (
<span aria-hidden className="inline-flex shrink-0">
{option.icon}
</span>
) : null}
{option.label ? <span className="truncate">{option.label}</span> : null}
</motion.button>
);
})}
</motion.div>
);
}
/* ------------------------------------------------------------------------ */
/* ToggleGroup */
/* ------------------------------------------------------------------------ */
export interface ToggleGroupItem {
value: string;
label?: ReactNode;
icon?: ReactNode;
disabled?: boolean;
/** Required when the item has only an icon. */
"aria-label"?: string;
}
type ToggleGroupBase = {
items: ToggleGroupItem[];
size?: SegmentedControlSize;
disabled?: boolean;
"aria-label"?: string;
"aria-labelledby"?: string;
className?: string;
};
export type ToggleGroupSingleProps = ToggleGroupBase & {
type: "single";
/** The pressed item, or `""` when none is pressed. */
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Disallow un-pressing the active item, so one is always pressed. */
required?: boolean;
};
export type ToggleGroupMultipleProps = ToggleGroupBase & {
type: "multiple";
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;
const toggleSize: Record<SegmentedControlSize, string> = {
sm: "h-7 min-w-7 gap-1.5 px-2 text-xs [&_svg]:size-3.5",
md: "h-9 min-w-9 gap-2 px-2.5 text-sm [&_svg]:size-4",
lg: "h-11 min-w-11 gap-2 px-3.5 text-sm [&_svg]:size-[18px]",
};
export function ToggleGroup(props: ToggleGroupProps) {
const { items, size = "md", disabled = false, className } = props;
const reduce = useReducedMotion();
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const [focusIndex, setFocusIndex] = useState<number | null>(null);
// Normalise both modes to a string[] internally.
const single = props.type === "single";
const [current, setCurrent] = useControllable<string[]>(
props.value === undefined ? undefined : single ? (props.value ? [props.value as string] : []) : (props.value as string[]),
single
? props.defaultValue
? [props.defaultValue as string]
: []
: ((props.defaultValue as string[] | undefined) ?? []),
(next) => {
if (props.type === "single") props.onValueChange?.(next[0] ?? "");
else props.onValueChange?.(next);
},
);
const list = items.map((i) => ({ ...i, disabled: disabled || i.disabled }));
const firstEnabled = list.findIndex((i) => !i.disabled);
const tabStop =
focusIndex !== null && !list[focusIndex]?.disabled ? focusIndex : firstEnabled;
const toggle = (itemValue: string) => {
const pressed = current.includes(itemValue);
if (props.type === "single") {
if (pressed && props.required) return;
setCurrent(pressed ? [] : [itemValue]);
} else {
setCurrent(pressed ? current.filter((v) => v !== itemValue) : [...current, itemValue]);
}
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next: number | null = null;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = nextEnabled(list, index, 1);
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = nextEnabled(list, index, -1);
else if (event.key === "Home") next = nextEnabled(list, -1, 1);
else if (event.key === "End") next = nextEnabled(list, list.length, -1);
if (next === null) return;
event.preventDefault();
// Focus moves; pressing stays on Space/Enter (toggle buttons, not radios).
setFocusIndex(next);
refs.current[next]?.focus();
};
return (
<motion.div
role="group"
aria-label={props["aria-label"]}
aria-labelledby={props["aria-labelledby"]}
className={cn(
"inline-flex items-center gap-0.5 rounded-xl border border-border bg-card p-1",
disabled && "opacity-60",
className,
)}
>
{list.map((item, index) => {
const pressed = current.includes(item.value);
return (
<motion.button
key={item.value}
ref={(el) => {
refs.current[index] = el;
}}
type="button"
aria-pressed={pressed}
aria-label={item["aria-label"]}
disabled={item.disabled}
tabIndex={index === tabStop ? 0 : -1}
data-state={pressed ? "on" : "off"}
onClick={() => {
setFocusIndex(index);
toggle(item.value);
}}
onFocus={() => setFocusIndex(index)}
onKeyDown={(e) => onKeyDown(e, index)}
whileTap={reduce || item.disabled ? undefined : { scale: 0.88 }}
transition={SPRING_PRESS}
className={cn(
"relative isolate inline-flex select-none items-center justify-center rounded-lg font-medium outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-card",
"disabled:cursor-not-allowed disabled:opacity-50",
toggleSize[size],
pressed
? "text-foreground"
: "text-muted-foreground enabled:hover:bg-muted/60 enabled:hover:text-foreground",
)}
>
<AnimatePresence initial={false}>
{pressed ? (
<motion.span
key="on"
aria-hidden
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.75, transition: { duration: 0.12 } }}
transition={SPRING_SWAP}
className="absolute inset-0 -z-10 rounded-lg bg-muted ring-1 ring-border-strong"
/>
) : null}
</AnimatePresence>
{item.icon ? (
<span aria-hidden className="inline-flex shrink-0">
{item.icon}
</span>
) : null}
{item.label ? <span>{item.label}</span> : null}
</motion.button>
);
})}
</motion.div>
);
}
@karaui not found? Add it to components.json
{
"registries": {
"@karaui": "https://karaui.kamisama-v0.my.id/r/{name}.json"
}
}@karaui is in shadcn's public registry directory, so the latest CLI needs no setup. On an older CLI, merge this into the existing file once and every @karaui/… install resolves.
API Reference
state?"idle" | "listening" | "thinking" | "speaking"idlelevel?numberAudio amplitude 0..1 (mic input while listening, output while speaking).
0simulate?booleanSynthesize a plausible `level` for the current state (no audio needed).
falsesize?numberDiameter in px. Default 160.
160label?stringAccessible name; the current state is appended. Default "Voice assistant".
Voice assistantclassName?string—Updated