Speedo
import { useState } from 'react';
// The dial runs from -120deg (0) to +120deg (240), centre at (100, 100).
const TICKS = Array.from({ length: 25 }, (_, i) => {
const a = ((-120 + i * 10) * Math.PI) / 180;
const r1 = i % 2 ? 78 : 72;
return { i, x1: (100 + Math.sin(a) * r1).toFixed(1), y1: (100 - Math.cos(a) * r1).toFixed(1), x2: (100 + Math.sin(a) * 84).toFixed(1), y2: (100 - Math.cos(a) * 84).toFixed(1) };
});
const NUMS = Array.from({ length: 7 }, (_, i) => {
const a = ((-120 + i * 40) * Math.PI) / 180;
return { n: i * 40, x: (100 + Math.sin(a) * 60).toFixed(1), y: (100 - Math.cos(a) * 60 + 3).toFixed(1) };
});
const css = `
.slider-speedo { position: relative; width: 210px; height: 170px; }
.slider-speedo svg { display: block; width: 210px; height: 170px; }
.slider-speedo__face { fill: light-dark(#f3f4f7, #1a1c1f); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
.slider-speedo__groove { fill: none; stroke: light-dark(#ebedf0, #232529); stroke-width: 6; stroke-linecap: round; }
.slider-speedo__arc { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 6; stroke-linecap: round; stroke-dasharray: 100 200; transition: stroke-dashoffset 0.6s cubic-bezier(0.3, 1.4, 0.5, 1); }
.slider-speedo__tick { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
.slider-speedo__tick--major { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2; }
.slider-speedo__num { fill: light-dark(#5f626a, #a7abb2); font-size: 10px; font-family: system-ui, sans-serif; text-anchor: middle; }
.slider-speedo__needle { transform-origin: 100px 100px; transform: rotate(calc(-120deg + var(--slider-speedo-v) * 240deg)); transition: transform 0.6s cubic-bezier(0.3, 1.6, 0.5, 1); }
.slider-speedo__needle path { fill: light-dark(#373a40, #f1f2f3); }
.slider-speedo__hub { fill: light-dark(#42454c, #e5e6e9); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 3; }
.slider-speedo__speed { fill: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; font-family: system-ui, sans-serif; text-anchor: middle; font-variant-numeric: tabular-nums; }
.slider-speedo__unit { fill: light-dark(#5f626a, #a7abb2); font-size: 9px; font-family: system-ui, sans-serif; text-anchor: middle; letter-spacing: 0.08em; }
.slider-speedo:has(.slider-speedo__input:focus-visible) .slider-speedo__face { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2; }
.slider-speedo__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-speedo__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 170px; }
.slider-speedo__input::-moz-range-thumb { width: 20px; height: 170px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-speedo__needle, .slider-speedo__arc { transition: none; }
}
`;
export function SpeedoSlider() {
const [v, setV] = useState(45);
const kmh = Math.round((v / 100) * 240);
return (
<>
<style>
{css}
</style>
<div className="slider-speedo" style={{ '--slider-speedo-v': v / 100 } as React.CSSProperties}>
<svg viewBox="0 0 200 162" aria-hidden="true">
<path className="slider-speedo__face" d="M20.4 150A94 94 0 1 1 179.6 150Z" />
<path className="slider-speedo__groove" d="M27.3 142A84 84 0 1 1 172.7 142" pathLength={100} />
<path className="slider-speedo__arc" d="M27.3 142A84 84 0 1 1 172.7 142" pathLength={100} style={{ strokeDashoffset: 100 - v }} />
{TICKS.map((t) => (
<line key={t.i} className={`slider-speedo__tick${t.i % 2 ? '' : ' slider-speedo__tick--major'}`} x1={t.x1} y1={t.y1} x2={t.x2} y2={t.y2} />
))}
{NUMS.map((n) => (
<text key={n.n} className="slider-speedo__num" x={n.x} y={n.y}>
{n.n}
</text>
))}
<text className="slider-speedo__speed" x="100" y="128">
{kmh}
</text>
<text className="slider-speedo__unit" x="100" y="141">
KM/H
</text>
<g className="slider-speedo__needle">
<path d="M98 100L99.2 30h1.6L102 100Z" />
</g>
<circle className="slider-speedo__hub" cx="100" cy="100" r="7" />
</svg>
<input
className="slider-speedo__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Speed limit"
aria-valuetext={`${kmh} kilometres per hour`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderSpeedo(el) on the mounted element instead.
<div class="slider-speedo" style="--slider-speedo-v:0.45">
<svg viewBox="0 0 200 162" aria-hidden="true">
<path class="slider-speedo__face" d="M20.4 150A94 94 0 1 1 179.6 150Z"></path>
<path class="slider-speedo__groove" d="M27.3 142A84 84 0 1 1 172.7 142" pathLength="100"></path>
<path class="slider-speedo__arc" d="M27.3 142A84 84 0 1 1 172.7 142" pathLength="100" style="stroke-dashoffset:55"></path>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="37.6" y1="136.0" x2="27.3" y2="142.0"></line>
<line class="slider-speedo__tick" x1="26.7" y1="126.7" x2="21.1" y2="128.7"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="29.1" y1="112.5" x2="17.3" y2="114.6"></line>
<line class="slider-speedo__tick" x1="22.0" y1="100.0" x2="16.0" y2="100.0"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="29.1" y1="87.5" x2="17.3" y2="85.4"></line>
<line class="slider-speedo__tick" x1="26.7" y1="73.3" x2="21.1" y2="71.3"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="37.6" y1="64.0" x2="27.3" y2="58.0"></line>
<line class="slider-speedo__tick" x1="40.2" y1="49.9" x2="35.7" y2="46.0"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="53.7" y1="44.8" x2="46.0" y2="35.7"></line>
<line class="slider-speedo__tick" x1="61.0" y1="32.5" x2="58.0" y2="27.3"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="75.4" y1="32.3" x2="71.3" y2="21.1"></line>
<line class="slider-speedo__tick" x1="86.5" y1="23.2" x2="85.4" y2="17.3"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="100.0" y1="28.0" x2="100.0" y2="16.0"></line>
<line class="slider-speedo__tick" x1="113.5" y1="23.2" x2="114.6" y2="17.3"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="124.6" y1="32.3" x2="128.7" y2="21.1"></line>
<line class="slider-speedo__tick" x1="139.0" y1="32.5" x2="142.0" y2="27.3"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="146.3" y1="44.8" x2="154.0" y2="35.7"></line>
<line class="slider-speedo__tick" x1="159.8" y1="49.9" x2="164.3" y2="46.0"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="162.4" y1="64.0" x2="172.7" y2="58.0"></line>
<line class="slider-speedo__tick" x1="173.3" y1="73.3" x2="178.9" y2="71.3"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="170.9" y1="87.5" x2="182.7" y2="85.4"></line>
<line class="slider-speedo__tick" x1="178.0" y1="100.0" x2="184.0" y2="100.0"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="170.9" y1="112.5" x2="182.7" y2="114.6"></line>
<line class="slider-speedo__tick" x1="173.3" y1="126.7" x2="178.9" y2="128.7"></line>
<line class="slider-speedo__tick slider-speedo__tick--major" x1="162.4" y1="136.0" x2="172.7" y2="142.0"></line>
<text class="slider-speedo__num" x="48.0" y="133.0">0</text>
<text class="slider-speedo__num" x="40.9" y="92.6">40</text>
<text class="slider-speedo__num" x="61.4" y="57.0">80</text>
<text class="slider-speedo__num" x="100.0" y="43.0">120</text>
<text class="slider-speedo__num" x="138.6" y="57.0">160</text>
<text class="slider-speedo__num" x="159.1" y="92.6">200</text>
<text class="slider-speedo__num" x="152.0" y="133.0">240</text>
<text class="slider-speedo__speed" x="100" y="128">108</text>
<text class="slider-speedo__unit" x="100" y="141">KM/H</text>
<g class="slider-speedo__needle">
<path d="M98 100L99.2 30h1.6L102 100Z"></path>
</g>
<circle class="slider-speedo__hub" cx="100" cy="100" r="7"></circle>
</svg>
<input class="slider-speedo__input" type="range" min="0" max="100" aria-label="Speed limit" aria-valuetext="108 kilometres per hour" value="45"/>
</div>.slider-speedo { position: relative; width: 210px; height: 170px; }
.slider-speedo svg { display: block; width: 210px; height: 170px; }
.slider-speedo__face { fill: light-dark(#f3f4f7, #1a1c1f); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1; }
.slider-speedo__groove { fill: none; stroke: light-dark(#ebedf0, #232529); stroke-width: 6; stroke-linecap: round; }
.slider-speedo__arc { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 6; stroke-linecap: round; stroke-dasharray: 100 200; transition: stroke-dashoffset 0.6s cubic-bezier(0.3, 1.4, 0.5, 1); }
.slider-speedo__tick { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
.slider-speedo__tick--major { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2; }
.slider-speedo__num { fill: light-dark(#5f626a, #a7abb2); font-size: 10px; font-family: system-ui, sans-serif; text-anchor: middle; }
.slider-speedo__needle { transform-origin: 100px 100px; transform: rotate(calc(-120deg + var(--slider-speedo-v) * 240deg)); transition: transform 0.6s cubic-bezier(0.3, 1.6, 0.5, 1); }
.slider-speedo__needle path { fill: light-dark(#373a40, #f1f2f3); }
.slider-speedo__hub { fill: light-dark(#42454c, #e5e6e9); stroke: light-dark(#dbdde1, #2e3035); stroke-width: 3; }
.slider-speedo__speed { fill: light-dark(#373a40, #f1f2f3); font-size: 22px; font-weight: 600; font-family: system-ui, sans-serif; text-anchor: middle; font-variant-numeric: tabular-nums; }
.slider-speedo__unit { fill: light-dark(#5f626a, #a7abb2); font-size: 9px; font-family: system-ui, sans-serif; text-anchor: middle; letter-spacing: 0.08em; }
.slider-speedo:has(.slider-speedo__input:focus-visible) .slider-speedo__face { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2; }
.slider-speedo__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-speedo__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 170px; }
.slider-speedo__input::-moz-range-thumb { width: 20px; height: 170px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-speedo__needle, .slider-speedo__arc { transition: none; }
}function initSliderSpeedo(root) {
var input = root.querySelector('.slider-speedo__input');
var arc = root.querySelector('.slider-speedo__arc');
var read = root.querySelector('.slider-speedo__speed');
function update() {
var v = +input.value;
var kmh = Math.round((v / 100) * 240);
root.style.setProperty('--slider-speedo-v', v / 100);
arc.style.strokeDashoffset = 100 - v;
read.textContent = kmh;
input.setAttribute('aria-valuetext', kmh + ' kilometres per hour');
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-speedo').forEach(initSliderSpeedo);Goal: Create a "Speedo" slider.
Visual behavior: A speed-limit slider as a car gauge: the needle sweeps the dial with a little overshoot, the lit arc follows it and the digital reading changes in km/h.
Technique:
- A real <input type="range"> covers the gauge, transparent, so dragging across it sets the speed and the arrow, Page Up/Down, Home and End keys work natively. The drawing is aria-hidden.
- The value is written to one CSS custom property (0 to 1) on the root; the needle rotates from -120 to 120 degrees on an overshooting curve, and the arc (pathLength 100) uses the value as its dash offset.
- Ticks and numbers are computed once around the dial.
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 needle and the arc move without easing.
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 "Speedo" slider.
Visual behavior: A speed-limit slider as a car gauge: the needle sweeps the dial with a little overshoot, the lit arc follows it and the digital reading changes in km/h.
Technique:
- A real <input type="range"> covers the gauge, transparent, so dragging across it sets the speed and the arrow, Page Up/Down, Home and End keys work natively. The drawing is aria-hidden.
- The value is written to one CSS custom property (0 to 1) on the root; the needle rotates from -120 to 120 degrees on an overshooting curve, and the arc (pathLength 100) uses the value as its dash offset.
- Ticks and numbers are computed once around the dial.
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 needle and the arc move without easing.
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 speed-limit slider as a car gauge: the needle sweeps the dial with a little overshoot, the lit arc follows it and the digital reading changes in km/h.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring, draw
- Techniques
- css variables, stroke dashoffset, 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 needle and the arc move without easing.