Waveform
import { useEffect, useRef } from 'react';
const css = `
.btn-waveform {
position: relative;
display: inline-grid;
place-items: center;
width: 220px;
height: 56px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 999px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
overflow: hidden;
transition: border-color 0.3s ease;
}
.btn-waveform:hover { border-color: light-dark(#6d7179, #7d8087); }
.btn-waveform:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-waveform__bars {
position: absolute;
inset: 0 22px;
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-waveform__bar {
width: 3px;
height: 40px;
border-radius: 3px;
background: light-dark(#cbced3, #393c41);
transform: scaleY(0.15);
will-change: transform;
}
.btn-waveform__label {
position: relative;
padding: 4px 12px;
border-radius: 999px;
background: color-mix(in oklab, light-dark(#f3f4f7, #1a1c1f) 82%, transparent);
backdrop-filter: blur(2px);
}
`;
const COUNT = 30;
export function WaveformButton() {
const root = useRef<HTMLButtonElement>(null);
const bars = useRef<HTMLSpanElement[]>([]);
const cursor = useRef<{ x: number; on: number }>({ x: 0.5, on: 0 });
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
bars.current.forEach((b, i) => (b.style.transform = `scaleY(${0.15 + 0.12 * Math.abs(Math.sin(i * 0.7))})`));
return;
}
let raf = 0;
let energy = 0;
const tick = (now: number) => {
const t = now / 1000;
energy += (cursor.current.on - energy) * 0.08;
bars.current.forEach((b, i) => {
const u = i / (COUNT - 1);
const idle = 0.14 + 0.1 * (0.5 + 0.5 * Math.sin(t * 2.2 + i * 0.55));
const near = Math.exp(-(((u - cursor.current.x) / 0.16) ** 2));
const beat = 0.55 + 0.45 * Math.sin(t * 11 + i * 1.7) * Math.sin(t * 5.3 + i);
const s = Math.min(1, idle + energy * near * beat * 0.95);
b.style.transform = `scaleY(${s.toFixed(3)})`;
b.style.background = near * energy > 0.35 ? 'light-dark(#50535b, #c6c8cd)' : 'light-dark(#cbced3, #393c41)';
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<button
ref={root}
type="button"
className="btn-waveform"
onPointerMove={(e) => {
const r = root.current!.getBoundingClientRect();
cursor.current = { x: (e.clientX - r.left - 22) / (r.width - 44), on: 1 };
}}
onPointerLeave={() => (cursor.current.on = 0)}
onFocus={() => (cursor.current = { x: 0.5, on: 1 })}
onBlur={() => (cursor.current.on = 0)}
>
<span className="btn-waveform__bars" aria-hidden="true">
{Array.from({ length: COUNT }, (_, i) => (
<span key={i} ref={(el) => void (el && (bars.current[i] = el))} className="btn-waveform__bar" />
))}
</span>
<span className="btn-waveform__label">Speak</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnWaveform(el) on the mounted element instead.
<button type="button" class="btn-waveform">
<span class="btn-waveform__bars" aria-hidden="true">
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
<span class="btn-waveform__bar"></span>
</span>
<span class="btn-waveform__label">Speak</span>
</button>.btn-waveform {
position: relative;
display: inline-grid;
place-items: center;
width: 220px;
height: 56px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 999px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
overflow: hidden;
transition: border-color 0.3s ease;
}
.btn-waveform:hover { border-color: light-dark(#6d7179, #7d8087); }
.btn-waveform:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-waveform__bars {
position: absolute;
inset: 0 22px;
display: flex;
align-items: center;
justify-content: space-between;
}
.btn-waveform__bar {
width: 3px;
height: 40px;
border-radius: 3px;
background: light-dark(#cbced3, #393c41);
transform: scaleY(0.15);
will-change: transform;
}
.btn-waveform__label {
position: relative;
padding: 4px 12px;
border-radius: 999px;
background: color-mix(in oklab, light-dark(#f3f4f7, #1a1c1f) 82%, transparent);
backdrop-filter: blur(2px);
}function initBtnWaveform(root) {
var bars = root.querySelectorAll('.btn-waveform__bar');
var count = bars.length;
if (!count) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
bars.forEach(function (b, i) {
b.style.transform = 'scaleY(' + (0.15 + 0.12 * Math.abs(Math.sin(i * 0.7))) + ')';
});
return;
}
var cursor = { x: 0.5, on: 0 };
var energy = 0;
var raf = 0;
function tick(now) {
var t = now / 1000;
energy += (cursor.on - energy) * 0.08;
bars.forEach(function (b, i) {
var u = i / (count - 1);
var idle = 0.14 + 0.1 * (0.5 + 0.5 * Math.sin(t * 2.2 + i * 0.55));
// A bump centred on the cursor, pulsing with a fast two-sine beat.
var near = Math.exp(-Math.pow((u - cursor.x) / 0.16, 2));
var beat = 0.55 + 0.45 * Math.sin(t * 11 + i * 1.7) * Math.sin(t * 5.3 + i);
b.style.transform = 'scaleY(' + Math.min(1, idle + energy * near * beat * 0.95).toFixed(3) + ')';
b.style.background = near * energy > 0.35 ? 'light-dark(#50535b, #c6c8cd)' : 'light-dark(#cbced3, #393c41)';
});
raf = requestAnimationFrame(tick);
}
function move(e) {
var r = root.getBoundingClientRect();
cursor = { x: (e.clientX - r.left - 22) / (r.width - 44), on: 1 };
}
function leave() { cursor.on = 0; }
function focus() { cursor = { x: 0.5, on: 1 }; }
root.addEventListener('pointermove', move);
root.addEventListener('pointerleave', leave);
root.addEventListener('focus', focus);
root.addEventListener('blur', leave);
raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerleave', leave);
root.removeEventListener('focus', focus);
root.removeEventListener('blur', leave);
};
}
document.querySelectorAll('.btn-waveform').forEach(initBtnWaveform);Goal: Create a "Waveform" button.
Visual behavior: A pill button filled with equaliser bars that idle gently, then swell and dance around the cursor as if it were the sound source, brightening where the energy is highest.
Technique:
- Thirty thin bars sit in a flex row behind the label, each scaled on Y from its centre.
- Every frame each bar takes an idle sine plus a Gaussian bump centred on the cursor position (normalised across the bar row), multiplied by a fast two-sine beat.
- The bump's strength eases toward 1 while the pointer is over the button and back to 0 when it leaves; bars near the peak switch to a lighter tone.
- The label sits on a slightly blurred chip so it stays readable over the bars.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus wakes the bars around the centre. Under prefers-reduced-motion the bars hold a static waveform.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Waveform" button.
Visual behavior: A pill button filled with equaliser bars that idle gently, then swell and dance around the cursor as if it were the sound source, brightening where the energy is highest.
Technique:
- Thirty thin bars sit in a flex row behind the label, each scaled on Y from its centre.
- Every frame each bar takes an idle sine plus a Gaussian bump centred on the cursor position (normalised across the bar row), multiplied by a fast two-sine beat.
- The bump's strength eases toward 1 while the pointer is over the button and back to 0 when it leaves; bars near the peak switch to a lighter tone.
- The label sits on a slightly blurred chip so it stays readable over the bars.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus wakes the bars around the centre. Under prefers-reduced-motion the bars hold a static waveform.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A pill button filled with equaliser bars that idle gently, then swell and dance around the cursor as if it were the sound source, brightening where the energy is highest.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- hover, mouse tracking, continuous, delay stagger
- Techniques
- raf, mouse tracking, event position
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. Focus wakes the bars around the centre. Under prefers-reduced-motion the bars hold a static waveform.