Figure
import { useEffect, useRef, useState } from 'react';
const W = 240;
const css = `
.slider-figure { position: relative; width: ${W}px; height: 56px; }
.slider-figure__row { display: flex; align-items: center; gap: 8px; height: 56px; }
.slider-figure__done, .slider-figure__left { flex-basis: 0; flex-shrink: 1; border-radius: 1px; }
.slider-figure__done { height: 2px; background: light-dark(#42454c, #e5e6e9); }
.slider-figure__left { height: 1px; background: light-dark(#cbced3, #393c41); }
.slider-figure__num {
display: flex;
align-items: flex-start;
color: light-dark(#373a40, #f1f2f3);
font-size: 28px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
transition: scale 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-figure__unit { margin: 2px 0 0 1px; color: light-dark(#5f626a, #a7abb2); font-size: 13px; font-weight: 500; letter-spacing: 0; }
.slider-figure:has(.slider-figure__input:active) .slider-figure__num { scale: 1.08; }
.slider-figure:has(.slider-figure__input:focus-visible) .slider-figure__num { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 5px; border-radius: 4px; }
.slider-figure__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-figure__input::-webkit-slider-thumb { -webkit-appearance: none; width: 64px; height: 56px; }
.slider-figure__input::-moz-range-thumb { width: 64px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-figure__num { transition: none; }
}
`;
/**
* Tracks how fast the value is moving. An underdamped spring chases it, and its speed
* is the lean: it builds while you drag, and swings past upright once before settling.
*/
function useLean(target: number) {
const [lean, setLean] = useState(0);
const s = useRef({ x: target, v: 0, raf: 0 });
useEffect(() => {
const st = s.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
st.x = target;
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
st.v += (-260 * (st.x - target) - 14 * st.v) * dt;
st.x += st.v * dt;
if (Math.abs(st.x - target) < 0.01 && Math.abs(st.v) < 0.5) {
st.x = target;
st.v = 0;
setLean(0);
return;
}
setLean(Math.max(-16, Math.min(16, st.v * 0.09)));
st.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(st.raf);
st.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(st.raf);
}, [target]);
return lean;
}
export function FigureSlider() {
const [v, setV] = useState(64);
const lean = useLean(v);
return (
<>
<style>
{css}
</style>
<div className="slider-figure">
<div className="slider-figure__row" aria-hidden="true">
<span className="slider-figure__done" style={{ flexGrow: v }} />
{/* Leans forward like italic type into the direction of the drag. */}
<span className="slider-figure__num" style={{ transform: `skewX(${-lean}deg)` }}>
{v}
<span className="slider-figure__unit">%</span>
</span>
<span className="slider-figure__left" style={{ flexGrow: 100 - v }} />
</div>
<input className="slider-figure__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Opacity" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderFigure(el) on the mounted element instead.
<div class="slider-figure">
<div class="slider-figure__row" aria-hidden="true">
<span class="slider-figure__done" style="flex-grow:64"></span>
<span class="slider-figure__num" style="transform:skewX(0deg)">
64
<span class="slider-figure__unit">%</span>
</span>
<span class="slider-figure__left" style="flex-grow:36"></span>
</div>
<input class="slider-figure__input" type="range" min="0" max="100" aria-label="Opacity" aria-valuetext="64%" value="64"/>
</div>.slider-figure { position: relative; width: 240px; height: 56px; }
.slider-figure__row { display: flex; align-items: center; gap: 8px; height: 56px; }
.slider-figure__done, .slider-figure__left { flex-basis: 0; flex-shrink: 1; border-radius: 1px; }
.slider-figure__done { height: 2px; background: light-dark(#42454c, #e5e6e9); }
.slider-figure__left { height: 1px; background: light-dark(#cbced3, #393c41); }
.slider-figure__num {
display: flex;
align-items: flex-start;
color: light-dark(#373a40, #f1f2f3);
font-size: 28px;
font-weight: 600;
line-height: 1;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
transition: scale 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-figure__unit { margin: 2px 0 0 1px; color: light-dark(#5f626a, #a7abb2); font-size: 13px; font-weight: 500; letter-spacing: 0; }
.slider-figure:has(.slider-figure__input:active) .slider-figure__num { scale: 1.08; }
.slider-figure:has(.slider-figure__input:focus-visible) .slider-figure__num { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 5px; border-radius: 4px; }
.slider-figure__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-figure__input::-webkit-slider-thumb { -webkit-appearance: none; width: 64px; height: 56px; }
.slider-figure__input::-moz-range-thumb { width: 64px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-figure__num { transition: none; }
}function initSliderFigure(root) {
var input = root.querySelector('.slider-figure__input');
var done = root.querySelector('.slider-figure__done');
var left = root.querySelector('.slider-figure__left');
var num = root.querySelector('.slider-figure__num');
// An underdamped spring chases the value; its speed is the lean, which builds while you drag
// and swings past upright once before settling.
var x = +input.value;
var v = 0;
var raf = 0;
function lean(deg) { num.style.transform = 'skewX(' + -deg + 'deg)'; }
function render() {
var goal = +input.value;
done.style.flexGrow = goal;
left.style.flexGrow = 100 - goal;
num.firstChild.nodeValue = goal;
input.setAttribute('aria-valuetext', goal + '%');
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
x = goal;
return;
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-260 * (x - goal) - 14 * v) * dt;
x += v * dt;
if (Math.abs(x - goal) < 0.01 && Math.abs(v) < 0.5) {
x = goal;
v = 0;
return lean(0);
}
lean(Math.max(-16, Math.min(16, v * 0.09)));
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', render);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', render);
};
}
document.querySelectorAll('.slider-figure').forEach(initSliderFigure);Goal: Create a "Figure" slider.
Visual behavior: A slider whose number is the thumb: the figure rides a hairline that parts around it and leans into the drag like italic type, then rights itself with a small swing when you let go.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area about as wide as the figure, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The row is a flex line: a filled hairline growing by the value, the figure, and an empty hairline growing by the rest, so the figure travels the track with gaps either side.
- An underdamped spring chases the value; its speed, clamped, is a skewX on the figure, which builds while you drag and swings past upright once before settling. Pressing scales the figure up a little.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the figure. Under prefers-reduced-motion the figure never leans.
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 "Figure" slider.
Visual behavior: A slider whose number is the thumb: the figure rides a hairline that parts around it and leans into the drag like italic type, then rights itself with a small swing when you let go.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area about as wide as the figure, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The row is a flex line: a filled hairline growing by the value, the figure, and an empty hairline growing by the rest, so the figure travels the track with gaps either side.
- An underdamped spring chases the value; its speed, clamped, is a skewX on the figure, which builds while you drag and swings past upright once before settling. Pressing scales the figure up a little.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the figure. Under prefers-reduced-motion the figure never leans.
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 slider whose number is the thumb: the figure rides a hairline that parts around it and leans into the drag like italic type, then rights itself with a small swing when you let go.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- state change, continuous, spring
- Techniques
- spring, raf, 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 percent; keyboard focus rings the figure. Under prefers-reduced-motion the figure never leans.