Zero
import { useRef, useState } from 'react';
const W = 240;
const T = 18;
const Y = 26;
const R = 30;
const at = (v: number) => T / 2 + ((v + R) / (2 * R)) * (W - T);
const MID = at(0);
const css = `
.slider-zero { position: relative; width: ${W}px; height: 78px; }
.slider-zero__rail, .slider-zero__fill { position: absolute; top: ${Y - 2}px; height: 4px; border-radius: 2px; }
.slider-zero__rail { left: 0; width: ${W}px; background: light-dark(#ebedf0, #232529); }
.slider-zero__fill { background: light-dark(#42454c, #e5e6e9); }
.slider-zero__tick { position: absolute; top: ${Y + 8}px; width: 1.5px; height: 5px; margin-left: -0.75px; background: light-dark(#cbced3, #393c41); }
.slider-zero__mark { position: absolute; left: ${MID - 1}px; top: ${Y - 10}px; width: 2px; height: 20px; border-radius: 1px; background: light-dark(#6d7179, #7d8087); transition: transform 0.3s cubic-bezier(0.3, 1.8, 0.5, 1), background-color 0.2s; }
.slider-zero--at .slider-zero__mark { background: light-dark(#373a40, #f1f2f3); transform: scaleY(1.4); }
.slider-zero__thumb {
position: absolute;
top: ${Y - T / 2}px;
box-sizing: border-box;
width: ${T}px;
height: ${T}px;
margin-left: ${-T / 2}px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 2px 5px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5));
transition: border-width 0.2s, background-color 0.2s;
}
.slider-zero--at .slider-zero__thumb { border: 5px solid light-dark(#373a40, #f1f2f3); background: light-dark(#fbfcfd, #121417); }
.slider-zero:has(.slider-zero__input:focus-visible) .slider-zero__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-zero__value { position: absolute; left: 0; right: 0; top: ${Y + 24}px; display: flex; justify-content: space-between; align-items: baseline; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-zero__value b { display: inline-flex; color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; perspective: 80px; }
.slider-zero__sign { display: inline-block; width: 0.7em; text-align: center; animation: slider-zero-flip 0.32s cubic-bezier(0.3, 1.4, 0.5, 1); }
@keyframes slider-zero-flip {
from { transform: rotateX(90deg); opacity: 0; }
to { transform: none; opacity: 1; }
}
.slider-zero__input { position: absolute; left: 0; top: ${Y - 18}px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-zero__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 36px; }
.slider-zero__input::-moz-range-thumb { width: ${T}px; height: 36px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-zero__mark { transition: background-color 0.2s; }
.slider-zero__sign { animation: none; }
}
`;
export function ZeroSlider() {
const [v, setV] = useState(12);
const drag = useRef(false);
const x = at(v);
const sign = v > 0 ? '+' : v < 0 ? '−' : '';
return (
<>
<style>
{css}
</style>
<div className={`slider-zero${v === 0 ? ' slider-zero--at' : ''}`}>
<span className="slider-zero__rail" aria-hidden="true" />
{[-3, -2, -1, 1, 2, 3].map((ev) => (
<span key={ev} className="slider-zero__tick" style={{ left: at(ev * 10) }} aria-hidden="true" />
))}
{/* The fill grows from the centre towards the thumb, either way. */}
<span className="slider-zero__fill" style={{ left: Math.min(x, MID), width: Math.abs(x - MID) }} aria-hidden="true" />
<span className="slider-zero__mark" aria-hidden="true" />
<span className="slider-zero__thumb" style={{ left: x }} aria-hidden="true" />
<p className="slider-zero__value" aria-hidden="true">
<span>Exposure</span>
<b>
<span key={sign} className="slider-zero__sign">
{sign}
</span>
{(Math.abs(v) / 10).toFixed(1)} EV
</b>
</p>
<input
className="slider-zero__input"
type="range"
min={-R}
max={R}
value={v}
onPointerDown={() => (drag.current = true)}
onPointerUp={() => (drag.current = false)}
onPointerCancel={() => (drag.current = false)}
// A drag sticks at zero for a moment as it crosses; the keys still step straight through.
onChange={(e) => {
const raw = +e.target.value;
setV(drag.current && Math.abs(raw) < 3 ? 0 : raw);
}}
aria-label="Exposure"
aria-valuetext={`${sign === '−' ? 'minus ' : sign ? 'plus ' : ''}${(Math.abs(v) / 10).toFixed(1)} EV`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderZero(el) on the mounted element instead.
<div class="slider-zero">
<span class="slider-zero__rail" aria-hidden="true"></span>
<span class="slider-zero__tick" style="left:9px" aria-hidden="true"></span>
<span class="slider-zero__tick" style="left:46px" aria-hidden="true"></span>
<span class="slider-zero__tick" style="left:83px" aria-hidden="true"></span>
<span class="slider-zero__tick" style="left:157px" aria-hidden="true"></span>
<span class="slider-zero__tick" style="left:194px" aria-hidden="true"></span>
<span class="slider-zero__tick" style="left:231px" aria-hidden="true"></span>
<span class="slider-zero__fill" style="left:120px;width:44.4px" aria-hidden="true"></span>
<span class="slider-zero__mark" aria-hidden="true"></span>
<span class="slider-zero__thumb" style="left:164.4px" aria-hidden="true"></span>
<p class="slider-zero__value" aria-hidden="true">
<span>Exposure</span>
<b>
<span class="slider-zero__sign">+</span>
1.2 EV
</b>
</p>
<input class="slider-zero__input" type="range" min="-30" max="30" aria-label="Exposure" aria-valuetext="plus 1.2 EV" value="12"/>
</div>.slider-zero { position: relative; width: 240px; height: 78px; }
.slider-zero__rail, .slider-zero__fill { position: absolute; top: 24px; height: 4px; border-radius: 2px; }
.slider-zero__rail { left: 0; width: 240px; background: light-dark(#ebedf0, #232529); }
.slider-zero__fill { background: light-dark(#42454c, #e5e6e9); }
.slider-zero__tick { position: absolute; top: 34px; width: 1.5px; height: 5px; margin-left: -0.75px; background: light-dark(#cbced3, #393c41); }
.slider-zero__mark { position: absolute; left: 119px; top: 16px; width: 2px; height: 20px; border-radius: 1px; background: light-dark(#6d7179, #7d8087); transition: transform 0.3s cubic-bezier(0.3, 1.8, 0.5, 1), background-color 0.2s; }
.slider-zero--at .slider-zero__mark { background: light-dark(#373a40, #f1f2f3); transform: scaleY(1.4); }
.slider-zero__thumb {
position: absolute;
top: 17px;
box-sizing: border-box;
width: 18px;
height: 18px;
margin-left: -9px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 2px 5px light-dark(rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.5));
transition: border-width 0.2s, background-color 0.2s;
}
.slider-zero--at .slider-zero__thumb { border: 5px solid light-dark(#373a40, #f1f2f3); background: light-dark(#fbfcfd, #121417); }
.slider-zero:has(.slider-zero__input:focus-visible) .slider-zero__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-zero__value { position: absolute; left: 0; right: 0; top: 50px; display: flex; justify-content: space-between; align-items: baseline; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-zero__value b { display: inline-flex; color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; perspective: 80px; }
.slider-zero__sign { display: inline-block; width: 0.7em; text-align: center; animation: slider-zero-flip 0.32s cubic-bezier(0.3, 1.4, 0.5, 1); }
@keyframes slider-zero-flip {
from { transform: rotateX(90deg); opacity: 0; }
to { transform: none; opacity: 1; }
}
.slider-zero__input { position: absolute; left: 0; top: 8px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-zero__input::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 36px; }
.slider-zero__input::-moz-range-thumb { width: 18px; height: 36px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-zero__mark { transition: background-color 0.2s; }
.slider-zero__sign { animation: none; }
}function initSliderZero(root) {
var W = 240;
var T = 18;
var R = 30;
var input = root.querySelector('.slider-zero__input');
var fill = root.querySelector('.slider-zero__fill');
var thumb = root.querySelector('.slider-zero__thumb');
var sign = root.querySelector('.slider-zero__sign');
var reading = root.querySelector('.slider-zero__value b');
var drag = false;
var mid = at(0);
function at(v) { return T / 2 + ((v + R) / (2 * R)) * (W - T); }
// A drag sticks at zero for a moment as it crosses; the keys still step straight through.
function update() {
var raw = +input.value;
if (drag && Math.abs(raw) < 3) input.value = raw = 0;
var x = at(raw);
var s = raw > 0 ? '+' : raw < 0 ? '−' : '';
root.classList.toggle('slider-zero--at', raw === 0);
// The fill grows from the centre towards the thumb, either way.
fill.style.left = Math.min(x, mid) + 'px';
fill.style.width = Math.abs(x - mid) + 'px';
thumb.style.left = x + 'px';
if (sign.textContent !== s) {
// The sign flips over when it changes: replay its keyframes.
sign.textContent = s;
sign.style.animation = 'none';
void sign.offsetWidth;
sign.style.animation = '';
}
reading.lastChild.nodeValue = (Math.abs(raw) / 10).toFixed(1) + ' EV';
input.setAttribute('aria-valuetext', (s === '+' ? 'plus ' : s ? 'minus ' : '') + (Math.abs(raw) / 10).toFixed(1) + ' EV');
}
function down() { drag = true; }
function up() { drag = false; }
input.addEventListener('pointerdown', down);
input.addEventListener('pointerup', up);
input.addEventListener('pointercancel', up);
input.addEventListener('input', update);
return function () {
input.removeEventListener('pointerdown', down);
input.removeEventListener('pointerup', up);
input.removeEventListener('pointercancel', up);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-zero').forEach(initSliderZero);Goal: Create a "Zero" slider.
Visual behavior: A bipolar exposure slider: the fill grows from the centre in either direction, the thumb sticks at zero for a moment as a drag crosses it, and the sign flips over.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The fill runs from the centre to the thumb, whichever side it is on. During a pointer drag, values within 3 of zero are written back as zero, so the thumb catches there; the arrow keys step straight through.
- At zero the centre mark grows and the thumb hollows to a ring. The sign is its own element, a plus or a true minus; when it changes it replays a keyframe that turns it in from edge-on under a small perspective.
Accessibility: A native range input with an aria-label and an aria-valuetext that reads plus or minus and the EV; keyboard focus rings the thumb. Under prefers-reduced-motion the sign changes without the flip and the mark without the overshoot.
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 "Zero" slider.
Visual behavior: A bipolar exposure slider: the fill grows from the centre in either direction, the thumb sticks at zero for a moment as a drag crosses it, and the sign flips over.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The fill runs from the centre to the thumb, whichever side it is on. During a pointer drag, values within 3 of zero are written back as zero, so the thumb catches there; the arrow keys step straight through.
- At zero the centre mark grows and the thumb hollows to a ring. The sign is its own element, a plus or a true minus; when it changes it replays a keyframe that turns it in from edge-on under a small perspective.
Accessibility: A native range input with an aria-label and an aria-valuetext that reads plus or minus and the EV; keyboard focus rings the thumb. Under prefers-reduced-motion the sign changes without the flip and the mark without the overshoot.
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 bipolar exposure slider: the fill grows from the centre in either direction, the thumb sticks at zero for a moment as a drag crosses it, and the sign flips over.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, fill, keyframe
- Techniques
- css transition, keyframes, perspective
- 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 that reads plus or minus and the EV; keyboard focus rings the thumb. Under prefers-reduced-motion the sign changes without the flip and the mark without the overshoot.