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.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="bar">
<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>
<output>drag to rotate</output>
</div>
</div>
.viewer {
--c: #8b6cff;
display: grid;
grid-template-rows: 1fr auto;
width: 340px;
height: 280px;
font-family: system-ui, sans-serif;
}
.view {
display: grid;
place-items: center;
border-radius: 14px;
outline-offset: -4px;
perspective: 700px;
cursor: grab;
user-select: none;
touch-action: pan-y; /* sideways drags are ours, vertical ones scroll the page */
}
.view.is-dragging {
cursor: grabbing;
}
/* 86 wide, 124 tall, 72 deep. No transition: JS sets a new angle every frame */
.product {
position: relative;
width: 86px;
height: 124px;
pointer-events: none;
transform-style: preserve-3d;
transform: rotateX(-14deg) rotateY(var(--ry, -32deg));
}
.f {
position: absolute;
box-sizing: border-box;
border: 1px solid color-mix(in srgb, var(--c) 55%, #fff);
/* woven fabric: a fine dot grid over a light falloff */
background:
radial-gradient(rgb(0 0 0 / 0.22) 0.7px, transparent 1.2px) 0 0 / 4px 4px,
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(36px); /* depth / 2 */
}
.back {
background:
radial-gradient(circle at 50% 72%, #05060c 0 9px, color-mix(in srgb, var(--c) 40%, #05060c) 10px 12px, transparent 12.5px),
linear-gradient(color-mix(in srgb, var(--c) 55%, #0b0d18), color-mix(in srgb, var(--c) 35%, #0b0d18));
transform: rotateY(180deg) translateZ(36px);
}
/* depth-wide, centred on the box */
.left,
.right {
top: 0;
left: 7px;
width: 72px;
height: 124px;
background:
radial-gradient(rgb(0 0 0 / 0.22) 0.7px, transparent 1.2px) 0 0 / 4px 4px,
linear-gradient(color-mix(in srgb, var(--c) 65%, #0b0d18), color-mix(in srgb, var(--c) 45%, #0b0d18));
}
.left { transform: rotateY(-90deg) translateZ(43px); } /* width / 2 */
.right { transform: rotateY(90deg) translateZ(43px); }
.top,
.bottom {
top: 26px;
left: 0;
width: 86px;
height: 72px;
}
/* lighter, with three buttons */
.top {
background:
radial-gradient(circle at 30% 50%, rgb(255 255 255 / 0.8) 0 3px, transparent 3.5px),
radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.8) 0 3px, transparent 3.5px),
radial-gradient(circle at 70% 50%, rgb(255 255 255 / 0.8) 0 3px, transparent 3.5px),
color-mix(in srgb, var(--c) 70%, #fff);
transform: rotateX(90deg) translateZ(62px); /* height / 2 */
}
.bottom {
background: color-mix(in srgb, var(--c) 30%, #05060c);
transform: rotateX(-90deg) translateZ(62px);
}
/* tweeter and woofer */
.front b {
width: 24px;
height: 24px;
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: 58px;
height: 58px;
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% - 80px);
left: calc(50% - 80px);
width: 160px;
height: 160px;
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(-64px);
}
.bar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding-top: 6px;
font-size: 13px;
}
.bar button {
width: 22px;
height: 22px;
box-sizing: border-box;
padding: 0;
border: 2px solid #141830;
border-radius: 50%;
background: var(--sw);
box-shadow: 0 0 0 1px rgb(140 150 220 / 0.34);
cursor: pointer;
}
.bar button[aria-pressed='true'] {
box-shadow: 0 0 0 2px #eceefb;
}
.bar output {
width: 12ch;
color: #949bc0;
white-space: nowrap;
}
const viewer = document.querySelector('.viewer');
const view = viewer.querySelector('.view');
const product = viewer.querySelector('.product');
const bar = viewer.querySelector('.bar');
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);
});
bar.addEventListener('click', (e) => {
const btn = e.target.closest('[data-c]');
if (!btn) return;
viewer.style.setProperty('--c', btn.dataset.c);
bar.querySelectorAll('[data-c]').forEach((b) => b.setAttribute('aria-pressed', String(b === btn)));
});
$w: 86px; // speaker width
$h: 124px; // height
$d: 72px; // depth
.d-turntable {
--c: var(--accent);
display: grid;
grid-template-rows: 1fr auto;
width: 100%;
height: 100%;
padding: 10px 14px;
&__view {
display: grid;
place-items: center;
min-height: 0;
border-radius: 14px;
outline-offset: -4px;
perspective: 700px;
cursor: grab;
user-select: none;
touch-action: pan-y; // sideways drags turn the speaker, vertical ones still scroll the page
&.is-dragging {
cursor: grabbing;
}
}
// JS writes only --ry. No transition: during a drag and during the glide JS sets a new
// angle every frame, and a transition would only add lag.
&__product {
position: relative;
width: $w;
height: $h;
pointer-events: none;
transform-style: preserve-3d;
transform: rotateX(-14deg) rotateY(var(--ry, -32deg));
}
&__f {
position: absolute;
border: 1px solid color-mix(in srgb, var(--c) 55%, #fff);
// woven fabric: a fine dot grid over a vertical light falloff
background:
radial-gradient(rgb(0 0 0 / 0.22) 0.7px, transparent 1.2px) 0 0 / 4px 4px,
linear-gradient(color-mix(in srgb, var(--c) 85%, #fff), color-mix(in srgb, var(--c) 70%, #0b0d18));
// a box, not a cube: every face gets its own size, then turns and steps out by half the
// dimension it faces
&--front,
&--back {
inset: 0;
}
&--front {
display: grid;
place-items: center;
align-content: space-evenly;
transform: translateZ($d * 0.5);
}
&--back {
background:
radial-gradient(circle at 50% 72%, #05060c 0 9px, color-mix(in srgb, var(--c) 40%, #05060c) 10px 12px, transparent 12.5px),
linear-gradient(color-mix(in srgb, var(--c) 55%, #0b0d18), color-mix(in srgb, var(--c) 35%, #0b0d18));
transform: rotateY(180deg) translateZ($d * 0.5);
}
&--left,
&--right {
top: 0;
left: ($w - $d) * 0.5;
width: $d;
height: $h;
background:
radial-gradient(rgb(0 0 0 / 0.22) 0.7px, transparent 1.2px) 0 0 / 4px 4px,
linear-gradient(color-mix(in srgb, var(--c) 65%, #0b0d18), color-mix(in srgb, var(--c) 45%, #0b0d18));
}
&--left {
transform: rotateY(-90deg) translateZ($w * 0.5);
}
&--right {
transform: rotateY(90deg) translateZ($w * 0.5);
}
&--top,
&--bottom {
top: ($h - $d) * 0.5;
left: 0;
width: $w;
height: $d;
}
// top: lighter, with a row of three buttons
&--top {
background:
radial-gradient(circle at 30% 50%, rgb(255 255 255 / 0.8) 0 3px, transparent 3.5px),
radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.8) 0 3px, transparent 3.5px),
radial-gradient(circle at 70% 50%, rgb(255 255 255 / 0.8) 0 3px, transparent 3.5px),
color-mix(in srgb, var(--c) 70%, #fff);
transform: rotateX(90deg) translateZ($h * 0.5);
}
&--bottom {
background: color-mix(in srgb, var(--c) 30%, #05060c);
transform: rotateX(-90deg) translateZ($h * 0.5);
}
// two drivers: a tweeter and a woofer
b {
width: 24px;
height: 24px;
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%);
& + b {
width: 58px;
height: 58px;
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%);
}
}
}
// a round shadow on the floor: round, so it does not care about the rotation
&__shadow {
position: absolute;
top: calc(50% - 80px);
left: calc(50% - 80px);
width: 160px;
height: 160px;
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($h * -0.5 - 2px);
}
&__bar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding-top: 6px;
font-size: 13px;
button {
width: 22px;
height: 22px;
padding: 0;
border: 2px solid var(--surface-solid);
border-radius: 50%;
background: var(--sw);
box-shadow: 0 0 0 1px var(--border-strong);
cursor: pointer;
&[aria-pressed='true'] {
box-shadow: 0 0 0 2px var(--text);
}
}
output {
width: 12ch; // fixed, so the swatches do not jump when the text changes
color: var(--muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
}
}