Radio
import { useState, type CSSProperties } from 'react';
// Frequencies in tenths of a megahertz, so the input can step by 0.1 MHz.
const MIN = 880;
const MAX = 1080;
const STATIONS = [
{ f: 893, name: 'Jazz' },
{ f: 947, name: 'News' },
{ f: 991, name: 'Classic' },
{ f: 1035, name: 'Talk' },
{ f: 1069, name: 'Indie' },
];
const at = (f: number) => (f - MIN) / (MAX - MIN);
// How well a frequency pulls in its nearest station: 1 dead on, 0 at 0.8 MHz away.
function tune(f: number) {
const near = STATIONS.reduce((a, s) => (Math.abs(s.f - f) < Math.abs(a.f - f) ? s : a));
return { near, p: Math.max(0, 1 - Math.abs(near.f - f) / 8) };
}
const WAVE = (() => {
let d = 'M-16 22';
for (let x = -16, s = -1; x < 110; x += 8, s = -s) d += ` C${x + 3.4} ${22 + s * 12} ${x + 4.6} ${22 + s * 12} ${x + 8} 22`;
return d;
})();
const css = `
.slider-radio { display: grid; gap: 12px; width: 230px; }
/* The dial: a lit glass scale with the needle as the handle. */
.slider-radio__dial { position: relative; height: 66px; border-radius: 12px; overflow: hidden; background: radial-gradient(circle at calc(12px + var(--slider-radio-v) * (100% - 24px)) 46%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent), transparent 64px), linear-gradient(light-dark(#ebedf0, #232529), light-dark(#f3f4f7, #1a1c1f)); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 12px 18px -12px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.6)); }
.slider-radio__scale { position: absolute; left: 12px; right: 12px; top: 0; bottom: 0; }
.slider-radio__num { position: absolute; top: 9px; translate: -50% 0; color: light-dark(#50535b, #c6c8cd); font-size: 10px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.slider-radio__tick { position: absolute; top: 27px; width: 1px; height: 6px; translate: -0.5px 0; background: light-dark(#6d7179, #7d8087); }
.slider-radio__tick--major { top: 24px; height: 9px; background: light-dark(#50535b, #c6c8cd); }
.slider-radio__station { position: absolute; top: 40px; width: 4px; height: 4px; translate: -2px 0; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); }
.slider-radio__needle { position: absolute; top: 5px; bottom: 9px; left: calc(12px + var(--slider-radio-v) * (100% - 24px)); width: 2px; translate: -1px 0; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 8px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent); }
.slider-radio__needle::after { content: ''; position: absolute; left: 50%; bottom: -6px; width: 16px; height: 9px; translate: -50% 0; border-radius: 3px 3px 4px 4px; background: linear-gradient(light-dark(#42454c, #e5e6e9), light-dark(#5f626a, #a7abb2)); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)); }
.slider-radio:has(.slider-radio__input:focus-visible) .slider-radio__dial { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-radio__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-radio__input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 66px; }
.slider-radio__input::-moz-range-thumb { width: 24px; height: 66px; border: 0; }
.slider-radio__set { display: grid; grid-template-columns: 92px 1fr 40px; align-items: center; gap: 12px; }
/* The tuning screen: static between stations, a clean wave on one. */
.slider-radio__screen { position: relative; height: 44px; overflow: hidden; border-radius: 8px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 6px 10px -6px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.5)); }
.slider-radio__static { position: absolute; inset: 0; opacity: calc((1 - var(--slider-radio-p)) * 0.9); background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 75%, transparent) 0.7px, transparent 1.1px) 0 0 / 5px 4px, radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent) 0.6px, transparent 1px) 2px 1px / 7px 6px, linear-gradient(transparent 46%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 22%, transparent) 46% 54%, transparent 54%) 0 0 / 100% 9px; animation: slider-radio-static 0.32s steps(1) infinite; }
.slider-radio__wave { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; stroke-linecap: round; opacity: var(--slider-radio-p); }
.slider-radio__wave g { transform-box: view-box; transform-origin: 0 22px; transform: scaleY(var(--slider-radio-p)); transition: transform 0.2s ease-out; }
.slider-radio__wave path { animation: slider-radio-flow 0.4s linear infinite; }
.slider-radio__read { display: grid; gap: 4px; min-width: 0; }
.slider-radio__mhz { color: light-dark(#373a40, #f1f2f3); font-size: 26px; font-weight: 300; line-height: 1; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.slider-radio__mhz b { font-weight: inherit; }
.slider-radio__mhz small { margin-left: 3px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; }
.slider-radio__name { display: flex; align-items: flex-end; gap: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; white-space: nowrap; }
.slider-radio[data-tuned] .slider-radio__name { color: light-dark(#373a40, #f1f2f3); }
.slider-radio__bars { display: flex; align-items: flex-end; gap: 2px; }
.slider-radio__bars i { width: 3px; height: calc(4px + var(--k) * 2px); border-radius: 1px; background: light-dark(#373a40, #f1f2f3); opacity: clamp(0.18, calc(var(--slider-radio-p) * 5 - var(--k)), 1); transition: opacity 0.15s; }
/* The knob turns with the dial, as on the set. */
.slider-radio__knob { width: 40px; height: 40px; border-radius: 50%; background: radial-gradient(circle at 50% 50%, light-dark(#cbced3, #393c41) 0 13px, transparent 13.5px), repeating-conic-gradient(light-dark(#6d7179, #7d8087) 0 6deg, light-dark(#dbdde1, #2e3035) 6deg 12deg); box-shadow: 0 3px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)), inset 0 0 0 1px light-dark(#dbdde1, #2e3035); rotate: calc(var(--slider-radio-v) * 540deg); transition: rotate 0.15s ease-out; }
.slider-radio__knob::after { content: ''; display: block; width: 4px; height: 4px; margin: 9px auto 0; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); }
@keyframes slider-radio-static { 0% { background-position: 0 0, 2px 1px, 0 0; } 25% { background-position: 3px 2px, 5px 4px, 0 3px; } 50% { background-position: 1px 3px, 1px 5px, 0 6px; } 75% { background-position: 4px 1px, 6px 2px, 0 1px; } }
@keyframes slider-radio-flow { to { transform: translateX(-16px); } }
@media (prefers-reduced-motion: reduce) {
.slider-radio *, .slider-radio *::after { animation: none !important; transition: none !important; }
}
`;
export function Preview() {
const [f, setF] = useState(991);
const { near, p } = tune(f);
const tuned = p > 0.7;
const mhz = (f / 10).toFixed(1);
return (
<>
<style href="kit-sliders-radio" precedence="kit">
{css}
</style>
<div className="slider-radio" data-tuned={tuned ? '' : undefined} style={{ '--slider-radio-v': at(f).toFixed(4), '--slider-radio-p': p.toFixed(3) } as CSSProperties}>
<div className="slider-radio__dial">
<span className="slider-radio__scale" aria-hidden="true">
{Array.from({ length: 21 }, (_, i) => (
<i key={i} className={`slider-radio__tick${i % 4 === 0 ? ' slider-radio__tick--major' : ''}`} style={{ left: `${(i / 20) * 100}%` }} />
))}
{[88, 92, 96, 100, 104, 108].map((n) => (
<b key={n} className="slider-radio__num" style={{ left: `${at(n * 10) * 100}%` }}>
{n}
</b>
))}
{STATIONS.map((s) => (
<i key={s.f} className="slider-radio__station" style={{ left: `${at(s.f) * 100}%` }} />
))}
</span>
<span className="slider-radio__needle" aria-hidden="true" />
<input
className="slider-radio__input"
type="range"
min={MIN}
max={MAX}
value={f}
onChange={(e) => setF(+e.target.value)}
aria-label="Tuning"
aria-valuetext={`${mhz} megahertz, ${tuned ? near.name : 'no station'}`}
/>
</div>
<div className="slider-radio__set">
<span className="slider-radio__screen" aria-hidden="true">
<span className="slider-radio__static" />
<svg className="slider-radio__wave" viewBox="0 0 92 44" preserveAspectRatio="none">
<g>
<path d={WAVE} />
</g>
</svg>
</span>
<span className="slider-radio__read">
<span className="slider-radio__mhz">
<b>{mhz}</b>
<small>MHz</small>
</span>
<span className="slider-radio__name">
<span className="slider-radio__bars" aria-hidden="true">
{[0, 1, 2, 3, 4].map((k) => (
<i key={k} style={{ '--k': k } as CSSProperties} />
))}
</span>
<span className="slider-radio__station-name">{tuned ? near.name : 'Searching'}</span>
</span>
</span>
<span className="slider-radio__knob" aria-hidden="true" />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderRadio(el) on the mounted element instead.
<div class="slider-radio" data-tuned="" style="--slider-radio-v:0.5550;--slider-radio-p:1.000">
<div class="slider-radio__dial">
<span class="slider-radio__scale" aria-hidden="true">
<i class="slider-radio__tick slider-radio__tick--major" style="left:0%"></i>
<i class="slider-radio__tick" style="left:5%"></i>
<i class="slider-radio__tick" style="left:10%"></i>
<i class="slider-radio__tick" style="left:15%"></i>
<i class="slider-radio__tick slider-radio__tick--major" style="left:20%"></i>
<i class="slider-radio__tick" style="left:25%"></i>
<i class="slider-radio__tick" style="left:30%"></i>
<i class="slider-radio__tick" style="left:35%"></i>
<i class="slider-radio__tick slider-radio__tick--major" style="left:40%"></i>
<i class="slider-radio__tick" style="left:45%"></i>
<i class="slider-radio__tick" style="left:50%"></i>
<i class="slider-radio__tick" style="left:55.00000000000001%"></i>
<i class="slider-radio__tick slider-radio__tick--major" style="left:60%"></i>
<i class="slider-radio__tick" style="left:65%"></i>
<i class="slider-radio__tick" style="left:70%"></i>
<i class="slider-radio__tick" style="left:75%"></i>
<i class="slider-radio__tick slider-radio__tick--major" style="left:80%"></i>
<i class="slider-radio__tick" style="left:85%"></i>
<i class="slider-radio__tick" style="left:90%"></i>
<i class="slider-radio__tick" style="left:95%"></i>
<i class="slider-radio__tick slider-radio__tick--major" style="left:100%"></i>
<b class="slider-radio__num" style="left:0%">88</b>
<b class="slider-radio__num" style="left:20%">92</b>
<b class="slider-radio__num" style="left:40%">96</b>
<b class="slider-radio__num" style="left:60%">100</b>
<b class="slider-radio__num" style="left:80%">104</b>
<b class="slider-radio__num" style="left:100%">108</b>
<i class="slider-radio__station" style="left:6.5%"></i>
<i class="slider-radio__station" style="left:33.5%"></i>
<i class="slider-radio__station" style="left:55.50000000000001%"></i>
<i class="slider-radio__station" style="left:77.5%"></i>
<i class="slider-radio__station" style="left:94.5%"></i>
</span>
<span class="slider-radio__needle" aria-hidden="true"></span>
<input class="slider-radio__input" type="range" min="880" max="1080" aria-label="Tuning" aria-valuetext="99.1 megahertz, Classic" value="991"/>
</div>
<div class="slider-radio__set">
<span class="slider-radio__screen" aria-hidden="true">
<span class="slider-radio__static"></span>
<svg class="slider-radio__wave" viewBox="0 0 92 44" preserveAspectRatio="none">
<g>
<path d="M-16 22 C-12.6 10 -11.4 10 -8 22 C-4.6 34 -3.4000000000000004 34 0 22 C3.4 10 4.6 10 8 22 C11.4 34 12.6 34 16 22 C19.4 10 20.6 10 24 22 C27.4 34 28.6 34 32 22 C35.4 10 36.6 10 40 22 C43.4 34 44.6 34 48 22 C51.4 10 52.6 10 56 22 C59.4 34 60.6 34 64 22 C67.4 10 68.6 10 72 22 C75.4 34 76.6 34 80 22 C83.4 10 84.6 10 88 22 C91.4 34 92.6 34 96 22 C99.4 10 100.6 10 104 22 C107.4 34 108.6 34 112 22"></path>
</g>
</svg>
</span>
<span class="slider-radio__read">
<span class="slider-radio__mhz">
<b>99.1</b>
<small>MHz</small>
</span>
<span class="slider-radio__name">
<span class="slider-radio__bars" aria-hidden="true">
<i style="--k:0"></i>
<i style="--k:1"></i>
<i style="--k:2"></i>
<i style="--k:3"></i>
<i style="--k:4"></i>
</span>
<span class="slider-radio__station-name">Classic</span>
</span>
</span>
<span class="slider-radio__knob" aria-hidden="true"></span>
</div>
</div>.slider-radio { display: grid; gap: 12px; width: 230px; }
/* The dial: a lit glass scale with the needle as the handle. */
.slider-radio__dial { position: relative; height: 66px; border-radius: 12px; overflow: hidden; background: radial-gradient(circle at calc(12px + var(--slider-radio-v) * (100% - 24px)) 46%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 12%, transparent), transparent 64px), linear-gradient(light-dark(#ebedf0, #232529), light-dark(#f3f4f7, #1a1c1f)); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 12px 18px -12px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.6)); }
.slider-radio__scale { position: absolute; left: 12px; right: 12px; top: 0; bottom: 0; }
.slider-radio__num { position: absolute; top: 9px; translate: -50% 0; color: light-dark(#50535b, #c6c8cd); font-size: 10px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.slider-radio__tick { position: absolute; top: 27px; width: 1px; height: 6px; translate: -0.5px 0; background: light-dark(#6d7179, #7d8087); }
.slider-radio__tick--major { top: 24px; height: 9px; background: light-dark(#50535b, #c6c8cd); }
.slider-radio__station { position: absolute; top: 40px; width: 4px; height: 4px; translate: -2px 0; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); }
.slider-radio__needle { position: absolute; top: 5px; bottom: 9px; left: calc(12px + var(--slider-radio-v) * (100% - 24px)); width: 2px; translate: -1px 0; border-radius: 1px; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 8px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent); }
.slider-radio__needle::after { content: ''; position: absolute; left: 50%; bottom: -6px; width: 16px; height: 9px; translate: -50% 0; border-radius: 3px 3px 4px 4px; background: linear-gradient(light-dark(#42454c, #e5e6e9), light-dark(#5f626a, #a7abb2)); box-shadow: 0 1px 2px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)); }
.slider-radio:has(.slider-radio__input:focus-visible) .slider-radio__dial { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-radio__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-radio__input::-webkit-slider-thumb { -webkit-appearance: none; width: 24px; height: 66px; }
.slider-radio__input::-moz-range-thumb { width: 24px; height: 66px; border: 0; }
.slider-radio__set { display: grid; grid-template-columns: 92px 1fr 40px; align-items: center; gap: 12px; }
/* The tuning screen: static between stations, a clean wave on one. */
.slider-radio__screen { position: relative; height: 44px; overflow: hidden; border-radius: 8px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), inset 0 6px 10px -6px light-dark(rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.5)); }
.slider-radio__static { position: absolute; inset: 0; opacity: calc((1 - var(--slider-radio-p)) * 0.9); background: radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 75%, transparent) 0.7px, transparent 1.1px) 0 0 / 5px 4px, radial-gradient(circle, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 55%, transparent) 0.6px, transparent 1px) 2px 1px / 7px 6px, linear-gradient(transparent 46%, color-mix(in oklab, light-dark(#373a40, #f1f2f3) 22%, transparent) 46% 54%, transparent 54%) 0 0 / 100% 9px; animation: slider-radio-static 0.32s steps(1) infinite; }
.slider-radio__wave { position: absolute; inset: 0; width: 100%; height: 100%; fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; stroke-linecap: round; opacity: var(--slider-radio-p); }
.slider-radio__wave g { transform-box: view-box; transform-origin: 0 22px; transform: scaleY(var(--slider-radio-p)); transition: transform 0.2s ease-out; }
.slider-radio__wave path { animation: slider-radio-flow 0.4s linear infinite; }
.slider-radio__read { display: grid; gap: 4px; min-width: 0; }
.slider-radio__mhz { color: light-dark(#373a40, #f1f2f3); font-size: 26px; font-weight: 300; line-height: 1; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; white-space: nowrap; }
.slider-radio__mhz b { font-weight: inherit; }
.slider-radio__mhz small { margin-left: 3px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; }
.slider-radio__name { display: flex; align-items: flex-end; gap: 6px; color: light-dark(#5f626a, #a7abb2); font-size: 11px; white-space: nowrap; }
.slider-radio[data-tuned] .slider-radio__name { color: light-dark(#373a40, #f1f2f3); }
.slider-radio__bars { display: flex; align-items: flex-end; gap: 2px; }
.slider-radio__bars i { width: 3px; height: calc(4px + var(--k) * 2px); border-radius: 1px; background: light-dark(#373a40, #f1f2f3); opacity: clamp(0.18, calc(var(--slider-radio-p) * 5 - var(--k)), 1); transition: opacity 0.15s; }
/* The knob turns with the dial, as on the set. */
.slider-radio__knob { width: 40px; height: 40px; border-radius: 50%; background: radial-gradient(circle at 50% 50%, light-dark(#cbced3, #393c41) 0 13px, transparent 13.5px), repeating-conic-gradient(light-dark(#6d7179, #7d8087) 0 6deg, light-dark(#dbdde1, #2e3035) 6deg 12deg); box-shadow: 0 3px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.45)), inset 0 0 0 1px light-dark(#dbdde1, #2e3035); rotate: calc(var(--slider-radio-v) * 540deg); transition: rotate 0.15s ease-out; }
.slider-radio__knob::after { content: ''; display: block; width: 4px; height: 4px; margin: 9px auto 0; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); }
@keyframes slider-radio-static { 0% { background-position: 0 0, 2px 1px, 0 0; } 25% { background-position: 3px 2px, 5px 4px, 0 3px; } 50% { background-position: 1px 3px, 1px 5px, 0 6px; } 75% { background-position: 4px 1px, 6px 2px, 0 1px; } }
@keyframes slider-radio-flow { to { transform: translateX(-16px); } }
@media (prefers-reduced-motion: reduce) {
.slider-radio *, .slider-radio *::after { animation: none !important; transition: none !important; }
}function initSliderRadio(root) {
var input = root.querySelector('.slider-radio__input');
var number = root.querySelector('.slider-radio__mhz b');
var name = root.querySelector('.slider-radio__station-name');
var stations = [[893, 'Jazz'], [947, 'News'], [991, 'Classic'], [1035, 'Talk'], [1069, 'Indie']];
var min = +input.min;
var max = +input.max;
function update() {
var f = +input.value;
// How well the nearest station comes in: 1 dead on, 0 at 0.8 MHz away.
var near = stations[0];
stations.forEach(function (s) { if (Math.abs(s[0] - f) < Math.abs(near[0] - f)) near = s; });
var p = Math.max(0, 1 - Math.abs(near[0] - f) / 8);
var tuned = p > 0.7;
var mhz = (f / 10).toFixed(1);
root.style.setProperty('--slider-radio-v', ((f - min) / (max - min)).toFixed(4));
root.style.setProperty('--slider-radio-p', p.toFixed(3));
root.toggleAttribute('data-tuned', tuned);
number.textContent = mhz;
name.textContent = tuned ? near[1] : 'Searching';
input.setAttribute('aria-valuetext', mhz + ' megahertz, ' + (tuned ? near[1] : 'no station'));
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-radio').forEach(initSliderRadio);Goal: Create a "Radio" slider.
Visual behavior: An FM tuning dial: drag the needle along the band and the screen fills with static between stations, then clears to a clean wave and the station name as you tune one in.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- The value is in tenths of a megahertz, so the dial steps by 0.1 MHz. A tuning strength from 0 to 1 is worked out from the distance to the nearest of five stations.
- That one strength fades the static out and the wave in (the wave also grows to full height), lights the signal bars one by one with clamp(), and names the station once it passes 0.7.
- The needle has a grip tab, the dial glows round it, and the knob beside the readout turns with the dial.
Accessibility: A native range input named "Tuning" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the static and the wave hold 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 "Radio" slider.
Visual behavior: An FM tuning dial: drag the needle along the band and the screen fills with static between stations, then clears to a clean wave and the station name as you tune one in.
Technique:
- One real <input type="range"> lies transparent over the track (its thumb sized to the drawn handle, so the pointer lines up), and everything drawn is set from its value.
- The value is in tenths of a megahertz, so the dial steps by 0.1 MHz. A tuning strength from 0 to 1 is worked out from the distance to the nearest of five stations.
- That one strength fades the static out and the wave in (the wave also grows to full height), lights the signal bars one by one with clamp(), and names the station once it passes 0.7.
- The needle has a grip tab, the dial glows round it, and the knob beside the readout turns with the dial.
Accessibility: A native range input named "Tuning" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the static and the wave hold 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 FM tuning dial: drag the needle along the band and the screen fills with static between stations, then clears to a clean wave and the station name as you tune one in.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous
- Techniques
- css variables, keyframes, svg, radial gradient
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input named "Tuning" with an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn handle. The drawing is aria-hidden. Under prefers-reduced-motion the static and the wave hold still.