How to build a blog podcast player with animated waveform bars in React
A React podcast player for blog pages renders animated waveform bars with Framer Motion, a play/pause toggle that switches the bars from static heights to looping keyframe animations, and a scrollable episode list, all themed via CSS custom properties so it adapts to any color palette without touching the component.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~140 lines, zero audio API dependency.
- 40 waveform bars with randomised heights generated once via useMemo; each bar animates independently when playing.
- Accessible: the play/pause button carries an aria-label that updates with state.
- Fully themed: every color reads from CSS custom properties (--color-accent, --color-border, etc.), compatible with the 7 presets.
- The waveform is visual UI only, wire a real <audio> element or a Web Audio API hook to add actual playback.
Blog Audio Player is a React section that brings a podcast feed to life with an animated waveform, a prominent featured-episode card, and a compact episode list below. The waveform bars shift from frozen heights to asynchronous looping animations the moment the user hits play, giving immediate visual feedback without a single line of CSS keyframe animation written by hand.
Anatomy
The component has three visual zones. At the top, a centered header with a pill badge (Headphones icon + "Podcast" label), a podcast name heading and a short description. Below it, a featured-episode card with guest/date metadata, a title and description, then a horizontal row holding the play/pause button, the WaveformBars sub-component, and the episode duration. At the bottom, a scrollable vertical list of smaller episode rows, each with a circular play icon, an episode title, a duration and a date.
How it works
The WaveformBars sub-component receives a single `playing` boolean. On mount, useMemo generates an array of 40 random heights (between 20 and 100% of 48px) that never change between renders, so the bars look consistent. When `playing` is false each bar renders at its fixed height via a simple 0.3s transition. When `playing` flips to true, each bar's `animate` prop receives a three-keyframe height array and a random duration between 0.4s and 0.8s with `repeat: Infinity, repeatType: "reverse"`, creating the staggered, organic waveform pulse. The bars in the first 40% of the array use `--color-accent` to hint at "played" progress, the rest use `--color-border`.
How to build it in React
Build the WaveformBars sub-component
Create a component that accepts `playing` and an optional `barCount` (default 40). Use useMemo to generate the random height array once. Map over it and render a motion.div per bar, giving each one a fixed `minWidth: 2` and `maxWidth: 6` so the bars pack tightly into the available space.
const heights = useMemo( () => Array.from({ length: barCount }, () => 20 + Math.random() * 80), [barCount] );Switch between static and animated states
Pass different `animate` props depending on the `playing` flag. When false, animate to the fixed pixel height. When true, pass a three-value keyframe array so Framer Motion loops it automatically. Each bar gets a randomised duration to break visual synchrony.
animate={playing ? { height: [h * 0.3, h * 0.01 * 48, h * 0.5 * 0.01 * 48] } : { height: h * 0.01 * 48 } } transition={playing ? { duration: 0.4 + Math.random() * 0.4, repeat: Infinity, repeatType: "reverse" } : { duration: 0.3 } }Assemble the featured-episode card
Render the guest name, date, episode title and description above the player row. The player row is a flex container: the circular accent play/pause button on the left, the WaveformBars with `flex: 1` in the middle, and the duration string on the right. Manage play state with a single useState boolean and toggle it on button click.
const [playing, setPlaying] = useState(false); // ... <button onClick={() => setPlaying(!playing)} aria-label={playing ? "Pause" : "Lecture"}> {playing ? <Pause /> : <Play />} </button> <WaveformBars playing={playing} /> <span>{feat.duration}</span>Add the episode list with staggered entry animations
Map over the episodes array and render each as a motion.button (for keyboard accessibility). Set `initial={{ opacity: 0, y: 10 }}` and `whileInView={{ opacity: 1, y: 0 }}` with a delay of `0.12 + index * 0.05` seconds so the rows cascade in as the user scrolls down to the section.
When to use it
Reach for it on blog or editorial sites that publish audio content: podcast landing pages, interview-driven newsletters, or media brands that want to showcase episodes without redirecting readers to Spotify. It fits between a featured article and a newsletter CTA. Skip it if your site has no audio content at all, and remember the waveform is decorative, connect a real audio source before shipping to production.
Used by
- Spotify, Waveform-style scrubbers and animated bars signal playback state across its web player and podcast pages.
- Transistor, Embeddable podcast players with animated waveform previews used by thousands of independent podcasters.
- Substack, In-post audio players with a minimal waveform UI let writers publish spoken editions of their newsletters.
- Overcast, Compact episode list cards with metadata (duration, date, guest) mirror the layout pattern of the component's episode list.
FAQ
Does this component actually play audio?
No, the waveform and play/pause state are purely visual. To add real playback, attach an HTML <audio> element (or the Web Audio API) and sync its `play()`/`pause()` calls to the same boolean state that drives the waveform animation.
Why use useMemo for the bar heights?
Without useMemo, the random heights would regenerate on every render, making the bars jump to new positions whenever the parent re-renders. Memoising them once ensures a stable, consistent waveform shape for the lifetime of the component.
How do I change the waveform color to match my brand?
The bars read `--color-accent` (played portion) and `--color-border` (unplayed portion) from CSS custom properties. Override those two variables on your wrapper element or switch to a different theme preset and the waveform updates automatically.
Can I reduce the number of waveform bars for a lighter UI?
Yes. Pass `barCount={20}` (or any number) to WaveformBars. Fewer bars give a sparser, more minimal look; more bars produce a denser waveform. The component's flex layout scales the bar widths automatically to fill the available space.