Why it exists
The DJ Calendar's homepage is a grid of 99 artist cards. A photo tells you who a DJ is; it doesn't tell you what they sound like. Hovering a card and hearing thirty seconds of their music answers that instantly, without leaving the page, opening a player or signing in to anything.
The hard part isn't playing audio on hover. It's doing it in a way that never misbehaves: on touch screens where hover doesn't exist, for visitors who've asked their device for less motion, when another player is already running, and when someone sweeps their cursor across ten cards in a second.
Real frequencies, not a fake animation
Plenty of "audio visualizers" are a looping CSS animation that would bounce just the same in silence. This one is wired to the sound. The preview plays through an <audio> element, createMediaElementSource routes it into an AnalyserNode, and on every animation frame each of the eight bars reads its own frequency bin.
const source = ctx.createMediaElementSource(audio);
const analyser = ctx.createAnalyser();
analyser.fftSize = 64; // 32 frequency bins: plenty for 8 bars
analyser.smoothingTimeConstant = 0.8; // calm, readable movement
source.connect(analyser).connect(ctx.destination);
const bins = new Uint8Array(analyser.frequencyBinCount);
(function draw() {
analyser.getByteFrequencyData(bins); // the real spectrum, this frame
drawEightBars(bins);
raf = requestAnimationFrame(draw);
})();
Simplified sketch of the technique. The production code is in src/ on GitHub.
One detail makes this legal on Apple's preview files: the audio element is created with crossOrigin = "anonymous". Without it, the browser treats cross-origin audio as opaque and the analyser reads nothing but zeros.
Every "no" is designed
Most hover-preview widgets fail loudly on the devices and settings they weren't built for. This one treats each "no" as a first-class answer:
- Touch devices
- On
(hover: none) and (pointer: coarse), hover never fires. A tap on the photo toggles play and stop, and that tap is also the user gesture browsers require for sound. - Reduced motion
- With
prefers-reduced-motion: reduce, there's no hover preview at all. A motion-reactive EQ is exactly what these visitors asked their device to skip. - Another player is running
- When the site's favorites player is active, card previews stand down, then hand control back when they're done.
- No preview found
- The card is marked
data-preview-missand quietly skipped from then on: no repeated failing lookups on every hover.
The race-condition guard
Sweep your cursor across a row of cards quickly and three things race each other: the audio loading, the canvas draw loop, and your mouse leaving. Without a guard, a preview that finished loading after you moved on would start playing on a card you're no longer pointing at.
The fix is a generation counter on each card. Every hover bumps it, and every async callback checks it before touching the audio or the DOM. A callback from an older generation is stale, so it does nothing. Try it on the demo: sweep in and out quickly and watch the counters under the card.
function onEnter(card) {
const gen = ++card._previewGen; // this hover's ticket
loadPreview(card).then(url => {
if (gen !== card._previewGen) return; // stale: the cursor already left
play(card, url);
});
}
function onLeave(card) {
card._previewGen++; // orphan everything the last hover started
stop(card);
}
Simplified sketch. In production the counter is _djcPreviewGen, and it guards the audio load, the draw loop and the overlay together.
Extras that come free
- While a preview plays, the tab title becomes
Artist - Track, then restores when it stops. navigator.mediaSessiongets real metadata, so the track appears in the operating system's media controls with its artwork.- If a card has no cached preview URL, the widget looks one up live from the iTunes Search API, takes the first result with a preview, and caches it on the card for next time.
Use it in your project
The widget binds itself to a grid and expects each card to follow this contract. The two data-preview-* attributes are an optional cache; leave them out and the first hover fills them in.
<a class="dj-card" href="/artist-slug/"
data-id="davidguetta"
data-name="David Guetta"
data-image="https://.../artist.jpg"
data-preview-url="https://audio-ssl.itunes.apple.com/...m4a" <!-- optional cache -->
data-preview-title="Titanium (feat. Sia)"> <!-- optional cache -->
<span class="card-image">
<img class="card-image-photo" src="..." alt="...">
<span class="card-image-overlay">
<span class="card-overlay-label">You are now listening to:</span>
<span class="card-overlay-track"></span>
</span>
<canvas class="waveform-canvas" width="280" height="40"></canvas>
</span>
<span class="dj-card-name">David Guetta</span>
</a>
From the repo's README. No build step, no framework, no dependencies: any browser from the last decade with Web Audio support.
Related projects
Frequently asked questions
What does dj-card-preview do?
dj-card-preview adds a 30-second iTunes preview to artist cards and draws an eight-bar canvas equalizer from the audio’s real Web Audio frequencies.
How does the equalizer respond to music?
The audio element feeds a Web Audio AnalyserNode, and each canvas bar reads its own frequency bin while the preview plays.
Does the preview work on touch devices?
On touch devices, a tap toggles playback instead of relying on hover. The tap is also the user gesture needed to allow audio.
What happens when motion is reduced or no preview is available?
The widget disables hover preview for reduced-motion preferences and remembers a missing preview so it does not repeat the failed lookup on every hover.
Need someone who sweats these details?
Bryan Hamilton is a full-stack web engineer with 25+ years in PHP and JavaScript, available immediately for remote, hybrid or on-site roles in the NYC metro area.