Synth
import { useEffect, useRef, useState } from 'react';
const css = `
.card-synth {
width: 236px;
padding: 12px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-synth__screen {
display: block;
width: 212px;
height: 70px;
border-radius: 10px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 100% 17.5px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 21.2px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-synth__wave { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.6; stroke-linejoin: round; filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd)); }
.card-synth__knobs { display: flex; justify-content: space-around; margin-top: 12px; }
.card-synth__control { display: grid; justify-items: center; gap: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; }
.card-synth__knob {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, light-dark(#cbced3, #393c41), light-dark(#ebedf0, #232529));
box-shadow: 0 0 0 4px light-dark(#fbfcfd, #121417), 0 0 0 5px light-dark(#dbdde1, #2e3035), 0 6px 10px -4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
cursor: ns-resize;
touch-action: none;
}
.card-synth__knob:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 6px; }
.card-synth__knob::after {
content: '';
position: absolute;
left: 50%;
top: 5px;
width: 3px;
height: 12px;
margin-left: -1.5px;
border-radius: 2px;
background: light-dark(#373a40, #f1f2f3);
}
.card-synth__value { color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; }
`;
const W = 212;
const H = 70;
/** A knob: drag up/down (or arrow keys) to turn it through 270 degrees. */
function Knob({ label, value, onChange, format }: { label: string; value: number; onChange: (v: number) => void; format: (v: number) => string }) {
const drag = useRef<{ y: number; v: number } | null>(null);
return (
<div className="card-synth__control">
<span
className="card-synth__knob"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value * 100)}
aria-valuetext={format(value)}
style={{ transform: `rotate(${-135 + value * 270}deg)` }}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { y: e.clientY, v: value };
}}
onPointerMove={(e) => {
if (!drag.current) return;
onChange(Math.min(1, Math.max(0, drag.current.v + (drag.current.y - e.clientY) / 120)));
}}
onPointerUp={() => (drag.current = null)}
onKeyDown={(e) => {
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') onChange(Math.min(1, value + 0.05));
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') onChange(Math.max(0, value - 0.05));
}}
/>
{label}
<span className="card-synth__value">{format(value)}</span>
</div>
);
}
export function SynthCard() {
const wave = useRef<SVGPathElement>(null);
const [freq, setFreq] = useState(0.35);
const [shape, setShape] = useState(0.2);
const params = useRef({ freq, shape });
params.current = { freq, shape };
useEffect(() => {
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let raf = 0;
const draw = (now: number) => {
const { freq: f, shape: s } = params.current;
const cycles = 1 + f * 6;
const phase = still ? 0 : now / 400;
let d = '';
for (let i = 0; i <= 120; i++) {
const u = i / 120;
const x = (u * cycles + phase) % 1;
// Shape morphs sine → triangle → square → saw as the knob turns.
const sine = Math.sin(x * Math.PI * 2);
const tri = 1 - 4 * Math.abs(x - 0.5);
const sq = x < 0.5 ? 1 : -1;
const saw = 2 * x - 1;
const k = s * 3;
const seg = Math.min(2, Math.floor(k));
const t = k - seg;
const pair = [
[sine, -tri],
[-tri, sq],
[sq, saw],
][seg];
const y = pair[0] * (1 - t) + pair[1] * t;
d += `${i ? 'L' : 'M'}${(u * W).toFixed(1)} ${(H / 2 - y * 24).toFixed(1)}`;
}
wave.current!.setAttribute('d', d);
if (!still) raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, [freq, shape]);
const shapes = ['Sine', 'Triangle', 'Square', 'Saw'];
return (
<>
<style>
{css}
</style>
<div className="card-synth" role="group" aria-label="Oscillator">
<svg className="card-synth__screen" viewBox={`0 0 ${W} ${H}`} aria-hidden="true">
<path ref={wave} className="card-synth__wave" />
</svg>
<div className="card-synth__knobs">
<Knob label="Freq" value={freq} onChange={setFreq} format={(v) => `${Math.round(110 * 2 ** (v * 3))} Hz`} />
<Knob label="Shape" value={shape} onChange={setShape} format={(v) => shapes[Math.round(v * 3)]} />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCardSynth(el) on the mounted element instead.
<div class="card-synth" role="group" aria-label="Oscillator">
<svg class="card-synth__screen" viewBox="0 0 212 70" aria-hidden="true">
<path class="card-synth__wave"></path>
</svg>
<div class="card-synth__knobs">
<div class="card-synth__control"><span class="card-synth__knob" role="slider" tabindex="0" aria-label="Freq" aria-valuemin="0" aria-valuemax="100" aria-valuenow="35" aria-valuetext="228 Hz" style="transform:rotate(-40.5deg)"></span>Freq<span class="card-synth__value">228 Hz</span></div>
<div class="card-synth__control"><span class="card-synth__knob" role="slider" tabindex="0" aria-label="Shape" aria-valuemin="0" aria-valuemax="100" aria-valuenow="20" aria-valuetext="Triangle" style="transform:rotate(-81deg)"></span>Shape<span class="card-synth__value">Triangle</span></div>
</div>
</div>.card-synth {
width: 236px;
padding: 12px;
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-synth__screen {
display: block;
width: 212px;
height: 70px;
border-radius: 10px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 100% 17.5px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 0 / 21.2px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.card-synth__wave { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.6; stroke-linejoin: round; filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd)); }
.card-synth__knobs { display: flex; justify-content: space-around; margin-top: 12px; }
.card-synth__control { display: grid; justify-items: center; gap: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; }
.card-synth__knob {
position: relative;
width: 44px;
height: 44px;
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, light-dark(#cbced3, #393c41), light-dark(#ebedf0, #232529));
box-shadow: 0 0 0 4px light-dark(#fbfcfd, #121417), 0 0 0 5px light-dark(#dbdde1, #2e3035), 0 6px 10px -4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
cursor: ns-resize;
touch-action: none;
}
.card-synth__knob:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 6px; }
.card-synth__knob::after {
content: '';
position: absolute;
left: 50%;
top: 5px;
width: 3px;
height: 12px;
margin-left: -1.5px;
border-radius: 2px;
background: light-dark(#373a40, #f1f2f3);
}
.card-synth__value { color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; }function initCardSynth(root) {
var wave = root.querySelector('.card-synth__wave');
var knobs = root.querySelectorAll('.card-synth__knob');
var reads = root.querySelectorAll('.card-synth__value');
if (!wave || knobs.length !== 2 || reads.length !== 2) return;
var W = 212;
var H = 70;
var SHAPES = ['Sine', 'Triangle', 'Square', 'Saw'];
var values = [0.35, 0.2]; // freq, shape
var formats = [
function (v) { return Math.round(110 * Math.pow(2, v * 3)) + ' Hz'; },
function (v) { return SHAPES[Math.round(v * 3)]; },
];
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0;
function draw(now) {
var f = values[0];
var s = values[1];
var cycles = 1 + f * 6;
var phase = still ? 0 : now / 400;
var d = '';
for (var i = 0; i <= 120; i++) {
var u = i / 120;
var x = (u * cycles + phase) % 1;
// Shape morphs sine, triangle, square, saw as the knob turns.
var sine = Math.sin(x * Math.PI * 2);
var tri = 1 - 4 * Math.abs(x - 0.5);
var sq = x < 0.5 ? 1 : -1;
var saw = 2 * x - 1;
var k = s * 3;
var seg = Math.min(2, Math.floor(k));
var t = k - seg;
var pair = [[sine, -tri], [-tri, sq], [sq, saw]][seg];
var y = pair[0] * (1 - t) + pair[1] * t;
d += (i ? 'L' : 'M') + (u * W).toFixed(1) + ' ' + (H / 2 - y * 24).toFixed(1);
}
wave.setAttribute('d', d);
if (!still) raf = requestAnimationFrame(draw);
}
function set(n, v) {
values[n] = Math.min(1, Math.max(0, v));
var knob = knobs[n];
knob.style.transform = 'rotate(' + (-135 + values[n] * 270) + 'deg)';
knob.setAttribute('aria-valuenow', String(Math.round(values[n] * 100)));
knob.setAttribute('aria-valuetext', formats[n](values[n]));
reads[n].textContent = formats[n](values[n]);
if (still) draw(0);
}
// A knob: drag up or down (or use the arrow keys) to turn it through 270 degrees.
var cleanups = [];
knobs.forEach(function (knob, n) {
var drag = null;
function down(e) {
knob.setPointerCapture(e.pointerId);
drag = { y: e.clientY, v: values[n] };
}
function move(e) {
if (drag) set(n, drag.v + (drag.y - e.clientY) / 120);
}
function up() { drag = null; }
function key(e) {
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') set(n, values[n] + 0.05);
if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') set(n, values[n] - 0.05);
}
knob.addEventListener('pointerdown', down);
knob.addEventListener('pointermove', move);
knob.addEventListener('pointerup', up);
knob.addEventListener('keydown', key);
cleanups.push(function () {
knob.removeEventListener('pointerdown', down);
knob.removeEventListener('pointermove', move);
knob.removeEventListener('pointerup', up);
knob.removeEventListener('keydown', key);
});
});
raf = requestAnimationFrame(draw);
return function () {
cancelAnimationFrame(raf);
cleanups.forEach(function (c) { c(); });
};
}
document.querySelectorAll('.card-synth').forEach(initCardSynth);Goal: Create a "Synth" card.
Visual behavior: A tiny synth module: drag the two knobs to shape a live waveform on the little screen. Freq packs more cycles in; Shape morphs it smoothly from sine through triangle and square to saw.
Technique:
- Each knob is a slider: vertical drag (120px for the full range) or the arrow keys change its value, and it rotates through 270 degrees with a pointer mark.
- Every frame the screen's SVG path is rebuilt from 120 samples of the wave, scrolling sideways with time.
- Shape (0 to 1) picks a pair of neighbouring waves (sine/triangle, triangle/square, square/saw) and crossfades between them, so the morph is continuous.
- Frequency sets 1 to 7 cycles across the screen; the readouts show it in Hz and the nearest shape name.
Accessibility: Each knob is role="slider" with a label, aria-valuenow and an aria-valuetext readout; arrow keys turn it. Under prefers-reduced-motion the wave stops scrolling but still follows the knobs.
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 "Synth" card.
Visual behavior: A tiny synth module: drag the two knobs to shape a live waveform on the little screen. Freq packs more cycles in; Shape morphs it smoothly from sine through triangle and square to saw.
Technique:
- Each knob is a slider: vertical drag (120px for the full range) or the arrow keys change its value, and it rotates through 270 degrees with a pointer mark.
- Every frame the screen's SVG path is rebuilt from 120 samples of the wave, scrolling sideways with time.
- Shape (0 to 1) picks a pair of neighbouring waves (sine/triangle, triangle/square, square/saw) and crossfades between them, so the morph is continuous.
- Frequency sets 1 to 7 cycles across the screen; the readouts show it in Hz and the nearest shape name.
Accessibility: Each knob is role="slider" with a label, aria-valuenow and an aria-valuetext readout; arrow keys turn it. Under prefers-reduced-motion the wave stops scrolling but still follows the knobs.
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 tiny synth module: drag the two knobs to shape a live waveform on the little screen. Freq packs more cycles in; Shape morphs it smoothly from sine through triangle and square to saw.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, morph, continuous, card
- Techniques
- svg, raf, event position, state machine
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Each knob is role="slider" with a label, aria-valuenow and an aria-valuetext readout; arrow keys turn it. Under prefers-reduced-motion the wave stops scrolling but still follows the knobs.