Dial
import { useState } from 'react';
const LEDS = Array.from({ length: 21 }, (_, k) => k);
const css = `
.slider-dial { display: grid; justify-items: center; gap: 8px; }
.slider-dial__ring { position: relative; width: 150px; height: 150px; }
.slider-dial__led {
--on: clamp(0, calc((var(--slider-dial-v) * 20 - var(--k) + 0.5) * 4), 1);
position: absolute;
left: 50%;
top: 50%;
width: 5px;
height: 5px;
margin: -2.5px;
border-radius: 50%;
background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) calc(var(--on) * 100%), light-dark(#ebedf0, #232529));
box-shadow: 0 0 calc(var(--on) * 6px) color-mix(in oklab, light-dark(#373a40, #f1f2f3) calc(var(--on) * 60%), transparent);
transform: rotate(calc(-135deg + var(--k) * 13.5deg)) translateY(-68px);
transition: background-color 0.15s, box-shadow 0.15s;
}
.slider-dial__knob {
position: absolute;
inset: 27px;
border-radius: 50%;
background: radial-gradient(circle at 50% 30%, light-dark(#6d7179, #7d8087), light-dark(#dbdde1, #2e3035) 70%);
box-shadow: inset 0 1px 0 light-dark(#5f626a, #a7abb2), 0 0 0 4px light-dark(#f3f4f7, #1a1c1f), 0 0 0 5px light-dark(#dbdde1, #2e3035), 0 10px 18px -6px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
transform: rotate(calc(-135deg + var(--slider-dial-v) * 270deg));
transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.5, 1);
}
.slider-dial__knob::before { content: ''; position: absolute; left: 50%; top: 8px; width: 4px; height: 18px; margin-left: -2px; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); }
.slider-dial__knob::after { content: ''; position: absolute; inset: 16px; border-radius: 50%; background: repeating-conic-gradient(from 0deg, light-dark(#cbced3, #393c41) 0 4deg, light-dark(#dbdde1, #2e3035) 4deg 8deg); opacity: 0.5; }
.slider-dial__label { color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; letter-spacing: 0.12em; }
.slider-dial__value { color: light-dark(#373a40, #f1f2f3); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; font-variant-numeric: tabular-nums; }
.slider-dial:has(.slider-dial__input:focus-visible) .slider-dial__knob { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 8px; }
.slider-dial__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-dial__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 150px; }
.slider-dial__input::-moz-range-thumb { width: 20px; height: 150px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-dial__knob { transition: none; }
}
`;
export function DialSlider() {
const [v, setV] = useState(60);
return (
<>
<style>
{css}
</style>
<div className="slider-dial" style={{ '--slider-dial-v': v / 100 } as React.CSSProperties}>
<span className="slider-dial__label" aria-hidden="true">
GAIN
</span>
<span className="slider-dial__ring">
{LEDS.map((k) => (
<span key={k} className="slider-dial__led" style={{ '--k': k } as React.CSSProperties} aria-hidden="true" />
))}
<span className="slider-dial__knob" aria-hidden="true" />
<input className="slider-dial__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Gain" aria-valuetext={(v / 10).toFixed(1)} />
</span>
<span className="slider-dial__value" aria-hidden="true">
{(v / 10).toFixed(1)}
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderDial(el) on the mounted element instead.
<div class="slider-dial" style="--slider-dial-v:0.6">
<span class="slider-dial__label" aria-hidden="true">GAIN</span>
<span class="slider-dial__ring">
<span class="slider-dial__led" style="--k:0" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:1" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:2" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:3" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:4" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:5" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:6" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:7" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:8" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:9" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:10" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:11" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:12" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:13" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:14" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:15" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:16" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:17" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:18" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:19" aria-hidden="true"></span>
<span class="slider-dial__led" style="--k:20" aria-hidden="true"></span>
<span class="slider-dial__knob" aria-hidden="true"></span>
<input class="slider-dial__input" type="range" min="0" max="100" aria-label="Gain" aria-valuetext="6.0" value="60"/>
</span>
<span class="slider-dial__value" aria-hidden="true">6.0</span>
</div>.slider-dial { display: grid; justify-items: center; gap: 8px; }
.slider-dial__ring { position: relative; width: 150px; height: 150px; }
.slider-dial__led {
--on: clamp(0, calc((var(--slider-dial-v) * 20 - var(--k) + 0.5) * 4), 1);
position: absolute;
left: 50%;
top: 50%;
width: 5px;
height: 5px;
margin: -2.5px;
border-radius: 50%;
background: color-mix(in oklab, light-dark(#373a40, #f1f2f3) calc(var(--on) * 100%), light-dark(#ebedf0, #232529));
box-shadow: 0 0 calc(var(--on) * 6px) color-mix(in oklab, light-dark(#373a40, #f1f2f3) calc(var(--on) * 60%), transparent);
transform: rotate(calc(-135deg + var(--k) * 13.5deg)) translateY(-68px);
transition: background-color 0.15s, box-shadow 0.15s;
}
.slider-dial__knob {
position: absolute;
inset: 27px;
border-radius: 50%;
background: radial-gradient(circle at 50% 30%, light-dark(#6d7179, #7d8087), light-dark(#dbdde1, #2e3035) 70%);
box-shadow: inset 0 1px 0 light-dark(#5f626a, #a7abb2), 0 0 0 4px light-dark(#f3f4f7, #1a1c1f), 0 0 0 5px light-dark(#dbdde1, #2e3035), 0 10px 18px -6px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.6));
transform: rotate(calc(-135deg + var(--slider-dial-v) * 270deg));
transition: transform 0.25s cubic-bezier(0.3, 1.4, 0.5, 1);
}
.slider-dial__knob::before { content: ''; position: absolute; left: 50%; top: 8px; width: 4px; height: 18px; margin-left: -2px; border-radius: 2px; background: light-dark(#373a40, #f1f2f3); }
.slider-dial__knob::after { content: ''; position: absolute; inset: 16px; border-radius: 50%; background: repeating-conic-gradient(from 0deg, light-dark(#cbced3, #393c41) 0 4deg, light-dark(#dbdde1, #2e3035) 4deg 8deg); opacity: 0.5; }
.slider-dial__label { color: light-dark(#5f626a, #a7abb2); font-size: 10px; font-weight: 600; letter-spacing: 0.12em; }
.slider-dial__value { color: light-dark(#373a40, #f1f2f3); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; font-variant-numeric: tabular-nums; }
.slider-dial:has(.slider-dial__input:focus-visible) .slider-dial__knob { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 8px; }
.slider-dial__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: ew-resize; -webkit-appearance: none; appearance: none; background: none; }
.slider-dial__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 150px; }
.slider-dial__input::-moz-range-thumb { width: 20px; height: 150px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-dial__knob { transition: none; }
}function initSliderDial(root) {
var input = root.querySelector('.slider-dial__input');
var read = root.querySelector('.slider-dial__value');
function update() {
var v = +input.value;
var gain = (v / 10).toFixed(1);
root.style.setProperty('--slider-dial-v', v / 100);
read.textContent = gain;
input.setAttribute('aria-valuetext', gain);
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-dial').forEach(initSliderDial);Goal: Create a "Dial" slider.
Visual behavior: A gain knob: drag sideways or use the arrow keys and the knob turns through its sweep while a ring of lights fills in around it.
Technique:
- A real <input type="range"> covers the knob, transparent, so dragging sideways turns it 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 knob rotates from -135 to 135 degrees, and each of 21 lights around it carries its index and glows once the value passes it.
- The reading 0.0 to 10.0 is shown below and is the aria-valuetext.
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 knob turns 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 "Dial" slider.
Visual behavior: A gain knob: drag sideways or use the arrow keys and the knob turns through its sweep while a ring of lights fills in around it.
Technique:
- A real <input type="range"> covers the knob, transparent, so dragging sideways turns it 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 knob rotates from -135 to 135 degrees, and each of 21 lights around it carries its index and glows once the value passes it.
- The reading 0.0 to 10.0 is shown below and is the aria-valuetext.
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 knob turns 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 gain knob: drag sideways or use the arrow keys and the knob turns through its sweep while a ring of lights fills in around it.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, glow, fill
- Techniques
- css variables, conic gradient, css transition
- 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 knob turns without easing.