Oscilloscope
import { useEffect, useRef } from 'react';
const css = `
.btn-oscilloscope {
display: inline-flex;
align-items: center;
gap: 14px;
padding: 8px 20px 8px 8px;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 12px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.3s ease;
}
.btn-oscilloscope:hover { border-color: light-dark(#6d7179, #7d8087); }
.btn-oscilloscope:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-oscilloscope__screen {
position: relative;
width: 124px;
height: 38px;
border-radius: 7px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 100% 9.5px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 15.5px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 0 14px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35));
overflow: hidden;
}
.btn-oscilloscope__trace {
fill: none;
stroke: light-dark(#373a40, #f1f2f3);
stroke-width: 1.6;
stroke-linejoin: round;
filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd));
}
`;
const W = 124;
const H = 38;
const N = 90;
export function OscilloscopeButton() {
const path = useRef<SVGPathElement>(null);
const hover = useRef(0);
const pulse = useRef<number | null>(null);
useEffect(() => {
const el = path.current!;
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let blend = 0;
let phase = 0;
let last = performance.now();
let raf = 0;
const draw = () => {
let d = '';
const amp = 8 + 4 * blend;
for (let i = 0; i <= N; i++) {
const u = i / N;
const s = Math.sin(u * Math.PI * 2 * (2 + blend) + phase);
const square = Math.tanh(7 * s);
let y = (s * (1 - blend) + square * blend) * amp;
if (pulse.current !== null) y -= 13 * Math.exp(-(((u - pulse.current) / 0.035) ** 2));
d += `${i ? 'L' : 'M'}${(u * W).toFixed(1)} ${(H / 2 + y).toFixed(1)}`;
}
el.setAttribute('d', d);
};
const tick = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
blend += (hover.current - blend) * Math.min(1, dt * 6);
phase -= dt * (3 + 5 * blend);
if (pulse.current !== null) {
pulse.current += dt * 1.4;
if (pulse.current > 1.1) pulse.current = null;
}
draw();
raf = requestAnimationFrame(tick);
};
if (still) draw();
else raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<button
type="button"
className="btn-oscilloscope"
onPointerEnter={() => (hover.current = 1)}
onPointerLeave={() => (hover.current = 0)}
onFocus={() => (hover.current = 1)}
onBlur={() => (hover.current = 0)}
onClick={() => (pulse.current = -0.1)}
>
<span className="btn-oscilloscope__screen" aria-hidden="true">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`}>
<path ref={path} className="btn-oscilloscope__trace" />
</svg>
</span>
Signal
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnOscilloscope(el) on the mounted element instead.
<button type="button" class="btn-oscilloscope"><span class="btn-oscilloscope__screen" aria-hidden="true"><svg width="124" height="38" viewBox="0 0 124 38"><path class="btn-oscilloscope__trace"></path></svg></span>Signal</button>.btn-oscilloscope {
display: inline-flex;
align-items: center;
gap: 14px;
padding: 8px 20px 8px 8px;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 12px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.3s ease;
}
.btn-oscilloscope:hover { border-color: light-dark(#6d7179, #7d8087); }
.btn-oscilloscope:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-oscilloscope__screen {
position: relative;
width: 124px;
height: 38px;
border-radius: 7px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 100% 9.5px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 15.5px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 0 14px light-dark(rgba(0, 0, 0, 0.11), rgba(0, 0, 0, 0.35));
overflow: hidden;
}
.btn-oscilloscope__trace {
fill: none;
stroke: light-dark(#373a40, #f1f2f3);
stroke-width: 1.6;
stroke-linejoin: round;
filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd));
}function initBtnOscilloscope(root) {
var path = root.querySelector('.btn-oscilloscope__trace');
if (!path) return;
var W = 124;
var H = 38;
var N = 90;
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var hover = 0;
var blend = 0;
var phase = 0;
var pulse = null;
var last = performance.now();
var raf = 0;
function draw() {
var d = '';
var amp = 8 + 4 * blend;
for (var i = 0; i <= N; i++) {
var u = i / N;
var s = Math.sin(u * Math.PI * 2 * (2 + blend) + phase);
// tanh(7·sin) flattens the sine into a square wave as blend rises.
var y = (s * (1 - blend) + Math.tanh(7 * s) * blend) * amp;
if (pulse !== null) y -= 13 * Math.exp(-Math.pow((u - pulse) / 0.035, 2));
d += (i ? 'L' : 'M') + (u * W).toFixed(1) + ' ' + (H / 2 + y).toFixed(1);
}
path.setAttribute('d', d);
}
function tick(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
blend += (hover - blend) * Math.min(1, dt * 6);
phase -= dt * (3 + 5 * blend);
if (pulse !== null) {
pulse += dt * 1.4;
if (pulse > 1.1) pulse = null;
}
draw();
raf = requestAnimationFrame(tick);
}
function on() { hover = 1; }
function off() { hover = 0; }
function fire() { pulse = -0.1; }
root.addEventListener('pointerenter', on);
root.addEventListener('pointerleave', off);
root.addEventListener('focus', on);
root.addEventListener('blur', off);
root.addEventListener('click', fire);
if (still) draw();
else raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointerenter', on);
root.removeEventListener('pointerleave', off);
root.removeEventListener('focus', on);
root.removeEventListener('blur', off);
root.removeEventListener('click', fire);
};
}
document.querySelectorAll('.btn-oscilloscope').forEach(initBtnOscilloscope);Goal: Create a "Oscilloscope" button.
Visual behavior: A button with a live oscilloscope screen. The trace runs as a sine at rest, bends into a square wave and speeds up on hover, and a click fires a pulse that travels along the line.
Technique:
- The screen is a small gridded panel (two repeating linear-gradients for the graticule) holding one SVG path.
- Every frame the path is rebuilt from 90 samples: a sine blended toward tanh(7 * sine), which flattens it into a square wave, by a hover value that eases toward 1 or 0.
- Hover also raises the frequency and scroll speed. A click starts a Gaussian bump at the left edge that travels across and fades out.
- A drop-shadow filter gives the trace its phosphor glow.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus drives the trace like hover, and the button keeps its outline. Under prefers-reduced-motion the trace is drawn once and stays still.
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 "Oscilloscope" button.
Visual behavior: A button with a live oscilloscope screen. The trace runs as a sine at rest, bends into a square wave and speeds up on hover, and a click fires a pulse that travels along the line.
Technique:
- The screen is a small gridded panel (two repeating linear-gradients for the graticule) holding one SVG path.
- Every frame the path is rebuilt from 90 samples: a sine blended toward tanh(7 * sine), which flattens it into a square wave, by a hover value that eases toward 1 or 0.
- Hover also raises the frequency and scroll speed. A click starts a Gaussian bump at the left edge that travels across and fades out.
- A drop-shadow filter gives the trace its phosphor glow.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus drives the trace like hover, and the button keeps its outline. Under prefers-reduced-motion the trace is drawn once and stays still.
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 button with a live oscilloscope screen. The trace runs as a sine at rest, bends into a square wave and speeds up on hover, and a click fires a pulse that travels along the line.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- hover, click, continuous, morph
- Techniques
- raf, svg, gradient
- 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 drives the trace like hover, and the button keeps its outline. Under prefers-reduced-motion the trace is drawn once and stays still.