Back to the library
Detent dial select
Variant: Weighted (ease-in-out)
A knurled rotary knob on a faceplate steps through Off, Low, Mid and High, and an LED by each label lights when the knob lands on it. Use it for mode or level changes where the control feels like hardware, such as fan speed, quality presets or sync frequency. The knob is moving on screen, not entering, so each 90° turn uses a strong ease-in-out: it starts with some weight, speeds up through the travel and settles into the click position in under 300ms.
dialstate-changerotationphysicalselector
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>Detent dial select — weighted</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-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* include only if used */
--_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-in-out) 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. */
@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.