Tyre

No dependencies
Code format
React + Tailwind
import { useEffect, useRef, useState, type CSSProperties } from 'react';

const MIN = 10;
const MAX = 50;
const GOOD = [30, 36];
const GROUND = 128;
const R = 54;
const CX = 115;

// The tyre's outline at a pressure: round when hard; soft, it sinks onto the
// road, the bottom flattens into a contact patch and the sidewalls bulge.
function shape(psi: number) {
  const v = Math.min(1, Math.max(0, (psi - MIN) / (MAX - MIN)));
  const cy = GROUND - R * (0.7 + 0.3 * v ** 0.8);
  const bulge = (1 - v) * 0.24;
  const ring = (r: number, floor: number, k: number) =>
    Array.from({ length: 72 }, (_, i) => {
      const a = (i / 72) * 2 * Math.PI;
      const s = Math.sin(a);
      const x = CX + r * Math.cos(a) * (1 + k * bulge * Math.max(0, s) ** 3);
      const y = Math.min(cy + r * s, floor);
      return `${i ? 'L' : 'M'}${x.toFixed(1)} ${y.toFixed(1)}`;
    }).join('') + 'Z';
  return { cy, tyre: ring(R, GROUND, 1) + ring(R - 17, GROUND - 3, 0.6), tread: ring(R - 3, GROUND - 3, 1) };
}
const status = (psi: number) => (psi < 20 ? 'Flat' : psi < GOOD[0] ? 'Low' : psi <= GOOD[1] ? 'Good' : 'Firm');

const css = `
  .slider-tyre { display: grid; gap: 10px; width: 230px; }
  .slider-tyre__scene { display: block; width: 230px; height: 140px; overflow: visible; }
  .slider-tyre__road { stroke: light-dark(#cbced3, #393c41); stroke-width: 2; stroke-linecap: round; }
  .slider-tyre__shadow { fill: light-dark(#dbdde1, #121417); opacity: 0.8; filter: blur(3px); }
  .slider-tyre__rubber { fill: light-dark(#50535b, #2e3035); fill-rule: evenodd; }
  .slider-tyre__tread { fill: none; stroke: light-dark(#42454c, #232529); stroke-width: 4; stroke-dasharray: 4 3.2; }
  .slider-tyre__rim { fill: light-dark(#6d7179, #7d8087); }
  .slider-tyre__rim-edge { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 2.5; }
  .slider-tyre__spoke { stroke: light-dark(#5f626a, #a7abb2); stroke-width: 5; stroke-linecap: round; }
  .slider-tyre__hub { fill: light-dark(#50535b, #c6c8cd); }
  .slider-tyre__valve { fill: light-dark(#50535b, #c6c8cd); }
  .slider-tyre__read { display: flex; align-items: baseline; justify-content: space-between; }
  .slider-tyre__psi { color: light-dark(#373a40, #f1f2f3); font-size: 26px; font-weight: 300; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; }
  .slider-tyre__psi b { font-weight: inherit; }
  .slider-tyre__psi small { margin-left: 3px; color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; }
  .slider-tyre__state { padding: 2px 8px; border-radius: 999px; background: light-dark(#ebedf0, #232529); color: light-dark(#50535b, #c6c8cd); font-size: 11px; font-weight: 600; }
  .slider-tyre[data-good] .slider-tyre__state { background: light-dark(#373a40, #f1f2f3); color: light-dark(#f3f4f7, #1a1c1f); }
  .slider-tyre__track { position: relative; height: 20px; margin-bottom: 10px; }
  .slider-tyre__rail { position: absolute; left: 0; right: 0; top: 8px; height: 4px; border-radius: 2px; background: light-dark(#dbdde1, #2e3035); }
  /* The recommended range is marked on the track. */
  .slider-tyre__band { position: absolute; top: 8px; height: 4px; border-radius: 2px; left: calc(10px + ${(GOOD[0] - MIN) / (MAX - MIN)} * (100% - 20px)); width: calc(${(GOOD[1] - GOOD[0]) / (MAX - MIN)} * (100% - 20px)); background: light-dark(#5f626a, #a7abb2); }
  .slider-tyre__band::before, .slider-tyre__band::after { content: ''; position: absolute; top: -4px; width: 1.5px; height: 12px; border-radius: 1px; background: light-dark(#5f626a, #a7abb2); }
  .slider-tyre__band::before { left: 0; }
  .slider-tyre__band::after { right: 0; }
  .slider-tyre__band b { position: absolute; left: 50%; top: 13px; translate: -50% 0; color: light-dark(#6d7179, #7d8087); font-size: 9px; font-weight: 600; letter-spacing: 0.04em; white-space: nowrap; }
  .slider-tyre__thumb { position: absolute; top: 0; left: calc(var(--slider-tyre-v) * (100% - 20px)); width: 20px; height: 20px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.45)), inset 0 0 0 5px light-dark(#373a40, #f1f2f3), inset 0 0 0 7px light-dark(#6d7179, #7d8087); }
  .slider-tyre:has(.slider-tyre__input:focus-visible) .slider-tyre__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
  .slider-tyre__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
  .slider-tyre__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; }
  .slider-tyre__input::-moz-range-thumb { width: 20px; height: 20px; border: 0; }
`;

export function Preview() {
  const [psi, setPsi] = useState(22);
  // The drawn pressure springs after the set one, so the tyre bounces as it fills.
  const [shown, setShown] = useState(psi);
  const motion = useRef({ x: psi, v: 0 });
  useEffect(() => {
    const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    if (still) {
      motion.current = { x: psi, v: 0 };
      setShown(psi);
      return;
    }
    let raf = 0;
    const step = () => {
      const m = motion.current;
      m.v = (m.v + (psi - m.x) * 0.14) * 0.72;
      m.x += m.v;
      if (Math.abs(psi - m.x) < 0.02 && Math.abs(m.v) < 0.02) m.x = psi;
      else raf = requestAnimationFrame(step);
      setShown(m.x);
    };
    raf = requestAnimationFrame(step);
    return () => cancelAnimationFrame(raf);
  }, [psi]);
  const { cy, tyre, tread } = shape(shown);
  const v = (psi - MIN) / (MAX - MIN);
  const word = status(psi);
  return (
    <>
      <style href="kit-sliders-tyre" precedence="kit">
        {css}
      </style>
      <div className="slider-tyre" data-good={word === 'Good' ? '' : undefined} style={{ '--slider-tyre-v': v.toFixed(3) } as CSSProperties}>
        <svg className="slider-tyre__scene" viewBox="0 0 230 140" aria-hidden="true">
          <ellipse className="slider-tyre__shadow" cx={CX} cy={GROUND + 1} rx={R + 6} ry="4" />
          <path className="slider-tyre__road" d={`M8 ${GROUND} H222`} />
          <path className="slider-tyre__rubber" d={tyre} />
          <path className="slider-tyre__tread" d={tread} />
          <g className="slider-tyre__wheel" transform={`translate(${CX} ${cy.toFixed(1)})`}>
            <circle className="slider-tyre__rim" r="37.5" />
            {[0, 72, 144, 216, 288].map((a) => (
              <path key={a} className="slider-tyre__spoke" d="M0 -9 V-28" transform={`rotate(${a})`} />
            ))}
            <circle className="slider-tyre__rim-edge" r="36.5" />
            <circle className="slider-tyre__hub" r="8" />
            <rect className="slider-tyre__valve" x="22" y="-31" width="4" height="9" rx="1.5" transform="rotate(38)" />
          </g>
        </svg>
        <div className="slider-tyre__read" aria-hidden="true">
          <span className="slider-tyre__psi">
            <b>{psi}</b>
            <small>PSI</small>
          </span>
          <span className="slider-tyre__state">{word}</span>
        </div>
        <div className="slider-tyre__track">
          <span className="slider-tyre__rail" aria-hidden="true" />
          <span className="slider-tyre__band" aria-hidden="true">
            <b>Recommended</b>
          </span>
          <span className="slider-tyre__thumb" aria-hidden="true" />
          <input className="slider-tyre__input" type="range" min={MIN} max={MAX} value={psi} onChange={(e) => setPsi(+e.target.value)} aria-label="Tyre pressure" aria-valuetext={`${psi} psi, ${word.toLowerCase()}`} />
        </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-tyre
Source on GitHub

About this component

Tyre pressure: at low psi the tyre sags flat on the road and bulges, and pumping it up rounds it out with a little bounce.

Dependencies
None
Difficulty
Advanced
Triggers
state change, spring, morph
Techniques
svg, raf, spring
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A native range input named "Tyre pressure" 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 tyre changes shape without bouncing.

More sliders

See all
Abacus
ASCII
Band
Brush