Value input — slider with detents
Variant: Slider detents
Two drags released five points apart that land twenty-five apart, because the detent midpoint at 62.5 decides: released at 60 the thumb falls back to 50, released at 65 it carries on to 75. The build is one registered number — thumb travel, fill width and readout are all functions of it, so no pair of them can drift a frame apart. The drag is eased because a hand leaves rest and stops; the capture is the file’s one characterful curve and begins only after the grip dot has gone, since release comes first and the mechanism answers. Watch the 50 mark blip mid-drag: a detent is felt on the way past, not only landed on.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Value input — slider with detents</title>
<style>
/* One registered number is the value. The thumb's travel, the fill's
width, the readout and the chip all derive from it, so no pair of
them can ever disagree by a frame. --_p carries the smooth geometry,
--_v the rounded readout; both ride the same keyframes. */
@property --_p {
syntax: '<number>';
inherits: true;
initial-value: 25;
}
@property --_v {
syntax: '<integer>';
inherits: true;
initial-value: 25;
}
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--round: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
--_muted: color-mix(in srgb, var(--slate) 52%, transparent);
--_tick: color-mix(in srgb, var(--slate) 26%, transparent);
/* Clock — 5.6s: two drags, two releases, and the argument between
them. Released at 60 the thumb falls back to 50; released at 65 it
carries on to 75. The detent midpoint at 62.5 decides, which is why
the two releases sit five points apart and land twenty-five apart. */
--cycle: 5.6s;
--t: calc(var(--cycle) / var(--speed));
--_w: min(228px, 74vw);
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* The detent's pull. It starts from rest — the hand had already
stopped — so it takes hold gently for the first millimetre, then
moves decisively and lands dead. */
--ease-accent: cubic-bezier(0.5, 0, 0.15, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.rig {
display: grid;
gap: 14px;
justify-items: center;
animation: rig-cycle var(--t) linear infinite;
}
.cap {
font-size: 11px;
letter-spacing: 0.04em;
color: var(--_muted);
}
.foot {
font-size: 11px;
letter-spacing: 0.02em;
color: var(--_muted);
opacity: 0;
animation: foot-in var(--t) linear infinite;
}
.box {
position: relative;
width: var(--_w);
height: 62px;
animation: value var(--t) infinite;
}
.rail {
position: absolute;
left: 0;
right: 0;
top: 45px;
height: 4px;
border-radius: 2px;
background: color-mix(in srgb, var(--oat) 55%, transparent);
overflow: hidden;
}
.fill {
position: absolute;
inset: 0;
background: var(--clay);
transform-origin: left center;
transform: scaleX(calc(var(--_p) / 100));
}
/* Detents are marks below the rail, not on it — the thumb has to be
able to sit between them for the release to mean anything. */
.tick {
position: absolute;
top: 53px;
width: 2px;
height: 6px;
border-radius: 1px;
background: var(--_tick);
}
.tick.p0 {
left: -1px;
}
.tick.p25 {
left: calc(var(--_w) * 0.25 - 1px);
animation: t25 var(--t) infinite;
}
.tick.p50 {
left: calc(var(--_w) * 0.5 - 1px);
animation: t50 var(--t) infinite;
}
.tick.p75 {
left: calc(var(--_w) * 0.75 - 1px);
animation: t75 var(--t) infinite;
}
.tick.p100 {
right: -1px;
}
/* Thumb, chip and readout are one carrier riding one derived
translate — there is no second timeline for the chip to lag on. */
.carrier {
position: absolute;
left: 0;
top: 34px;
width: 0;
height: 26px;
transform: translateX(calc(var(--_w) * var(--_p) / 100));
}
.thumb {
position: absolute;
left: -10px;
top: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--paper);
border: 1.5px solid var(--oat);
box-shadow: 0 2px 6px color-mix(in srgb, var(--slate) 16%, transparent);
}
.chip {
position: absolute;
left: 0;
top: -32px;
transform: translateX(-50%);
padding: 2px 7px;
border-radius: calc(7px * var(--round));
background: var(--paper);
border: 1px solid var(--oat);
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.chip::before {
counter-reset: v var(--_v);
content: counter(v);
}
.ghost {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 18px;
opacity: 0;
animation:
hand-move var(--t) infinite,
hand-in var(--t) linear infinite;
}
.arrow {
display: block;
width: 100%;
height: 100%;
fill: var(--slate);
stroke: var(--ivory);
stroke-width: 1.4;
stroke-linejoin: round;
}
.tip {
position: absolute;
left: -9px;
top: -9px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--clay-d);
opacity: 0;
transform: scale(0.5);
animation: grip var(--t) infinite;
}
/* Drags are eased because the hand leaves rest and stops; captures are
the detent's own curve. The capture starts only after the grip dot
has gone — release first, then the mechanism answers. */
@keyframes value {
0%,
8% {
--_p: 25;
--_v: 25;
animation-timing-function: var(--ease-in-out);
}
22%,
23% {
--_p: 60;
--_v: 60;
animation-timing-function: var(--ease-accent);
}
28.5%,
44% {
--_p: 50;
--_v: 50;
animation-timing-function: var(--ease-in-out);
}
54%,
55% {
--_p: 65;
--_v: 65;
animation-timing-function: var(--ease-accent);
}
60.5%,
96% {
--_p: 75;
--_v: 75;
}
98%,
100% {
--_p: 25;
--_v: 25;
}
}
/* The hand runs the drag keyframes on the drag's own curve, so thumb
and pointer are pixel-identical while gripped — and after each
release the hand holds still while the thumb leaves without it,
which is the frame that shows who is in charge. */
@keyframes hand-move {
0%,
3% {
transform: translate(calc(var(--_w) * 0.42 + 26px), 96px);
animation-timing-function: var(--ease-out);
}
8% {
transform: translate(calc(var(--_w) * 0.25), 47px);
animation-timing-function: var(--ease-in-out);
}
22%,
42% {
transform: translate(calc(var(--_w) * 0.6), 47px);
animation-timing-function: var(--ease-out);
}
44% {
transform: translate(calc(var(--_w) * 0.5), 47px);
animation-timing-function: var(--ease-in-out);
}
54%,
58% {
transform: translate(calc(var(--_w) * 0.65), 47px);
animation-timing-function: var(--ease-out);
}
64%,
100% {
transform: translate(calc(var(--_w) * 0.65 + 20px), 78px);
}
}
@keyframes hand-in {
0%,
3% {
opacity: 0;
}
8%,
56% {
opacity: 1;
}
63%,
100% {
opacity: 0;
}
}
@keyframes grip {
0%,
7% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
8%,
21.5% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
23%,
43% {
opacity: 0;
transform: scale(0.5);
animation-timing-function: var(--ease-out);
}
44%,
53% {
opacity: 0.4;
transform: scale(1);
animation-timing-function: var(--ease-out);
}
55%,
100% {
opacity: 0;
transform: scale(0.5);
}
}
/* The resting detent goes out the moment the drag starts: a lit mark
under a moving thumb would claim a value the control no longer holds. */
@keyframes t25 {
0%,
7.5% {
background: var(--clay-d);
}
8.5%,
100% {
background: var(--_tick);
}
}
/* The blip at 16.5% is the thumb dragging THROUGH the 50 detent on the
way to 60 — a detent is felt on the way past, not only landed on. */
@keyframes t50 {
0%,
16% {
background: var(--_tick);
}
16.5%,
17% {
background: var(--clay);
}
18%,
28.3% {
background: var(--_tick);
}
28.6%,
43.6% {
background: var(--clay-d);
}
44.4%,
100% {
background: var(--_tick);
}
}
@keyframes t75 {
0%,
60.3% {
background: var(--_tick);
}
60.6%,
94% {
background: var(--clay-d);
}
95%,
100% {
background: var(--_tick);
}
}
@keyframes foot-in {
0%,
61% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
64%,
90% {
opacity: 1;
}
94%,
100% {
opacity: 0;
}
}
/* The thumb re-arms at 98%, inside the dark window, so it is never
seen sliding home from 75. */
@keyframes rig-cycle {
0% {
opacity: 0;
}
3%,
90% {
opacity: 1;
}
95%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The travel goes and the detent stays: the thumb is parked on 50
and the landed mark breathes so the control still reads as live. */
.rig {
animation: none;
}
.box {
animation: none;
--_p: 50;
--_v: 50;
}
.ghost {
animation: none;
}
.foot {
animation: none;
opacity: 1;
}
.tick.p25,
.tick.p75 {
animation: none;
}
.tick.p50 {
animation: t50-rest calc(var(--t) * 0.8) ease infinite;
}
@keyframes t50-rest {
0%,
8% {
background: color-mix(in srgb, var(--clay-d) 55%, var(--_tick));
}
40%,
60% {
background: var(--clay-d);
}
92%,
100% {
background: color-mix(in srgb, var(--clay-d) 55%, var(--_tick));
}
}
}
</style>
</head>
<body>
<div
class="rig"
role="img"
aria-label="A slider dragged twice and released between detents: released at 60 the thumb falls back to the 50 mark, released at 65 it carries on to 75"
>
<p class="cap">drag · release · detent</p>
<div class="box">
<div class="rail"><i class="fill"></i></div>
<i class="tick p0"></i>
<i class="tick p25"></i>
<i class="tick p50"></i>
<i class="tick p75"></i>
<i class="tick p100"></i>
<div class="carrier">
<span class="chip"></span>
<span class="thumb"></span>
</div>
<span class="ghost">
<span class="tip"></span>
<svg class="arrow" viewBox="0 0 14 18" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1 L1 14.8 L4.5 11.6 L6.7 16.6 L9.3 15.4 L7.1 10.5 L11.8 10.3 Z" />
</svg>
</span>
</div>
<p class="foot">released at 60 and 65 — landed on 50 and 75</p>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.