Barrel roll number counter: digits glide between anchored slots
A live count up/count down number where every digit is a ten-numeral barrel.
<main class="demo">
<header class="intro">
<h1>Barrel roll counter</h1>
<p>
Every digit column is a drum of ten numerals. Underneath, each column is a grid of invisible slots, and
the slot a numeral sits in is just its distance from the current digit. Each visible numeral is
<code>data-⚓️-fill</code> on its slot with <code>data-⚓️-transition="glide"</code>, so when the number changes the
slots reflow <em>instantly</em> and the numerals glide, roll and fade into their new places, up and down or left and right.
Add a digit and the whole row reflows: the commas, the numerals and the backing lens (anchored to the row) all glide to the new layout.
The script only writes the number and each slot's offset.
</p>
</header>
<form class="controls" aria-label="Counter controls" onsubmit="return false">
<label class="field">
<span>Number</span>
<input id="num" type="number" inputmode="numeric" min="0" max="99999999" step="1" value="1234">
</label>
<div class="steps" role="group" aria-label="Step the number">
<button type="button" data-step="-100">−100</button>
<button type="button" data-step="-10">−10</button>
<button type="button" data-step="-1">−1</button>
<button type="button" data-step="1">+1</button>
<button type="button" data-step="10">+10</button>
<button type="button" data-step="100">+100</button>
</div>
<button type="button" id="random">Random</button>
<button type="button" id="live" aria-pressed="false">Live</button>
<fieldset class="segmented">
<legend>Roll direction</legend>
<label><input type="radio" name="axis" value="y" checked> Up / down</label>
<label><input type="radio" name="axis" value="x"> Left / right</label>
</fieldset>
<label class="xray-toggle"><input type="checkbox" name="xray"> X-ray (show the slots)</label>
</form>
<div class="stage" data-⚓️-root role="img" aria-label="1,234">
<div class="lens" aria-hidden="true"
data-⚓️-to="--digits" data-⚓️-fill data-⚓️-transition="glide"></div>
<div class="cols" data-⚓️-name="--digits"></div>
</div>
</main>/* Demo styling only. Every numeral's position AND size come from data-⚓️-fill on its slot, and the
glide comes from data-⚓️-transition. This file sets looks, the slot grid, and the barrel curve
(translate / tilt / fade as a function of --r, a slot's offset from the current digit). */
:root {
color-scheme: dark;
--bg: oklch(16% 0.012 270);
--ink: oklch(94% 0.01 270);
--muted: oklch(70% 0.015 270);
--line: oklch(32% 0.02 270);
--accent: oklch(82% 0.13 190);
--dur: 0.55s;
--ease: cubic-bezier(0.3, 1.15, 0.4, 1);
background: var(--bg);
color: var(--ink);
font: 400 1rem/1.5 system-ui, sans-serif;
}
body { margin: 0; }
.demo { max-width: 60rem; margin-inline: auto; padding: clamp(1rem, 4vw, 2.5rem); display: grid; gap: 1.25rem; }
.intro h1 { margin: 0 0 0.35rem; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.01em; }
.intro p { margin: 0; color: var(--muted); max-width: 70ch; text-wrap: pretty; }
code { font: 0.88em ui-monospace, monospace; color: var(--accent); white-space: nowrap; }
/* ---------- controls ---------- */
.controls { display: flex; flex-wrap: wrap; align-items: end; gap: 0.75rem 1rem; }
.field { display: grid; gap: 0.2rem; font-size: 0.78rem; color: var(--muted); }
.field input {
inline-size: 9.5rem; box-sizing: border-box; padding: 0.4rem 0.6rem; border-radius: 0.6rem;
border: 1px solid var(--line); background: oklch(20% 0.014 270); color: var(--ink);
font: 600 1rem ui-monospace, monospace;
}
.steps { display: flex; gap: 0.3rem; }
.controls button {
padding: 0.4rem 0.75rem; border-radius: 0.6rem; border: 1px solid var(--line);
background: oklch(22% 0.015 270); color: var(--ink); font: 500 0.875rem ui-monospace, monospace; cursor: pointer;
}
.controls button:hover { background: oklch(28% 0.02 270); }
.controls button:active { translate: 0 1px; }
.controls button[aria-pressed="true"] { background: var(--accent); color: oklch(18% 0.02 270); border-color: transparent; }
.controls :is(button, input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.segmented { display: flex; border: 0; padding: 3px; margin: 0; border-radius: 999px; background: oklch(24% 0.015 270); }
.segmented legend { position: absolute; clip-path: inset(50%); inline-size: 1px; block-size: 1px; overflow: hidden; }
.segmented label { padding: 0.35rem 0.9rem; border-radius: 999px; font-size: 0.875rem; cursor: pointer; color: var(--muted); transition: background-color 0.2s, color 0.2s; }
.segmented input { position: absolute; opacity: 0; pointer-events: none; }
.segmented label:has(:checked) { background: var(--ink); color: oklch(18% 0.01 270); }
.segmented label:has(:focus-visible), .xray-toggle:has(:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
.xray-toggle { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.875rem; color: var(--muted); cursor: pointer; padding-block: 0.4rem; }
.xray-toggle input { accent-color: var(--accent); }
/* ---------- slot geometry: one pitch per axis ---------- */
.stage {
/* em units resolve where they're used; every part of the counter shares this font-size */
font-size: clamp(2.6rem, 11vw, 4.75rem);
font-variant-numeric: tabular-nums;
--sw: 0.72em; /* slot width */
--sh: 1.1em; /* slot height */
--cw: var(--sw); /* column width */
--sepw: 0.34em;
--fade: 0.42; /* opacity lost per slot away from the centre */
display: grid;
place-items: center;
block-size: 4.7em;
overflow: clip;
border: 1px solid var(--line);
border-radius: 1rem;
background: oklch(13% 0.012 270);
mask-image: linear-gradient(to bottom, transparent, #000 22%, #000 78%, transparent);
}
body:has([name="axis"][value="x"]:checked) .stage {
--sw: 0.9em;
--cw: 2.1em;
--sepw: 0.5em;
--fade: 0.72;
mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent);
}
/* the row: instant layout only */
.cols { display: flex; flex-direction: row-reverse; align-items: center; padding: 0.04em 0.3em; }
.col { inline-size: var(--cw); block-size: var(--sh); }
.col.leaving, .sep.leaving { inline-size: 0; }
.sep { --r: 0; inline-size: var(--sepw); block-size: var(--sh); }
/* the drum: ten slots, the current digit's slot (r = 0) sits on the column box, the rest overflow */
.reel {
display: grid;
grid-template: repeat(10, var(--sh)) / var(--sw);
margin-block-start: calc(var(--sh) * -5);
}
.slot { grid-row: calc(var(--r) + 6); }
body:has([name="axis"][value="x"]:checked) .reel {
grid-template: var(--sh) / repeat(10, var(--sw));
margin-block-start: 0;
margin-inline-start: calc((var(--cw) - var(--sw)) / 2 - var(--sw) * 5);
}
body:has([name="axis"][value="x"]:checked) .slot { grid-row: 1; grid-column: calc(var(--r) + 6); }
/* ---------- the visible numerals ---------- */
.glyph {
display: grid;
place-content: center;
line-height: 1;
font-weight: 700;
color: var(--ink);
/* the drum: positions are squeezed toward the edges (R*sin(a) - r*pitch, R = pitch/36deg) and each face leans away.
These can't live in custom properties on .stage: var(--r) only exists on the slots. */
translate: 0 calc(var(--sh) * (1.5915 * sin(var(--r) * 36deg) - var(--r)));
transform: perspective(7em) rotateX(calc(var(--r) * -36deg));
opacity: max(0, 1 - abs(var(--r)) * var(--fade));
pointer-events: none;
}
.sep .glyph { color: var(--muted); }
.leaving .glyph { opacity: 0; }
@starting-style { .glyph { opacity: 0; } }
/* the library's glide only moves geometry; the drum also rolls, so extend it (and honour reduced motion) */
@media (prefers-reduced-motion: no-preference) {
.glyph {
transition:
inset var(--dur) var(--ease), width var(--dur) var(--ease), height var(--dur) var(--ease),
translate var(--dur) var(--ease), transform var(--dur) var(--ease), opacity calc(var(--dur) * 0.7) ease;
}
}
/* the backing lens is anchored to the whole row, so it glides when a digit is added or dropped */
.lens {
border-radius: 0.28em;
background: linear-gradient(oklch(26% 0.03 230 / 0.55), oklch(20% 0.02 250 / 0.55));
box-shadow: inset 0 0 0 1px oklch(55% 0.06 220 / 0.35), 0 0 2em -0.5em oklch(75% 0.13 200 / 0.25);
}
body:has([name="axis"][value="x"]:checked) .glyph {
translate: calc(var(--sw) * (1.5915 * sin(var(--r) * 36deg) - var(--r))) 0;
transform: perspective(7em) rotateY(calc(var(--r) * 36deg));
}
/* ---------- X-ray: show the slots under the numerals ---------- */
body:has([name="xray"]:checked) {
& .slot { outline: 1px dashed oklch(80% 0.14 190 / 0.8); outline-offset: -1px; }
& .slot::after { content: attr(data-k); position: absolute; font: 0.14em ui-monospace, monospace; color: oklch(85% 0.14 190); }
& .glyph { color: oklch(98% 0.01 270 / 0.75); }
& .col, & .cols { outline: 1px solid oklch(70% 0.14 20 / 0.7); }
}// The only state is the number. Per digit place there are ten slots in a grid; slot k's offset --r from
// the current digit decides which grid track it occupies, so changing the digit reflows the slots instantly.
// Each numeral is data-⚓️-fill on its own slot with glide, so it travels to wherever its slot went.
const MAX = 99_999_999;
const cols = document.querySelector(".cols");
const stage = document.querySelector(".stage");
const input = document.getElementById("num");
const liveBtn = document.getElementById("live");
const places = new Map(); // place -> { col, sep, slots, leaving, timer }
let value = 1234;
const slotName = (p, k) => `--s${p}x${k}`;
const wrap = (k, d) => ((((k - d + 5) % 10) + 10) % 10) - 5; // -5..4: the short way round the drum
function create(p) {
const hasSep = p > 0 && p % 3 === 0;
// innerHTML, not setAttribute: the ⚓️ attribute names aren't valid XML Names
cols.insertAdjacentHTML("beforeend",
(hasSep
? `<span class="sep" data-sep="${p}" data-⚓️-name="--sep${p}">
<span class="glyph" aria-hidden="true" data-⚓️-to="--sep${p}" data-⚓️-fill data-⚓️-transition="glide">,</span>
</span>`
: "") +
`<div class="col" data-place="${p}"><div class="reel">${
Array.from({ length: 10 }, (_, k) =>
`<span class="slot" data-k="${k}" data-⚓️-name="${slotName(p, k)}">
<span class="glyph" aria-hidden="true" data-⚓️-to="${slotName(p, k)}" data-⚓️-fill data-⚓️-transition="glide">${k}</span>
</span>`).join("")
}</div></div>`);
const col = cols.querySelector(`[data-place="${p}"]`);
const rec = {
col,
sep: hasSep ? cols.querySelector(`[data-sep="${p}"]`) : null,
slots: [...col.querySelectorAll(".slot")],
leaving: false,
timer: 0,
};
places.set(p, rec);
return rec;
}
function setLeaving(rec, leaving) {
rec.leaving = leaving;
rec.col.classList.toggle("leaving", leaving);
rec.sep?.classList.toggle("leaving", leaving);
}
function render() {
const n = String(value).length;
for (let p = 0; p < n; p++) {
const rec = places.get(p) ?? create(p);
if (rec.leaving) { clearTimeout(rec.timer); setLeaving(rec, false); }
const d = Math.floor(value / 10 ** p) % 10;
rec.slots.forEach((slot, k) => {
const r = wrap(k, d);
slot.style.setProperty("--r", r);
slot.toggleAttribute("data-near", Math.abs(r) <= 2);
});
}
for (const [p, rec] of places) {
if (p < n || rec.leaving) continue;
setLeaving(rec, true);
// let the numerals fade out while the row has already closed up, then drop the markup
rec.timer = setTimeout(() => {
rec.col.remove(); rec.sep?.remove(); places.delete(p);
}, 700);
}
const text = value.toLocaleString("en-US");
stage.setAttribute("aria-label", text);
if (document.activeElement !== input) input.value = value;
}
function set(v) {
value = Math.min(MAX, Math.max(0, Math.round(v)));
render();
}
input.addEventListener("input", () => { if (input.value !== "") set(+input.value); });
input.addEventListener("blur", () => { input.value = value; });
document.querySelector(".steps").addEventListener("click", (e) => {
const b = e.target.closest("[data-step]");
if (b) set(value + +b.dataset.step);
});
document.getElementById("random").addEventListener("click", () => {
// a random number with a random digit count, so places appear and disappear too
const digits = 1 + Math.floor(Math.random() * 7);
set(Math.floor(Math.random() * 10 ** digits));
});
// Live: a ticker that mostly counts one way, sometimes turns around, sometimes jumps
let timer = 0;
let dir = 1;
function tick() {
if (Math.random() < 0.18) dir = -dir;
const step = [1, 1, 1, 2, 3, 7, 12, 48, 130][Math.floor(Math.random() * 9)];
if ((dir > 0 && value + step > MAX) || (dir < 0 && value - step < 0)) dir = -dir;
set(value + dir * step);
}
liveBtn.addEventListener("click", () => {
const on = liveBtn.getAttribute("aria-pressed") !== "true";
liveBtn.setAttribute("aria-pressed", on);
clearInterval(timer);
if (on) { tick(); timer = setInterval(tick, 650); }
});
render();data-⚓️-filldata-⚓️-namedata-⚓️-rootdata-⚓️-todata-⚓️-transition
Archive an email: the row's height glides shut and the rest slide up →