Device Turn
A phone mockup with real thickness turns round on the spot: its screen, then one edge with the power button, then the back with its camera, then the edge with the volume buttons, and round to the front again, resting at each. The light slides across the glass and the rim as it turns. Drag it to turn it yourself.
Lisbon24°
How to use it
- Paste the snippet just before
</head>. - Add
data-device-turnto a box with a phone’s shape, e.g.width: 160px; aspect-ratio: 9 / 19. - Put what’s on the screen inside it: an image or any HTML. An optional second child goes on the back.
Options
Add any of these attributes to the same element.
data-color | Colour of the frame and the back. Default lavender, #c4b5fd. |
data-duration | How long each quarter turn takes. Default 900 ms. |
data-hold | How long it rests facing you, and facing away. Default 1500 ms. |
data-tilt | How far you look down on it, in degrees. Default 10. |
data-drag="false" | Don’t let people turn it by dragging. |
Made with CSS 3D: the rim is built from narrow strips round the rounded corners, so it looks solid from every side. It’s paused when off screen; with reduced motion it stands still at an angle.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Device Turn: a phone mockup with real thickness turns round on the spot:
// the screen, then one edge with its buttons, then the back with its camera,
// then the other edge, and round to the front again, pausing at each. The
// light shifts across it as it turns. Drag to turn it by hand.
// Usage: <div data-device-turn style="width: 160px; aspect-ratio: 9 / 19">
// <div>…what's on the screen…</div>
// <div>…optional: anything on the back…</div>
// </div>
// Optional: data-color (the frame and back, default "#c4b5fd"), data-duration
// (ms per quarter turn, default 900), data-hold (ms it rests facing you and
// facing away, default 1500), data-tilt (how far you look down on it, degrees,
// default 10), data-drag="false".
var DEFAULTS = { color: '#c4b5fd', duration: 900, hold: 1500, tilt: 10 };
var css =
'[data-device-turn]{position:relative;touch-action:pan-y}' +
'.dvt-body{position:absolute;inset:0;transform-style:preserve-3d}' +
'.dvt-front,.dvt-back{position:absolute;inset:0;backface-visibility:hidden;-webkit-backface-visibility:hidden;overflow:hidden}' +
'.dvt-front{background:#0b0a12;padding:3.6%}' +
'.dvt-screen{position:relative;width:100%;height:100%;overflow:hidden;background:#fff}' +
'.dvt-screen>*:not(.dvt-island):not(.dvt-gloss){width:100%;height:100%}' +
'.dvt-island{position:absolute;left:50%;top:2.2%;width:30%;height:3.4%;margin-left:-15%;border-radius:99px;background:#0b0a12;z-index:2}' +
'.dvt-gloss,.dvt-dim{position:absolute;inset:0;pointer-events:none;z-index:3}' +
'.dvt-dim{background:#000;opacity:0}' +
'.dvt-cam{position:absolute;left:7%;top:4%;width:44%;aspect-ratio:1;border-radius:28%;' +
'background:linear-gradient(135deg,rgba(255,255,255,.45),rgba(255,255,255,.08));box-shadow:inset 0 0 0 1px rgba(255,255,255,.4),0 .1em .3em rgba(0,0,0,.15)}' +
'.dvt-lens{position:absolute;width:38%;aspect-ratio:1;border-radius:50%;background:radial-gradient(circle at 35% 35%,#4c4a6b 0 12%,#14121f 30%,#05040a 70%);' +
'box-shadow:0 0 0 2px #2a2838,0 0 0 3px rgba(255,255,255,.35)}' +
'.dvt-back>.dvt-own{position:absolute;inset:0}' +
'.dvt-edge{position:absolute;left:50%;top:50%;backface-visibility:visible}' +
'.dvt-btn{position:absolute;top:18%;height:64%;border-radius:2px;background:rgba(0,0,0,.18)}' +
'.dvt-shadow{position:absolute;left:50%;bottom:-9%;height:5%;width:110%;margin-left:-55%;border-radius:50%;' +
'background:radial-gradient(closest-side,rgba(30,10,60,.35),rgba(30,10,60,0));pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
var n = parseInt(hex, 16);
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
function tint(c, k) {
return 'rgb(' + c.map(function (v) { return Math.round(k > 1 ? v + (255 - v) * (k - 1) : v * k); }).join(',') + ')';
}
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function setup(el) {
var d = el.dataset;
var color = rgb(d.color || DEFAULTS.color);
var dur = +d.duration || DEFAULTS.duration;
var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
var kids = Array.prototype.slice.call(el.children);
var body = document.createElement('div');
body.className = 'dvt-body';
var front = document.createElement('div');
front.className = 'dvt-front';
var screen = document.createElement('div');
screen.className = 'dvt-screen';
if (kids[0]) screen.appendChild(kids[0]);
var island = document.createElement('span');
island.className = 'dvt-island';
var gloss = document.createElement('span');
gloss.className = 'dvt-gloss';
screen.appendChild(island);
screen.appendChild(gloss);
front.appendChild(screen);
var frontDim = document.createElement('span');
frontDim.className = 'dvt-dim';
front.appendChild(frontDim);
var back = document.createElement('div');
back.className = 'dvt-back';
back.setAttribute('aria-hidden', 'true');
if (kids[1]) { kids[1].classList.add('dvt-own'); back.appendChild(kids[1]); }
var cam = document.createElement('span');
cam.className = 'dvt-cam';
[[10, 10], [52, 10], [10, 52]].forEach(function (p) {
var l = document.createElement('span');
l.className = 'dvt-lens';
l.style.left = p[0] + '%';
l.style.top = p[1] + '%';
cam.appendChild(l);
});
back.appendChild(cam);
var backDim = document.createElement('span');
backDim.className = 'dvt-dim';
back.appendChild(backDim);
var shadow = document.createElement('span');
shadow.className = 'dvt-shadow';
shadow.setAttribute('aria-hidden', 'true');
body.appendChild(front);
body.appendChild(back);
el.appendChild(shadow);
el.appendChild(body);
var edges = [], W = 0, H = 0, T = 0;
// The rim: flat strips round the rounded rectangle, each turned to face out.
function build() {
edges.forEach(function (e) { e.node.remove(); });
edges = [];
W = el.clientWidth;
H = el.clientHeight;
if (!W || !H) return;
T = Math.max(5, W * 0.075);
var R = W * 0.17, k = 5;
el.style.perspective = Math.round(W * 7) + 'px';
front.style.borderRadius = back.style.borderRadius = R + 'px';
screen.style.borderRadius = R * 0.8 + 'px';
front.style.transform = 'translateZ(' + T / 2 + 'px)';
back.style.transform = 'rotateY(180deg) translateZ(' + T / 2 + 'px)';
back.style.background = 'linear-gradient(150deg,' + tint(color, 1.18) + ',' + tint(color, 0.92) + ')';
var pts = [], cx = W / 2 - R, cy = H / 2 - R;
// Corners clockwise from the top left (y down), each as k strips.
[[-cx, -cy, Math.PI], [cx, -cy, Math.PI * 1.5], [cx, cy, 0], [-cx, cy, Math.PI * 0.5]].forEach(function (c) {
for (var i = 0; i <= k; i++) {
var a = c[2] + i / k * Math.PI / 2;
pts.push([c[0] + Math.cos(a) * R, c[1] + Math.sin(a) * R]);
}
});
for (var i = 0; i < pts.length; i++) {
var p = pts[i], q = pts[(i + 1) % pts.length];
var dx = q[0] - p[0], dy = q[1] - p[1], L = Math.hypot(dx, dy);
if (L < 0.01) continue;
var mx = (p[0] + q[0]) / 2, my = (p[1] + q[1]) / 2;
var nx = dy / L, ny = -dx / L;
if (nx * mx + ny * my < 0) { nx = -nx; ny = -ny; }
var node = document.createElement('span');
node.className = 'dvt-edge';
node.style.width = L + 1 + 'px';
node.style.height = T + 'px';
node.style.marginLeft = -(L + 1) / 2 + 'px';
node.style.marginTop = -T / 2 + 'px';
node.style.transform = 'translate(' + mx + 'px,' + my + 'px) rotateZ(' + Math.atan2(dy, dx) + 'rad) rotateX(90deg)';
// Buttons on the long sides: one on the right, two on the left.
if (L > H * 0.3) {
var right = nx > 0.5, left = nx < -0.5;
(right ? [[22, 16]] : left ? [[58, 9], [70, 9]] : []).forEach(function (b) {
var btn = document.createElement('span');
btn.className = 'dvt-btn';
btn.style.left = b[0] + '%';
btn.style.width = b[1] + '%';
node.appendChild(btn);
});
}
body.appendChild(node);
edges.push({ node: node, nx: nx, ny: ny });
}
}
var s = { yaw: 0, stop: 0, phase: 'hold', t: 0, from: 0, to: 0, dragging: false };
function holdFor(stop) { return ((stop % 4) + 4) % 4 % 2 ? hold * 0.35 : hold; }
function render(time) {
var pitch = tilt + Math.sin(time / 1300) * tilt * 0.4;
var yaw = s.yaw * Math.PI / 180, pr = pitch * Math.PI / 180;
body.style.transform = 'rotateX(' + (-pitch).toFixed(2) + 'deg) rotateY(' + s.yaw.toFixed(2) + 'deg)';
var cY = Math.cos(yaw), sY = Math.sin(yaw);
// Light from the upper left, in front. CSS y points down.
var Lx = -0.55, Ly = -0.45, Lz = 0.7;
function lit(nx, ny, nz) {
var x = nx * cY + nz * sY, z = -nx * sY + nz * cY;
var y = ny * Math.cos(pr) + z * Math.sin(pr);
z = -ny * Math.sin(pr) + z * Math.cos(pr);
return Math.max(0, x * Lx + y * Ly + z * Lz);
}
edges.forEach(function (e) { e.node.style.background = tint(color, 0.62 + lit(e.nx, e.ny, 0) * 0.62); });
frontDim.style.opacity = ((1 - lit(0, 0, 1)) * 0.45).toFixed(3);
backDim.style.opacity = ((1 - lit(0, 0, -1)) * 0.45).toFixed(3);
var g = 50 + Math.sin(yaw) * 140;
gloss.style.background = 'linear-gradient(115deg,rgba(255,255,255,0) ' + (g - 30) + '%,rgba(255,255,255,.28) ' + g + '%,rgba(255,255,255,0) ' + (g + 22) + '%)';
var span = Math.abs(cY) * W + Math.abs(sY) * T;
shadow.style.transform = 'scaleX(' + (W ? Math.max(0.08, span / W) : 1).toFixed(3) + ')';
}
function step(dt) {
s.t += dt;
if (s.phase === 'hold') {
if (s.t >= holdFor(s.stop)) { s.phase = 'turn'; s.t = 0; s.from = s.stop * 90; s.to = (s.stop + 1) * 90; }
} else {
var p = Math.min(1, s.t / dur);
s.yaw = s.from + (s.to - s.from) * ease(p);
if (p >= 1) { s.stop = Math.round(s.to / 90); s.phase = 'hold'; s.t = 0; }
}
}
build();
render(0);
if (window.ResizeObserver) new ResizeObserver(function () {
if (el.clientWidth !== W || el.clientHeight !== H) { build(); render(performance.now()); }
}).observe(el);
if (reduced()) {
s.yaw = -28;
render(0);
return;
}
if (d.drag !== 'false') {
el.style.cursor = 'grab';
var lx = 0;
el.addEventListener('pointerdown', function (e) {
s.dragging = true;
lx = e.clientX;
el.setPointerCapture(e.pointerId);
el.style.cursor = 'grabbing';
});
el.addEventListener('pointermove', function (e) {
if (!s.dragging) return;
s.yaw += (e.clientX - lx) * 0.8;
lx = e.clientX;
});
var end = function () {
if (!s.dragging) return;
s.dragging = false;
el.style.cursor = 'grab';
// Settle on the nearest side, then carry on from there.
s.phase = 'turn'; s.t = 0; s.from = s.yaw; s.to = Math.round(s.yaw / 90) * 90;
};
el.addEventListener('pointerup', end);
el.addEventListener('pointercancel', end);
}
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
if (!s.dragging) step(dt);
render(now);
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-device-turn]').forEach(function (el) {
if (el._dvt) return;
el._dvt = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-device-turn style="width: 160px; aspect-ratio: 9 / 19">
<img src="screenshot.png" alt="The app's home screen">
</div>