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.

  • 155 lines of CSS
  • 14 lines of HTML
  • 53 lines of JS
  • No dependencies
  • MIT licensed

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.

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 155 lines

.stepper {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  width: 340px;
  height: 260px;
  padding: 10px 14px 14px;
  box-sizing: border-box;
}

.view {
  position: relative;
  display: grid;
  place-items: center;
  perspective: 600px;
}

/* a soft shadow on the floor */
.view::before {
  content: '';
  position: absolute;
  bottom: calc(50% - 74px);
  left: calc(50% - 62px);
  width: 124px;
  height: 20px;
  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: 94px;
  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: 14px;
  transform-style: preserve-3d;
}

/* the four number faces: front, bottom, back, top */
.cube i {
  display: grid;
  place-items: center;
  border: 1px solid color-mix(in srgb, #8b6cff, #fff 35%);
  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 50px/1 system-ui, sans-serif;
  font-style: normal;
  font-variant-numeric: tabular-nums;
  text-shadow: 0 3px 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 {
  border: 1px solid color-mix(in srgb, #8b6cff, #fff 15%);
  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;
  inset: 0; /* full size: a smaller plate leaves a channel along each edge you can see into */
  background: color-mix(in srgb, #8b6cff, #000 45%);
  transform: translateZ(-6px);
}

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

.bar output {
  color: #949bc0;
  font-variant-numeric: tabular-nums;
}

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

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

.buttons svg {
  width: 16px;
  height: 16px;
}

.buttons button:focus-visible {
  outline: 2px solid #2ee6d6;
  outline-offset: 2px;
}

/* 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();

Sass source 167 lines

@use '../mixins' as *;

// A quantity stepper: the number is on a cube, the − / + buttons in the dock turn it.
.d-stepper {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  width: 100%;
  height: 100%;
  padding: 10px 14px 14px; // the control dock: the same place in every demo with controls

  &__view {
    position: relative;
    display: grid;
    place-items: center;
    min-height: 0;
    perspective: 600px;

    // a soft shadow on the floor, drawn before the cube so it is always underneath
    &::before {
      content: '';
      position: absolute;
      bottom: calc(50% - 74px);
      left: calc(50% - 62px);
      width: 124px;
      height: 20px;
      border-radius: 50%;
      background: radial-gradient(closest-side, rgb(0 0 0 / 0.45), transparent);
    }
  }

  // Only here for the "can't go further" nudge, so it never fights the cube's own transition.
  &__nudge {
    transform-style: preserve-3d;

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

  &__cube {
    --s: 94px;
    position: relative;
    width: var(--s);
    height: var(--s);
    transform-style: preserve-3d;
    // Read right to left: turn around X by --a (JS only ever adds or subtracts 90° and never
    // wraps back to 0, so the cube always takes the short way), a fixed view from above and a
    // little to the left, then pull back by half a side so the front 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);

    // the four number faces around the X axis: front, bottom, back, top
    i,
    b {
      position: absolute;
      inset: 0;
      border-radius: 14px;
    }

    i {
      display: grid;
      place-items: center;
      border: 1px solid color-mix(in srgb, var(--accent), #fff 35%);
      background:
        linear-gradient(150deg, rgb(255 255 255 / 0.28), transparent 45%),
        linear-gradient(color-mix(in srgb, var(--accent), var(--hot) 25%), color-mix(in srgb, var(--accent), #000 25%));
      color: #fff;
      font: 900 50px/1 var(--font);
      font-style: normal;
      font-variant-numeric: tabular-nums;
      text-shadow: 0 3px 0 color-mix(in srgb, var(--accent), #000 45%);
      backface-visibility: hidden; // a number must never show mirrored through the cube
      @include solid-core(6px, color-mix(in srgb, var(--accent), #000 45%));
    }

    @for $n from 0 through 3 {
      i:nth-child(#{$n + 1}) {
        transform: rotateX($n * -90deg) translateZ(calc(var(--s) / 2));
      }
    }

    // the two ends: darker, with a groove, like the ends of a barrel
    b {
      border: 1px solid color-mix(in srgb, var(--accent), #fff 15%);
      background:
        radial-gradient(circle, transparent 0 30%, rgb(0 0 0 / 0.18) 31% 34%, transparent 35%),
        color-mix(in srgb, var(--accent), #000 38%);
      @include solid-core(6px, color-mix(in srgb, var(--accent), #000 45%));
    }

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

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

  // the site's control pattern: the status centred on top, the buttons centred under it
  &__bar {
    display: grid;
    justify-items: center;
    gap: 6px;
    font-size: 12px;

    output {
      color: var(--muted);
      font-variant-numeric: tabular-nums;
    }
  }

  &__buttons {
    display: flex;
    gap: 10px;
    padding: 4px;
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    background: color-mix(in srgb, var(--bg) 70%, transparent);

    button {
      display: grid;
      place-items: center;
      width: 32px;
      height: 32px;
      padding: 0;
      border: 0;
      border-radius: 50%;
      background: linear-gradient(135deg, var(--accent), var(--hot));
      color: #fff;
      cursor: pointer;
      transition: opacity 0.2s;

      svg {
        width: 16px;
        height: 16px;
      }

      &:focus-visible {
        outline: 2px solid var(--accent-2);
        outline-offset: 2px;
      }

      // at a limit the button stays focusable (focus must not vanish), it just looks spent
      &[aria-disabled='true'] {
        opacity: 0.4;
        cursor: not-allowed;
      }
    }
  }
}

// "no further": a small tip in the direction asked for, and back
@keyframes d-stepper-nudge {
  0%,
  100% {
    transform: rotateX(0deg);
  }

  35% {
    transform: rotateX(calc(var(--dir, 1) * 14deg));
  }

  70% {
    transform: rotateX(calc(var(--dir, 1) * -4deg));
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.