Audio scrubber that renders playback position as a filled amplitude waveform — the podcast and audiobook signature. Scrubbing rides an invisible Slider, so tap-to-seek, drag, per-step haptics, and adjustable accessibility come free.
components/ui/waveform/waveform.tsx@lumioui/core@latest| value? | number | Controlled position (min..max). Omit for uncontrolled via defaultValue, or pass `value` alone for a static progress display. |
| defaultValue? | number | Uncontrolled initial position. |
| onValueChange? | (value: number) => void | |
| min? | number | |
| max? | number | |
| bars? | number | Bar count. Default 44. |
| scrubbable? | boolean | Tap/drag to scrub via an invisible Slider layered over the bars. Default true — set false for a purely visual progress read-out. |
| className? | string | Extra classes on the outer container (height, width). |
| barClassName? | string | Extra classes on every bar. |
| fillClassName? | string | Filled (played) portion color. Default `bg-foreground`. |
| trackClassName? | string | Unfilled portion color. Default `bg-foreground/25`. |
Audio scrubber that renders playback position as a filled amplitude waveform — the podcast and audiobook signature. Scrubbing rides an invisible Slider, so tap-to-seek, drag, per-step haptics, and adjustable accessibility come free.
import { Waveform } from "@/components/ui/waveform/waveform";
export function Example() {
return (
<Waveform />
);
} import * as React from "react";
import { forwardRef } from "react";
import { View, type ViewProps } from "react-native";
import { cn } from "@lumioui/core";
import { Slider } from "../slider/slider";
/** Deterministic per-bar amplitude (0.28–1.0). A fixed shape — not random —
* so every render and every install draws the same waveform. */
function amplitude(index: number) {
const wave = Math.abs(Math.sin(index * 2.35) * 0.55 + Math.sin(index * 0.7 + 1.2) * 0.45);
return 0.28 + 0.72 * wave;
}
export interface WaveformProps extends Omit<ViewProps, "className"> {
/** Controlled position (min..max). Omit for uncontrolled via defaultValue,
* or pass `value` alone for a static progress display. */
value?: number;
/** Uncontrolled initial position. */
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
/** Bar count. Default 44. */
bars?: number;
/** Tap/drag to scrub via an invisible Slider layered over the bars.
* Default true — set false for a purely visual progress read-out. */
scrubbable?: boolean;
/** Extra classes on the outer container (height, width). */
className?: string;
/** Extra classes on every bar. */
barClassName?: string;
/** Filled (played) portion color. Default `bg-foreground`. */
fillClassName?: string;
/** Unfilled portion color. Default `bg-foreground/25`. */
trackClassName?: string;
}
/**
* Waveform — the podcast/audiobook signature scrubber: a row of amplitude
* bars whose fill follows playback. Scrubbing rides an invisible Slider so
* drag, tap-to-seek, per-step haptics, and adjustable a11y come free.
* <Waveform value={pos} onValueChange={setPos} />
* Expo Go compatible.
*/
export const Waveform = forwardRef<React.ElementRef<typeof View>, WaveformProps>(
function Waveform(
{
value: controlledValue,
defaultValue,
onValueChange,
min = 0,
max = 100,
bars = 44,
scrubbable = true,
className,
barClassName,
fillClassName = "bg-foreground",
trackClassName = "bg-foreground/25",
...props
},
ref
) {
const [internal, setInternal] = React.useState(defaultValue ?? min);
const isControlled = controlledValue !== undefined;
const value = isControlled ? controlledValue : internal;
const progress = Math.min(Math.max((value - min) / (max - min), 0), 1);
const filled = Math.round(progress * bars);
const handle = (v: number) => {
if (!isControlled) setInternal(v);
onValueChange?.(v);
};
return (
<View
ref={ref}
className={cn("h-12 w-full justify-center", className)}
accessibilityRole={scrubbable ? undefined : "progressbar"}
accessibilityValue={scrubbable ? undefined : { min, max, now: Math.round(value) }}
{...props}
>
<View
className="h-full flex-row items-center gap-[3px]"
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
{Array.from({ length: bars }, (_, i) => (
<View
key={i}
style={{ height: `${amplitude(i) * 100}%` }}
className={cn(
"w-[3px] flex-1 rounded-full",
i < filled ? fillClassName : trackClassName,
barClassName
)}
/>
))}
</View>
{scrubbable ? (
<Slider
value={value}
onValueChange={handle}
min={min}
max={max}
step={0.5}
className="absolute bottom-0 left-0 right-0 top-0 h-full w-full opacity-0"
/>
) : null}
</View>
);
}
);
Waveform.displayName = "Waveform";