Phone mockup
Pure CSSA phone with real thickness: screen, back, four thin walls and a stack of rounded slabs that fills the corners. It turns to show both sides.
Drag sideways to spin the speaker; let go and it coasts to a stop. JS only tracks one angle and one colour, both handed to CSS as custom properties.
Your edited version
pointerdown it captures the pointer, each pointermove adds the sideways delta to the angle and writes it as --ry; CSS does the rotating.requestAnimationFrame loop that loses 6% per frame and stops itself once the speed is tiny: inertia without a loop running forever.touch-action: pan-y gives sideways drags to the viewer and leaves vertical swipes to the page. The swatches just set --c, and every face mixes its colour from it.--u, tied to the canvas: it is 86 × 124 × 72 of them, so it is the same share of a gallery card, the editor and a recording canvas. The swatches are in plain vmin, the same size in every model.pointer capture + drag delta → --ryinertia rAF that stops when slowcolour swatches set --ctouch-action: pan-y<div class="viewer">
<div class="view" tabindex="0" aria-label="Speaker. Drag sideways or use the arrow keys to rotate it">
<div class="product">
<i class="f front"><b></b><b></b></i>
<i class="f back"></i>
<i class="f left"></i>
<i class="f right"></i>
<i class="f top"></i>
<i class="f bottom"></i>
<i class="shadow"></i>
</div>
</div>
<div class="controls">
<output class="caption">drag to rotate</output>
<div class="row">
<button type="button" data-c="#8b6cff" style="--sw:#8b6cff" aria-label="Violet" aria-pressed="true"></button>
<button type="button" data-c="#2ee6d6" style="--sw:#2ee6d6" aria-label="Teal" aria-pressed="false"></button>
<button type="button" data-c="#ff4d9d" style="--sw:#ff4d9d" aria-label="Pink" aria-pressed="false"></button>
</div>
</div>
</div>
.viewer {
/* one base unit: every length below is a multiple of it, so the speaker is the same share of
a card, the editor, a full screen and a recording canvas. The control row under it is in
plain vmin, because it is the same object in every model. */
--u: 0.22vmin;
--c: #8b6cff;
display: grid;
justify-items: center;
gap: 4vmin; /* the band's gap between the model and the control zone */
font-family: Inter, system-ui, sans-serif;
}
/* the model box: the same height in every model that has controls, so the zone below it lands
in the same place whatever the model is */
.view {
box-sizing: border-box;
display: grid;
place-items: center;
height: 44vmin;
/* the round shadow hangs below the box: the room for it keeps the speaker centred */
padding-bottom: calc(26 * var(--u));
border-radius: calc(14 * var(--u));
outline-offset: calc(-4 * var(--u));
perspective: calc(1200 * var(--u)); /* a camera well back, so the verticals stay upright */
cursor: grab;
user-select: none;
touch-action: pan-y; /* sideways drags are ours, vertical ones scroll the page */
}
.view.is-dragging {
cursor: grabbing;
}
/* 86 units wide, 124 tall, 72 deep. No transition: JS sets a new angle every frame */
.product {
position: relative;
width: calc(86 * var(--u));
height: calc(124 * var(--u));
pointer-events: none;
transform-style: preserve-3d;
transform: rotateX(-14deg) rotateY(var(--ry, -32deg));
}
.f {
position: absolute;
box-sizing: border-box;
border: calc(1 * var(--u)) solid color-mix(in srgb, var(--c) 55%, #fff);
/* a transparent line outside the border: the turned face's hard outer edge stays invisible,
so the light edge line is smoothed instead of stair-stepping */
outline: calc(1 * var(--u)) solid transparent;
/* woven fabric: a fine dot grid over a light falloff */
background:
radial-gradient(rgb(0 0 0 / 0.22) calc(0.7 * var(--u)), transparent calc(1.2 * var(--u))) 0 0 / calc(4 * var(--u)) calc(4 * var(--u)),
linear-gradient(color-mix(in srgb, var(--c) 85%, #fff), color-mix(in srgb, var(--c) 70%, #0b0d18));
}
.front,
.back {
inset: 0;
}
.front {
display: grid;
place-items: center;
align-content: space-evenly;
transform: translateZ(calc(36 * var(--u))); /* depth / 2 */
}
.back {
background:
radial-gradient(circle at 50% 72%, #05060c 0 calc(9 * var(--u)), color-mix(in srgb, var(--c) 40%, #05060c) calc(10 * var(--u)) calc(12 * var(--u)), transparent calc(12.5 * var(--u))),
linear-gradient(color-mix(in srgb, var(--c) 55%, #0b0d18), color-mix(in srgb, var(--c) 35%, #0b0d18));
transform: rotateY(180deg) translateZ(calc(36 * var(--u)));
}
/* depth-wide, centred on the box */
.left,
.right {
top: 0;
left: calc(7 * var(--u));
width: calc(72 * var(--u));
height: calc(124 * var(--u));
background:
radial-gradient(rgb(0 0 0 / 0.22) calc(0.7 * var(--u)), transparent calc(1.2 * var(--u))) 0 0 / calc(4 * var(--u)) calc(4 * var(--u)),
linear-gradient(color-mix(in srgb, var(--c) 65%, #0b0d18), color-mix(in srgb, var(--c) 45%, #0b0d18));
}
/* the sides are squeezed hard on a small canvas: a two-unit highlight inside both upright edges
survives it where a one-unit line breaks up */
.left,
.right {
box-shadow:
inset calc(2 * var(--u)) 0 0 color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #fff) 70%, transparent),
inset calc(-2 * var(--u)) 0 0 color-mix(in srgb, color-mix(in srgb, var(--c) 55%, #fff) 70%, transparent);
}
.left { transform: rotateY(-90deg) translateZ(calc(43 * var(--u))); } /* width / 2 */
.right { transform: rotateY(90deg) translateZ(calc(43 * var(--u))); }
.top,
.bottom {
top: calc(26 * var(--u));
left: 0;
width: calc(86 * var(--u));
height: calc(72 * var(--u));
}
/* lighter, with three buttons */
.top {
background:
radial-gradient(circle at 30% 50%, rgb(255 255 255 / 0.8) 0 calc(3 * var(--u)), transparent calc(3.5 * var(--u))),
radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.8) 0 calc(3 * var(--u)), transparent calc(3.5 * var(--u))),
radial-gradient(circle at 70% 50%, rgb(255 255 255 / 0.8) 0 calc(3 * var(--u)), transparent calc(3.5 * var(--u))),
color-mix(in srgb, var(--c) 70%, #fff);
transform: rotateX(90deg) translateZ(calc(62 * var(--u))); /* height / 2 */
}
.bottom {
background: color-mix(in srgb, var(--c) 30%, #05060c);
transform: rotateX(-90deg) translateZ(calc(62 * var(--u)));
}
/* tweeter and woofer */
.front b {
width: calc(24 * var(--u));
height: calc(24 * var(--u));
border-radius: 50%;
background:
radial-gradient(circle at 40% 35%, rgb(255 255 255 / 0.5), transparent 30%),
radial-gradient(circle, #d4d7ea 0 18%, #1a1c2b 22% 58%, #34374f 62% 72%, #05060c 76% 84%, color-mix(in srgb, var(--c) 40%, #fff) 88%);
}
.front b + b {
width: calc(58 * var(--u));
height: calc(58 * var(--u));
background:
radial-gradient(circle at 40% 35%, rgb(255 255 255 / 0.3), transparent 30%),
radial-gradient(circle, #d4d7ea 0 12%, #1a1c2b 15% 50%, #2b2e44 52% 60%, #1a1c2b 62% 72%, #05060c 76% 86%, color-mix(in srgb, var(--c) 40%, #fff) 90%);
}
/* round, so the rotation does not matter; laid flat just under the bottom */
.shadow {
position: absolute;
top: calc(50% - 80 * var(--u));
left: calc(50% - 80 * var(--u));
width: calc(160 * var(--u));
height: calc(160 * var(--u));
border-radius: 50%;
background: radial-gradient(closest-side, rgb(0 0 0 / 0.5), rgb(0 0 0 / 0.18) 60%, transparent);
transform: rotateX(90deg) translateZ(calc(-64 * var(--u)));
}
/* the control zone: the same object, at the same size, in every model that has one — so it is
written in plain vmin and not in the speaker's own unit. The caption is on its own line above
the row, and its line box never changes height, so a new angle cannot move the speaker. */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.controls .caption {
font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
opacity: 0.7;
}
.controls .row {
display: flex;
gap: 2vmin;
}
.controls button,
.controls label {
height: 8vmin;
min-width: 8vmin;
padding: 0 3vmin;
box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
border: 0;
border-radius: 999px;
/* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
strength: no colour of the model's, which would vanish on one of the two stages */
background: rgb(140 150 220 / 0.2);
color: inherit;
font: 600 4vmin Inter, system-ui, sans-serif;
cursor: pointer;
transition: background-color 0.35s;
}
.controls button:hover,
.controls label:hover {
background-color: rgb(140 150 220 / 0.34);
}
.controls :focus-visible {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
background: linear-gradient(135deg, #6a45f5, #d1206f);
color: #fff;
}
/* The pill already chosen answers the pointer too.
Hover sets background-color; this rule sets the background SHORTHAND, so its gradient image was
painted straight over that colour. The one control already selected was the one control that
did nothing when you pointed at it, which is what check-motion kept saying:
'hover button#1 "Low": changes nothing on screen'.
Darker, not lighter: the gradient above is as pale as it can be with white text still passing. */
.controls [aria-pressed='true']:hover {
background-image: linear-gradient(135deg, #5730dd, #b31859);
}
/* a swatch: its colour is what it says, so it keeps it when selected, and the selected one is
ringed in the stage's own ink */
.controls .row button {
box-sizing: border-box;
width: 8vmin;
padding: 0;
border: 0.6vmin solid #141830;
background: var(--sw);
box-shadow: 0 0 0 0.3vmin rgb(140 150 220 / 0.34);
transition: box-shadow 0.35s;
}
/* the swatch's own colour wins over the block's hover tint, so a pointed-at or focused swatch
answers with a ring in the stage's ink instead: thinner than the selected one's, so the two
states stay apart. It lights up in 0.12s and fades in 0.35s */
.controls .row button[aria-pressed='false']:is(:hover, :focus-visible) {
box-shadow: 0 0 0 0.35vmin currentColor;
transition-duration: 0.12s;
}
.controls .row button[aria-pressed='true'] {
box-shadow: 0 0 0 0.6vmin currentColor;
color: inherit;
}
const viewer = document.querySelector('.viewer');
const view = viewer.querySelector('.view');
const product = viewer.querySelector('.product');
const controls = viewer.querySelector('.controls');
const out = viewer.querySelector('output');
let ry = -32, v = 0, last = 0, raf = 0, dragging = false;
function apply() {
product.style.setProperty('--ry', ry.toFixed(1) + 'deg');
out.textContent = Math.round(((ry % 360) + 360) % 360) + '°';
}
// inertia: keep turning, lose 6% per frame, stop the loop when slow
function glide() {
v *= 0.94;
ry += v;
apply();
raf = Math.abs(v) > 0.05 ? requestAnimationFrame(glide) : 0;
}
view.addEventListener('pointerdown', (e) => {
dragging = true;
last = e.clientX;
v = 0;
cancelAnimationFrame(raf);
view.setPointerCapture(e.pointerId); // keep getting moves outside the view
view.classList.add('is-dragging');
});
view.addEventListener('pointermove', (e) => {
if (!dragging) return;
v = (e.clientX - last) * 0.6;
last = e.clientX;
ry += v;
apply();
});
function up() {
if (!dragging) return;
dragging = false;
view.classList.remove('is-dragging');
if (Math.abs(v) > 0.05) raf = requestAnimationFrame(glide);
}
view.addEventListener('pointerup', up);
view.addEventListener('pointercancel', up);
view.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
v = e.key === 'ArrowLeft' ? -5 : 5;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(glide);
});
controls.addEventListener('click', (e) => {
const btn = e.target.closest('[data-c]');
if (!btn) return;
viewer.style.setProperty('--c', btn.dataset.c);
controls.querySelectorAll('[data-c]').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
});