Magnetic button
CSS + JSInside its field the button shifts and leans toward the pointer while its label floats higher, so the two slide apart. JS only reports the pointer position; release it and a springy transition takes it home.
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.
Your edited version
rotateX(n × -90deg) translateZ(s / 2), so turning the cube n quarter turns forward brings face n to the front, upright.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.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.transition with a little overshoot does the rolling; JS only supplies the angle and the text.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.--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.--a: step × 90deg, never wrappedface index = step mod 4write the next face before the turnrestart a nudge animation at the limits<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>
.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;
}
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();