Diorama Box
A little night scene built inside an open box, like a shoebox diorama: a moonlit sky on the back wall, then hills, a row of pines, a cottage with a lit window and the grass at the front, each standing at its own depth. The box turns to follow your pointer, so you can peer in from the side and watch the layers slide past each other. On a phone, drag a finger across it.
How to use it
- Paste the snippet just before
</head>. - Add
data-diorama-boxto a box with a width and a height. - Put your layers inside, back to front, each the full size of the box: the first is the back wall. Shape the others with
clip-pathor transparent images.
Options
Add any of these attributes to the same element.
data-depth | On a layer: where it stands, from 0 (the back wall) to 1 (the opening). Default evenly spaced. |
data-deep | How deep the box is, as a share of its width. Default 0.55. |
data-tilt | How far the box turns, in degrees. Default 22. |
data-wall | Colour of the side walls and ceiling. Default #2e1065. |
data-floor | Colour of the floor. Default #14532d. |
data-frame | Colour of the rim round the opening, or false for none. Default white. |
data-autoplay="false" | Don’t sway when no one’s pointing at it. |
Made with CSS 3D, so the layers can hold any HTML. It’s paused when off screen; with reduced motion the box stands still at a slight angle.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Diorama Box: a scene built inside an open-fronted box, like a shoebox
// diorama. The layers stand at different depths between the back wall and
// the opening, and the whole box turns to follow the pointer (or a finger
// dragged across it), so you can peer in from the sides. Left alone it
// sways gently by itself. Made with CSS 3D.
// Usage:
// <div data-diorama-box style="width: 480px; aspect-ratio: 16 / 10">
// <div class="sky"></div> <!-- the back wall -->
// <div class="hills"></div>
// <div class="trees"></div>
// <div class="grass"></div> <!-- nearest the opening -->
// </div>
// Children are layers, back to front, each the full size of the opening;
// shape them with clip-path or transparent images. Add data-depth (0 at
// the back to 1 at the opening) to place one yourself.
// Optional: data-deep (box depth as a share of its width, default 0.55),
// data-tilt (how far it turns, degrees, default 22), data-wall (colour of
// the walls, default #2e1065), data-floor (default #14532d), data-frame
// (colour of the rim, default #fff; "false" for none), data-autoplay="false"
// (stay still until pointed at).
var DEFAULTS = { deep: 0.55, tilt: 22, wall: '#2e1065', floor: '#14532d', frame: '#fff' };
var css =
'.drm{position:relative;perspective:var(--drm-p,900px);touch-action:pan-y}' +
'.drm-box{position:absolute;inset:0;transform-style:preserve-3d}' +
'.drm-box>*{position:absolute;box-sizing:border-box}' +
'.drm-layer{inset:0;pointer-events:none}' +
'.drm-floor{left:0;bottom:0;width:100%;transform-origin:50% 100%}' +
'.drm-ceil{left:0;top:0;width:100%;transform-origin:50% 0}' +
'.drm-left{left:0;top:0;height:100%;transform-origin:0 50%}' +
'.drm-right{right:0;top:0;height:100%;transform-origin:100% 50%}' +
'.drm-frame{inset:0;pointer-events:none;border-radius:4px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function part(cls, box) {
var p = document.createElement('div');
p.className = cls;
p.setAttribute('aria-hidden', 'true');
box.appendChild(p);
return p;
}
function setup(el) {
var d = el.dataset;
var kids = Array.from(el.children);
if (!kids.length) return;
el.classList.add('drm');
var deep = +d.deep || DEFAULTS.deep;
var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
var wall = d.wall || DEFAULTS.wall, floorColor = d.floor || DEFAULTS.floor;
var box = document.createElement('div');
box.className = 'drm-box';
// Walls darken towards the back.
var floor = part('drm-floor', box);
var dark = function (deg, color) { return 'linear-gradient(' + deg + 'deg,rgba(0,0,0,0),rgba(0,0,0,.6)),' + color; };
floor.style.background = dark(0, floorColor);
var ceil = part('drm-ceil', box);
var left = part('drm-left', box);
var right = part('drm-right', box);
ceil.style.background = dark(180, wall);
left.style.background = dark(90, wall);
right.style.background = dark(270, wall);
var n = kids.length;
var layers = kids.map(function (k, i) {
k.classList.add('drm-layer');
box.appendChild(k);
var depth = k.dataset.depth !== undefined ? +k.dataset.depth : (n === 1 ? 0 : i / n * 0.95);
return { el: k, depth: Math.max(0, Math.min(1, depth)) };
});
var frame = null;
if (d.frame !== 'false') {
frame = part('drm-frame', box);
frame.style.boxShadow = 'inset 0 0 0 var(--drm-rim,6px) ' + (d.frame || DEFAULTS.frame) + ', 0 18px 40px -12px rgba(30,10,60,.5)';
}
el.appendChild(box);
var W = 0, H = 0, D = 0;
function size() {
W = el.clientWidth;
H = el.clientHeight;
D = Math.round(W * deep);
el.style.setProperty('--drm-p', Math.round(W * 2.2) + 'px');
el.style.setProperty('--drm-rim', Math.max(3, Math.round(W / 70)) + 'px');
floor.style.height = ceil.style.height = D + 'px';
left.style.width = right.style.width = D + 'px';
floor.style.transform = 'rotateX(90deg)';
ceil.style.transform = 'rotateX(-90deg)';
left.style.transform = 'rotateY(90deg)';
right.style.transform = 'rotateY(-90deg)';
layers.forEach(function (l) { l.el.style.transform = 'translateZ(' + (-D * (1 - l.depth)).toFixed(1) + 'px)'; });
}
// The view: where it is (x, y) and where it's heading (tx, ty), -1 to 1.
var s = { x: 0.4, y: -0.3, tx: 0.4, ty: -0.3, t: 0, held: 0 };
function draw() {
// Turn round the middle of the box, not its opening.
box.style.transform = 'translateZ(' + (-D / 2) + 'px) rotateX(' + (-s.y * tilt * 0.7).toFixed(2) + 'deg) rotateY(' + (s.x * tilt).toFixed(2) + 'deg) translateZ(' + D / 2 + 'px)';
}
size();
draw();
if (window.ResizeObserver) new ResizeObserver(function () { size(); draw(); }).observe(el);
if (reduced()) return;
var aim = function (e) {
var r = el.getBoundingClientRect();
// Point right and the box turns so you see in from the right.
s.tx = Math.max(-1, Math.min(1, -(((e.clientX - r.left) / r.width) * 2 - 1)));
s.ty = Math.max(-1, Math.min(1, -(((e.clientY - r.top) / r.height) * 2 - 1)));
s.held = 2.5;
};
el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) aim(e); });
el.addEventListener('pointerdown', aim);
var auto = d.autoplay !== 'false';
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) / 1000 : 0.016;
last = now;
if (s.held > 0) s.held -= dt;
else if (auto) {
s.t += dt * 0.5;
s.tx = Math.sin(s.t) * 0.9;
s.ty = Math.sin(s.t * 0.7 + 1) * 0.5;
}
var k = 1 - Math.pow(0.03, dt);
s.x += (s.tx - s.x) * k;
s.y += (s.ty - s.y) * k;
draw();
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-diorama-box]').forEach(function (el) {
if (el._drm) return;
el._drm = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-diorama-box style="width: 480px; aspect-ratio: 16 / 10">
<div class="sky"></div>
<div class="hills" data-depth="0.25"></div>
<div class="trees" data-depth="0.5"></div>
<div class="grass" data-depth="0.9"></div>
</div>