Radio

99.1MHzClassic
No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/slider-radio
Source on GitHub

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.

More sliders

See all
Scissor Lift
Swing Ride
Tyre
Abacus