CSS + JS Buttons & forms #controls #shape #number #form

Rolling number stepper in CSS + JavaScript

A quantity stepper whose number sits on a cube. − and + roll it a quarter turn down or up; just before each turn JS writes the new number onto the face that is about to come into view.

  • 173 lines of CSS
  • 14 lines of HTML
  • 53 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The number faces are the four sides of a cube around its X axis: face n is placed with rotateX(n × -90deg) translateZ(s / 2), so turning the cube n quarter turns forward brings face n to the front, upright.
  2. JS keeps one number, step, and sets --a: step × 90deg. It never wraps it back to 0: from 270° to 360° the cube keeps rolling the same way instead of spinning back three quarters.
  3. The face that is about to come into view is faces[step mod 4]. JS writes the new number there just before changing --a, and keeps the faces above and below holding the true neighbours, so every turn reveals the right number.
  4. The CSS transition with a little overshoot does the rolling; JS only supplies the angle and the text.
  5. At a limit the button stays focusable (aria-disabled, not disabled, so focus never vanishes) and a short nudge animation on a wrapper says “no further”. The <output> above the buttons announces the quantity.
  6. Every length is a multiple of one base unit, --u, tied to the canvas, text included, so the stepper is the same share of a gallery card, the editor and a recording canvas. JS writes only an angle and a text, never a length. The buttons are part of the model, so there is no separate control row.

Key techniques

  • --a: step × 90deg, never wrapped
  • face index = step mod 4
  • write the next face before the turn
  • restart a nudge animation at the limits

Copy-paste code

HTML 14 lines

<div class="stepper">
  <div class="view">
    <div class="nudge">
      <div class="cube" aria-hidden="true"><i>3</i><i>4</i><i></i><i>2</i><b></b><b></b></div>
    </div>
  </div>
  <div class="bar">
    <output aria-live="polite">Quantity 3</output>
    <div class="buttons">
      <button type="button" aria-label="Decrease quantity"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg></button>
      <button type="button" aria-label="Increase quantity"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg></button>
    </div>
  </div>
</div>

CSS 173 lines

.stepper {
  /* one base unit: every length below is a multiple of it, so the stepper is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.32vmin;
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  /* narrower than a square canvas (280 units is 90vmin): a box wider than the canvas does not
     centre, it hangs off the right-hand side, and on a 1:1 or 9:16 canvas the stepper sat 4vmin
     off the middle */
  width: calc(280 * var(--u));
  /* as tall as the cube and the dock need: a taller box left empty room over the cube, which the
     centred stack counts, so the drawn stepper sat 4vmin low */
  height: calc(252 * var(--u));
  padding: calc(10 * var(--u)) calc(14 * var(--u)) calc(14 * var(--u));
  box-sizing: border-box;
}

.view {
  position: relative;
  display: grid;
  place-items: center;
  perspective: calc(600 * var(--u));
}

/* a soft shadow on the floor */
.view::before {
  content: '';
  position: absolute;
  bottom: calc(50% - 78 * var(--u));
  left: calc(50% - 72 * var(--u));
  width: calc(144 * var(--u));
  height: calc(20 * var(--u));
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(0 0 0 / 0.45), transparent);
}

/* only for the "no further" nudge, so it never fights the cube's own transition */
.nudge {
  transform-style: preserve-3d;
}

.nudge.is-nudge {
  animation: nudge 0.45s ease-out;
}

@keyframes nudge {
  0%, 100% { transform: rotateX(0deg); }
  35% { transform: rotateX(calc(var(--dir, 1) * 14deg)); }
  70% { transform: rotateX(calc(var(--dir, 1) * -4deg)); }
}

.cube {
  --s: calc(110 * var(--u));
  position: relative;
  width: var(--s);
  height: var(--s);
  transform-style: preserve-3d;
  /* read right to left: turn by --a (set by JS), a fixed view from above-left,
     then pull back half a side so the front face stays its true size */
  transform: translateZ(calc(var(--s) / -2)) rotateX(-14deg) rotateY(-20deg) rotateX(var(--a, 0deg));
  transition: transform 0.6s cubic-bezier(0.3, 1.3, 0.5, 1);
}

.cube i,
.cube b {
  position: absolute;
  inset: 0;
  border-radius: calc(14 * var(--u));
  transform-style: preserve-3d;
}

/* the four number faces: front, bottom, back, top */
.cube i {
  display: grid;
  place-items: center;
  /* an inner line plus a soft shade instead of a hard border: squeezed side-on, a 1 units line
     breaks up, the shade survives */
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) color-mix(in srgb, #8b6cff, #fff 35%),
    inset 0 0 calc(8 * var(--u)) color-mix(in srgb, color-mix(in srgb, #8b6cff, #fff 35%) 35%, transparent);
  background:
    linear-gradient(150deg, rgb(255 255 255 / 0.28), transparent 45%),
    linear-gradient(color-mix(in srgb, #8b6cff, #ff4d9d 25%), color-mix(in srgb, #8b6cff, #000 25%));
  color: #fff;
  font: 900 calc(50 * var(--u))/1 Inter, system-ui, sans-serif;
  font-style: normal;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 calc(3 * var(--u)) 0 color-mix(in srgb, #8b6cff, #000 45%);
  backface-visibility: hidden; /* never show a number mirrored through the cube */
}

.cube i:nth-child(1) { transform: rotateX(0deg)    translateZ(calc(var(--s) / 2)); }
.cube i:nth-child(2) { transform: rotateX(-90deg)  translateZ(calc(var(--s) / 2)); }
.cube i:nth-child(3) { transform: rotateX(-180deg) translateZ(calc(var(--s) / 2)); }
.cube i:nth-child(4) { transform: rotateX(-270deg) translateZ(calc(var(--s) / 2)); }

/* the two ends */
.cube b {
  box-shadow:
    inset 0 0 0 calc(1 * var(--u)) color-mix(in srgb, #8b6cff, #fff 15%),
    inset 0 0 calc(8 * var(--u)) color-mix(in srgb, color-mix(in srgb, #8b6cff, #fff 15%) 35%, transparent);
  background:
    radial-gradient(circle, transparent 0 30%, rgb(0 0 0 / 0.18) 31% 34%, transparent 35%),
    color-mix(in srgb, #8b6cff, #000 38%);
}

.cube b:nth-of-type(1) { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.cube b:nth-of-type(2) { transform: rotateY(90deg)  translateZ(calc(var(--s) / 2)); }

/* rounded faces leave holes at the corners: a square plate behind each one plugs them */
.cube i::before,
.cube b::before {
  content: '';
  position: absolute;
  /* 1 units short of the edge: full size, it would touch the next face and show there as a dotted
     seam; much smaller leaves a channel along each edge you can see into */
  inset: calc(1 * var(--u));
  background: color-mix(in srgb, #8b6cff, #000 45%);
  transform: translateZ(calc(-6 * var(--u)));
}

/* the dock: the status centred on top, the buttons under it */
.bar {
  display: grid;
  justify-items: center;
  gap: calc(6 * var(--u));
  font-size: calc(12 * var(--u));
}

/* the stage's own ink, softened with opacity rather than a colour of the model's: a fixed pale
   lavender read on the dark stage and all but vanished on the light one */
.bar output {
  opacity: 0.7;
  font-variant-numeric: tabular-nums;
}

.buttons {
  display: flex;
  gap: calc(10 * var(--u));
  padding: calc(4 * var(--u));
  border: calc(1 * var(--u)) solid rgb(140 150 220 / 0.34);
  border-radius: calc(999 * var(--u));
}

.buttons button {
  display: grid;
  place-items: center;
  width: calc(32 * var(--u));
  height: calc(32 * var(--u));
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: linear-gradient(135deg, #8b6cff, #ff4d9d);
  color: #fff;
  cursor: pointer;
  transition: opacity 0.2s;
}

.buttons svg {
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
}

.buttons button:focus-visible {
  outline: calc(2 * var(--u)) solid #2ee6d6;
  outline-offset: calc(2 * var(--u));
}

/* at a limit the button stays focusable, it only looks spent */
.buttons button[aria-disabled='true'] {
  opacity: 0.4;
  cursor: not-allowed;
}

JS 53 lines

var MIN = 0;
var MAX = 10;

var cube = document.querySelector('.cube');
var nudge = document.querySelector('.nudge');
var faces = cube.querySelectorAll('i');
var out = document.querySelector('.bar output');
var buttons = document.querySelectorAll('.buttons button');
var minus = buttons[0];
var plus = buttons[1];

var value = 3;
var step = 0; // quarter turns so far; never wrapped, so the cube always turns the short way

function face(s) {
  return faces[((s % 4) + 4) % 4]; // a real modulo: -1 is face 3, not face -1
}

function label(n) {
  return n >= MIN && n <= MAX ? String(n) : ''; // blank past a limit
}

function render() {
  // the front face, and the true neighbours on the faces that can turn into view next
  face(step).textContent = label(value);
  face(step + 1).textContent = label(value + 1);
  face(step - 1).textContent = label(value - 1);
  face(step + 2).textContent = '';
  cube.style.setProperty('--a', step * 90 + 'deg'); // CSS does the turning
  var limit = value === MIN ? ' (minimum)' : value === MAX ? ' (maximum)' : '';
  out.textContent = 'Quantity ' + value + limit;
  minus.setAttribute('aria-disabled', String(value === MIN));
  plus.setAttribute('aria-disabled', String(value === MAX));
}

function change(dir) {
  var next = value + dir;
  if (next < MIN || next > MAX) {
    // at a limit: restart a short nudge in that direction instead of turning
    nudge.style.setProperty('--dir', dir);
    nudge.classList.remove('is-nudge');
    void nudge.offsetWidth;
    nudge.classList.add('is-nudge');
    return;
  }
  value = next;
  step += dir;
  render();
}

minus.addEventListener('click', function () { change(-1); });
plus.addEventListener('click', function () { change(1); });
render();
Standalone snippet · plain CSS, no build step, no dependencies.