Pitch
import { useState } from 'react';
// 22 periods of 44 units: enough to fill the screen at the highest pitch (a quarter of the scale) plus one to loop.
const WAVE = `M0 48 ${Array.from({ length: 44 }, (_, i) => `Q${i * 22 + 11} ${i % 2 ? 108 : -12} ${(i + 1) * 22} 48`).join(' ')}`;
const css = `
@keyframes slider-pitch-run { to { transform: translateX(-44px); } }
.slider-pitch { display: grid; gap: 12px; width: 220px; }
.slider-pitch__screen {
position: relative;
height: 96px;
overflow: hidden;
border-radius: 10px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 -1px / 100% 24px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) -1px 0 / 22px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 0 24px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5));
}
.slider-pitch__screen svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.slider-pitch__freq { transform: scaleX(calc(1 / (1 + var(--slider-pitch-v) * 3))); transition: transform 0.35s cubic-bezier(0.3, 1.2, 0.5, 1); }
.slider-pitch__line { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd)); animation: slider-pitch-run 0.8s linear infinite; }
.slider-pitch__hz { position: absolute; right: 8px; top: 6px; color: light-dark(#42454c, #e5e6e9); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; font-variant-numeric: tabular-nums; }
.slider-pitch__track { position: relative; height: 24px; }
.slider-pitch__rail { position: absolute; left: 0; right: 0; top: 10px; height: 4px; border-radius: 2px; background: repeating-linear-gradient(90deg, light-dark(#dbdde1, #2e3035) 0 2px, transparent 2px 6px); }
.slider-pitch__thumb { position: absolute; top: 2px; left: calc(var(--slider-pitch-v) * (100% - 12px)); width: 12px; height: 20px; border-radius: 4px; background: light-dark(#42454c, #e5e6e9); box-shadow: inset 0 -2px 0 light-dark(#5f626a, #a7abb2), 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.slider-pitch:has(.slider-pitch__input:focus-visible) .slider-pitch__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-pitch__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-pitch__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 20px; }
.slider-pitch__input::-moz-range-thumb { width: 12px; height: 20px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-pitch__line { animation: none; }
}
`;
export function PitchSlider() {
const [v, setV] = useState(33);
const hz = Math.round(110 * 2 ** ((v / 100) * 3));
return (
<>
<style>
{css}
</style>
<div className="slider-pitch" style={{ '--slider-pitch-v': v / 100 } as React.CSSProperties}>
<div className="slider-pitch__screen" aria-hidden="true">
<svg viewBox="0 0 220 96" preserveAspectRatio="none">
<g className="slider-pitch__freq">
<path className="slider-pitch__line" d={WAVE} />
</g>
</svg>
<span className="slider-pitch__hz">{hz} Hz</span>
</div>
<div className="slider-pitch__track">
<span className="slider-pitch__rail" />
<span className="slider-pitch__thumb" />
<input
className="slider-pitch__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Frequency"
aria-valuetext={`${hz} hertz`}
/>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderPitch(el) on the mounted element instead.
<div class="slider-pitch" style="--slider-pitch-v:0.33">
<div class="slider-pitch__screen" aria-hidden="true">
<svg viewBox="0 0 220 96" preserveAspectRatio="none">
<g class="slider-pitch__freq">
<path class="slider-pitch__line" d="M0 48 Q11 -12 22 48 Q33 108 44 48 Q55 -12 66 48 Q77 108 88 48 Q99 -12 110 48 Q121 108 132 48 Q143 -12 154 48 Q165 108 176 48 Q187 -12 198 48 Q209 108 220 48 Q231 -12 242 48 Q253 108 264 48 Q275 -12 286 48 Q297 108 308 48 Q319 -12 330 48 Q341 108 352 48 Q363 -12 374 48 Q385 108 396 48 Q407 -12 418 48 Q429 108 440 48 Q451 -12 462 48 Q473 108 484 48 Q495 -12 506 48 Q517 108 528 48 Q539 -12 550 48 Q561 108 572 48 Q583 -12 594 48 Q605 108 616 48 Q627 -12 638 48 Q649 108 660 48 Q671 -12 682 48 Q693 108 704 48 Q715 -12 726 48 Q737 108 748 48 Q759 -12 770 48 Q781 108 792 48 Q803 -12 814 48 Q825 108 836 48 Q847 -12 858 48 Q869 108 880 48 Q891 -12 902 48 Q913 108 924 48 Q935 -12 946 48 Q957 108 968 48"></path>
</g>
</svg>
<span class="slider-pitch__hz">218 Hz</span>
</div>
<div class="slider-pitch__track">
<span class="slider-pitch__rail"></span>
<span class="slider-pitch__thumb"></span>
<input class="slider-pitch__input" type="range" min="0" max="100" aria-label="Frequency" aria-valuetext="218 hertz" value="33"/>
</div>
</div>@keyframes slider-pitch-run { to { transform: translateX(-44px); } }
.slider-pitch { display: grid; gap: 12px; width: 220px; }
.slider-pitch__screen {
position: relative;
height: 96px;
overflow: hidden;
border-radius: 10px;
background:
linear-gradient(light-dark(#ebedf0, #232529) 1px, transparent 1px) 0 -1px / 100% 24px,
linear-gradient(90deg, light-dark(#ebedf0, #232529) 1px, transparent 1px) -1px 0 / 22px 100%,
light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 0 24px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5));
}
.slider-pitch__screen svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.slider-pitch__freq { transform: scaleX(calc(1 / (1 + var(--slider-pitch-v) * 3))); transition: transform 0.35s cubic-bezier(0.3, 1.2, 0.5, 1); }
.slider-pitch__line { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; vector-effect: non-scaling-stroke; filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd)); animation: slider-pitch-run 0.8s linear infinite; }
.slider-pitch__hz { position: absolute; right: 8px; top: 6px; color: light-dark(#42454c, #e5e6e9); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; font-variant-numeric: tabular-nums; }
.slider-pitch__track { position: relative; height: 24px; }
.slider-pitch__rail { position: absolute; left: 0; right: 0; top: 10px; height: 4px; border-radius: 2px; background: repeating-linear-gradient(90deg, light-dark(#dbdde1, #2e3035) 0 2px, transparent 2px 6px); }
.slider-pitch__thumb { position: absolute; top: 2px; left: calc(var(--slider-pitch-v) * (100% - 12px)); width: 12px; height: 20px; border-radius: 4px; background: light-dark(#42454c, #e5e6e9); box-shadow: inset 0 -2px 0 light-dark(#5f626a, #a7abb2), 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.slider-pitch:has(.slider-pitch__input:focus-visible) .slider-pitch__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-pitch__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-pitch__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 20px; }
.slider-pitch__input::-moz-range-thumb { width: 12px; height: 20px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-pitch__line { animation: none; }
}function initSliderPitch(root) {
var input = root.querySelector('.slider-pitch__input');
var read = root.querySelector('.slider-pitch__hz');
function update() {
var v = +input.value;
// Three octaves up from A2.
var hz = Math.round(110 * Math.pow(2, (v / 100) * 3));
root.style.setProperty('--slider-pitch-v', v / 100);
read.textContent = hz + ' Hz';
input.setAttribute('aria-valuetext', hz + ' hertz');
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-pitch').forEach(initSliderPitch);Goal: Create a "Pitch" slider.
Visual behavior: An oscilloscope frequency slider: the wave on the screen tightens and runs faster as the pitch climbs, with the reading in hertz across three octaves.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- One long SVG wave path is drawn once; the value scales its group horizontally, so more periods fit the screen as the pitch rises, and a keyframe slides it sideways one period at a time so it loops seamlessly.
- The reading is 110 times 2 to the power of three times the value, so the slider spans three octaves evenly.
- :has(input:focus-visible) outlines the drawn thumb, so keyboard focus is visible though the input itself is not.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the wave stands 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 "Pitch" slider.
Visual behavior: An oscilloscope frequency slider: the wave on the screen tightens and runs faster as the pitch climbs, with the reading in hertz across three octaves.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- One long SVG wave path is drawn once; the value scales its group horizontally, so more periods fit the screen as the pitch rises, and a keyframe slides it sideways one period at a time so it loops seamlessly.
- The reading is 110 times 2 to the power of three times the value, so the slider spans three octaves evenly.
- :has(input:focus-visible) outlines the drawn thumb, so keyboard focus is visible though the input itself is not.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the wave stands 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
An oscilloscope frequency slider: the wave on the screen tightens and runs faster as the pitch climbs, with the reading in hertz across three octaves.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, loop
- Techniques
- css variables, keyframes, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the wave stands still.