Depth
import { useEffect, useRef, useState } from 'react';
const W = 240;
const T = 18;
const Y = 20;
const LABELS = Array.from({ length: 11 }, (_, i) => i * 10);
const at = (v: number) => T / 2 + (v / 100) * (W - T);
const css = `
.slider-depth { position: relative; width: ${W}px; height: 86px; }
.slider-depth__rail, .slider-depth__fill { position: absolute; left: 0; top: ${Y - 1.5}px; height: 3px; border-radius: 2px; }
.slider-depth__rail { width: ${W}px; background: light-dark(#ebedf0, #232529); }
.slider-depth__fill { background: light-dark(#50535b, #c6c8cd); }
.slider-depth__num { position: absolute; top: ${Y + 16}px; font-size: 12px; font-weight: 500; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.slider-depth__af { position: absolute; top: ${Y - T / 2}px; width: ${T}px; height: ${T}px; margin-left: ${-T / 2}px; overflow: visible; }
.slider-depth__corner { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; stroke-linecap: round; }
.slider-depth__dot { fill: light-dark(#373a40, #f1f2f3); }
.slider-depth:has(.slider-depth__input:focus-visible) .slider-depth__af { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 3px; }
.slider-depth__value { position: absolute; left: 0; right: 0; top: ${Y + 46}px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-depth__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-depth__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-depth__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 36px; }
.slider-depth__input::-moz-range-thumb { width: ${T}px; height: 36px; border: 0; }
`;
export function DepthSlider() {
const [v, setV] = useState(40);
const [fx, setFx] = useState(at(40));
const st = useRef({ x: at(40), v: 0, raf: 0 });
// The plane of focus trails the thumb on a soft spring and hunts past it a little, like a lens.
useEffect(() => {
const q = st.current;
const goal = at(v);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.x = goal;
setFx(goal);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-90 * (q.x - goal) - 12 * q.v) * dt;
q.x += q.v * dt;
if (Math.abs(q.x - goal) < 0.05 && Math.abs(q.v) < 0.5) {
q.x = goal;
q.v = 0;
setFx(goal);
return;
}
setFx(q.x);
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [v]);
const x = at(v);
return (
<>
<style>
{css}
</style>
<div className="slider-depth">
<span className="slider-depth__rail" aria-hidden="true" />
<span className="slider-depth__fill" style={{ width: x }} aria-hidden="true" />
{LABELS.map((n) => {
const dist = Math.abs(at(n) - fx);
return (
<span
key={n}
className="slider-depth__num"
style={{
left: at(n),
transform: `translateX(-50%) scale(${1.25 - Math.min(0.4, dist / 110)})`,
filter: `blur(${Math.min(3.2, (dist / 22) ** 1.3 * 0.9).toFixed(2)}px)`,
opacity: 1 - Math.min(0.55, dist / 140),
}}
aria-hidden="true"
>
{n}
</span>
);
})}
<svg className="slider-depth__af" style={{ left: x }} viewBox="0 0 18 18" aria-hidden="true">
<path className="slider-depth__corner" d="M1 6V1h5M12 1h5v5M17 12v5h-5M6 17H1v-5" />
<circle className="slider-depth__dot" cx={9} cy={9} r={2} />
</svg>
<p className="slider-depth__value" aria-hidden="true">
<span>Focus</span>
<b>{v}%</b>
</p>
<input className="slider-depth__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Focus" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderDepth(el) on the mounted element instead.
<div class="slider-depth">
<span class="slider-depth__rail" aria-hidden="true"></span>
<span class="slider-depth__fill" style="width:97.8px" aria-hidden="true"></span>
<span class="slider-depth__num" style="left:9px;transform:translateX(-50%) scale(0.85);filter:blur(3.20px);opacity:0.45" aria-hidden="true">0</span>
<span class="slider-depth__num" style="left:31.2px;transform:translateX(-50%) scale(0.85);filter:blur(3.20px);opacity:0.524" aria-hidden="true">10</span>
<span class="slider-depth__num" style="left:53.4px;transform:translateX(-50%) scale(0.85);filter:blur(2.24px);opacity:0.683" aria-hidden="true">20</span>
<span class="slider-depth__num" style="left:75.6px;transform:translateX(-50%) scale(1.048);filter:blur(0.91px);opacity:0.841" aria-hidden="true">30</span>
<span class="slider-depth__num" style="left:97.8px;transform:translateX(-50%) scale(1.25);filter:blur(0.00px);opacity:1" aria-hidden="true">40</span>
<span class="slider-depth__num" style="left:120px;transform:translateX(-50%) scale(1.048);filter:blur(0.91px);opacity:0.841" aria-hidden="true">50</span>
<span class="slider-depth__num" style="left:142.2px;transform:translateX(-50%) scale(0.85);filter:blur(2.24px);opacity:0.683" aria-hidden="true">60</span>
<span class="slider-depth__num" style="left:164.4px;transform:translateX(-50%) scale(0.85);filter:blur(3.20px);opacity:0.524" aria-hidden="true">70</span>
<span class="slider-depth__num" style="left:186.6px;transform:translateX(-50%) scale(0.85);filter:blur(3.20px);opacity:0.45" aria-hidden="true">80</span>
<span class="slider-depth__num" style="left:208.8px;transform:translateX(-50%) scale(0.85);filter:blur(3.20px);opacity:0.45" aria-hidden="true">90</span>
<span class="slider-depth__num" style="left:231px;transform:translateX(-50%) scale(0.85);filter:blur(3.20px);opacity:0.45" aria-hidden="true">100</span>
<svg class="slider-depth__af" style="left:97.8px" viewBox="0 0 18 18" aria-hidden="true">
<path class="slider-depth__corner" d="M1 6V1h5M12 1h5v5M17 12v5h-5M6 17H1v-5"></path>
<circle class="slider-depth__dot" cx="9" cy="9" r="2"></circle>
</svg>
<p class="slider-depth__value" aria-hidden="true">
<span>Focus</span>
<b>40%</b>
</p>
<input class="slider-depth__input" type="range" min="0" max="100" aria-label="Focus" aria-valuetext="40%" value="40"/>
</div>.slider-depth { position: relative; width: 240px; height: 86px; }
.slider-depth__rail, .slider-depth__fill { position: absolute; left: 0; top: 18.5px; height: 3px; border-radius: 2px; }
.slider-depth__rail { width: 240px; background: light-dark(#ebedf0, #232529); }
.slider-depth__fill { background: light-dark(#50535b, #c6c8cd); }
.slider-depth__num { position: absolute; top: 36px; font-size: 12px; font-weight: 500; color: light-dark(#373a40, #f1f2f3); font-variant-numeric: tabular-nums; white-space: nowrap; }
.slider-depth__af { position: absolute; top: 11px; width: 18px; height: 18px; margin-left: -9px; overflow: visible; }
.slider-depth__corner { fill: none; stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2; stroke-linecap: round; }
.slider-depth__dot { fill: light-dark(#373a40, #f1f2f3); }
.slider-depth:has(.slider-depth__input:focus-visible) .slider-depth__af { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 4px; border-radius: 3px; }
.slider-depth__value { position: absolute; left: 0; right: 0; top: 66px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-depth__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-depth__input { position: absolute; left: 0; top: 2px; width: 100%; height: 36px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-depth__input::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 36px; }
.slider-depth__input::-moz-range-thumb { width: 18px; height: 36px; border: 0; }function initSliderDepth(root) {
var W = 240;
var T = 18;
var input = root.querySelector('.slider-depth__input');
var fill = root.querySelector('.slider-depth__fill');
var af = root.querySelector('.slider-depth__af');
var nums = root.querySelectorAll('.slider-depth__num');
var label = root.querySelector('.slider-depth__value b');
var fx = at(+input.value);
var v = 0;
var raf = 0;
function at(n) { return T / 2 + (n / 100) * (W - T); }
// Numbers near the plane of focus are sharp and large; further off they shrink, fade and blur.
function draw() {
nums.forEach(function (el, i) {
var dist = Math.abs(at(i * 10) - fx);
el.style.transform = 'translateX(-50%) scale(' + (1.25 - Math.min(0.4, dist / 110)) + ')';
el.style.filter = 'blur(' + Math.min(3.2, Math.pow(dist / 22, 1.3) * 0.9).toFixed(2) + 'px)';
el.style.opacity = 1 - Math.min(0.55, dist / 140);
});
}
// The plane of focus trails the thumb on a soft spring and hunts past it a little, like a lens.
function run() {
cancelAnimationFrame(raf);
var goal = at(+input.value);
fill.style.width = goal + 'px';
af.style.left = goal + 'px';
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
fx = goal;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-90 * (fx - goal) - 12 * v) * dt;
fx += v * dt;
if (Math.abs(fx - goal) < 0.05 && Math.abs(v) < 0.5) {
fx = goal;
v = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', run);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.slider-depth').forEach(initSliderDepth);Goal: Create a "Depth" slider.
Visual behavior: The numbers along the scale are sharp near the thumb and fall out of focus further away, and the plane of focus trails the thumb and hunts past it a little, like a lens racking focus.
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.
- Each number takes a blur, a scale and an opacity from its distance to the plane of focus: the blur grows faster than linearly and caps at about 3px. The plane of focus follows the thumb on a soft, underdamped spring, so it lags a drag and overshoots slightly before settling.
- The thumb is an autofocus bracket: four corners and a dot.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the bracket. Under prefers-reduced-motion focus follows the thumb at once.
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 "Depth" slider.
Visual behavior: The numbers along the scale are sharp near the thumb and fall out of focus further away, and the plane of focus trails the thumb and hunts past it a little, like a lens racking focus.
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.
- Each number takes a blur, a scale and an opacity from its distance to the plane of focus: the blur grows faster than linearly and caps at about 3px. The plane of focus follows the thumb on a soft, underdamped spring, so it lags a drag and overshoots slightly before settling.
- The thumb is an autofocus bracket: four corners and a dot.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the bracket. Under prefers-reduced-motion focus follows the thumb at once.
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
The numbers along the scale are sharp near the thumb and fall out of focus further away, and the plane of focus trails the thumb and hunts past it a little, like a lens racking focus.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring, continuous
- Techniques
- filter, spring, raf
- 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 percent; keyboard focus rings the bracket. Under prefers-reduced-motion focus follows the thumb at once.