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.--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 {
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;
}
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();
@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));
}
}