Back to the library
Detent dial select, sprung
Variant: Spring (overshoot)
The same selector knob, but each turn swings about 9° past the click position and springs back, like a sprung ball catching its notch. Use it when you want the change to feel physical and a little playful, such as a hardware-style control or onboarding demo. The overshoot is safe because the knob only rotates inside its own recess, so nothing can clip, and the file opts into overshoot through its knobs meta tag.
dialstate-changerotationspringtactile
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" />
<meta name="animation-knobs" content="overshoot: ok" />
<title>Detent dial select — spring</title>
<style>
: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; /* include ONLY if at least one border-radius is wrapped below */
--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
(#d97757 / #b85c3e / #788c5d / #b04a3f). */
--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));
/* Clock — every duration in the document runs off --t. */
--cycle: 3.6s; /* the authored master loop */
--t: calc(var(--cycle) / var(--speed));
--ease-accent: cubic-bezier(0.34, 1.56, 0.64, 1); /* the snippet's ONE characterful curve, if it has one */
--_s: min(210px, 72vmin);
--_loop: var(--t);
--_shade: color-mix(in oklab, var(--slate) 22%, transparent);
--_shade-soft: color-mix(in oklab, var(--slate) 9%, transparent);
--_knurl: color-mix(in oklab, var(--slate) 76%, var(--oat));
--_glow: color-mix(in oklab, var(--clay) 55%, transparent);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
color: var(--slate);
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
.plate {
position: relative;
width: var(--_s);
aspect-ratio: 1;
background: var(--paper);
border: 1px solid var(--oat);
border-radius: calc(28px * var(--round));
box-shadow: 0 1px 0 var(--paper) inset, 0 12px 28px var(--_shade-soft), 0 2px 4px var(--_shade-soft);
}
.well {
position: absolute;
inset: 0;
margin: auto;
width: 50%;
height: 50%;
border-radius: 50%;
background: var(--ivory);
box-shadow: inset 0 2px 5px var(--_shade-soft), inset 0 0 0 1px var(--oat);
}
/* The drop shadow sits on a static layer so it doesn't turn with the knob. */
.well::before,
.knob {
content: "";
position: absolute;
inset: 0;
margin: auto;
width: 84%;
height: 84%;
border-radius: 50%;
}
.well::before {
box-shadow: 0 6px 10px var(--_shade), 0 1px 2px var(--_shade);
}
.knob {
background: repeating-conic-gradient(var(--_knurl) 0deg 5deg, var(--slate) 5deg 10deg);
animation: turn var(--_loop) var(--ease-accent) infinite;
}
.knob::before {
content: "";
position: absolute;
inset: 13%;
border-radius: 50%;
background: radial-gradient(circle, var(--paper) 0 45%, var(--oat) 100%);
box-shadow: 0 0 0 1px var(--slate);
}
.pointer {
position: absolute;
left: 50%;
top: 19%;
width: 6%;
height: 22%;
margin-left: -3%;
border-radius: 999px;
background: var(--clay);
}
.stop {
position: absolute;
left: 50%;
top: 50%;
transform: rotate(var(--_a)) translateY(calc(var(--_s) * -0.38)) rotate(calc(var(--_a) * -1));
}
.mark {
position: absolute;
left: 0;
top: 0;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.led {
position: relative;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--oat);
box-shadow: inset 0 1px 1px var(--_shade-soft);
}
.led::after {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
background: var(--clay);
box-shadow: 0 0 6px var(--_glow);
opacity: 0;
animation: led var(--_loop) ease infinite;
animation-delay: calc(var(--_loop) * (var(--_i) * 0.25 - 1));
}
.label {
font-size: clamp(11px, calc(var(--_s) * 0.062), 13px);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
line-height: 1;
opacity: 0.45;
animation: label var(--_loop) ease infinite;
animation-delay: calc(var(--_loop) * (var(--_i) * 0.25 - 1));
}
/* Each quarter: hold 17%, turn 8% (~288ms at speed 1) into the next detent.
The accent curve peaks ~10% past 1, so each 90deg turn swings ~9deg over. */
@keyframes turn {
0%, 17% { transform: rotate(0deg); }
25%, 42% { transform: rotate(90deg); }
50%, 67% { transform: rotate(180deg); }
75%, 92% { transform: rotate(270deg); }
100% { transform: rotate(360deg); }
}
/* The LED lights just after the knob lands and dims as it leaves. */
@keyframes led {
0% { opacity: 0; }
4%, 17% { opacity: 1; }
21%, 100% { opacity: 0; }
}
@keyframes label {
0% { opacity: 0.45; }
4%, 17% { opacity: 1; }
21%, 100% { opacity: 0.45; }
}
@keyframes led-calm {
0% { opacity: 0.2; }
8%, 17% { opacity: 0.9; }
25%, 100% { opacity: 0.2; }
}
@keyframes label-calm {
0% { opacity: 0.5; }
8%, 17% { opacity: 1; }
25%, 100% { opacity: 0.5; }
}
@media (max-width: 380px) {
:root { --_s: min(200px, 74vmin); }
}
@media (prefers-reduced-motion: reduce) {
:root { --_loop: calc(var(--t) * 2); }
.knob { animation: none; }
.pointer { opacity: 0; }
.led::after { animation-name: led-calm; }
.label { animation-name: label-calm; }
}
</style>
</head>
<body>
<div class="plate" role="img" aria-label="Speed dial stepping through Off, Low, Mid and High">
<div class="well">
<div class="knob"><span class="pointer"></span></div>
</div>
<div class="stop" style="--_i: 0; --_a: 0deg"><div class="mark"><span class="led"></span><span class="label">Off</span></div></div>
<div class="stop" style="--_i: 1; --_a: 90deg"><div class="mark"><span class="led"></span><span class="label">Low</span></div></div>
<div class="stop" style="--_i: 2; --_a: 180deg"><div class="mark"><span class="led"></span><span class="label">Mid</span></div></div>
<div class="stop" style="--_i: 3; --_a: 270deg"><div class="mark"><span class="led"></span><span class="label">High</span></div></div>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.