Anamorph
import { useEffect, useRef, type CSSProperties } from 'react';
const P = 640; // the camera distance, shared with the CSS perspective
const S = 30;
const STEP = 25;
// The tick as the camera will see it head-on: two arms at exactly 45 degrees.
const TILES: [number, number][] = [];
for (let i = 0; i <= 2; i++) TILES.push([-58 + i * STEP * 0.7071, -2 + i * STEP * 0.7071]);
for (let i = 1; i <= 6; i++) TILES.push([-58 + 2 * STEP * 0.7071 + i * STEP * 0.7071, -2 + 2 * STEP * 0.7071 - i * STEP * 0.7071]);
const DEPTH = [104, -56, 32, -112, 76, -24, 120, -84, 48];
// Each block is pushed along the sight line from the camera, so at any depth it covers
// the same spot at the same size, but only from this one angle.
const BLOCKS = TILES.map(([x, y], i) => {
const z = DEPTH[i];
const k = (P - z) / P;
return `translate3d(${(x * k).toFixed(2)}px, ${(y * k).toFixed(2)}px, ${z}px) scale(${k.toFixed(4)}) rotate(45deg)`;
});
const css = `
.cb-anamorph { --front: light-dark(#373a40, #f1f2f3); --side-a: light-dark(#5f626a, #a7abb2); --side-b: light-dark(#6d7179, #7d8087); flex: none; position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; font-weight: 500; cursor: pointer; -webkit-user-select: none; user-select: none; }
.cb-anamorph__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-anamorph__art { position: relative; display: block; width: 256px; height: 190px; border-radius: 12px; perspective: ${P}px; perspective-origin: 50% 55%; }
.cb-anamorph__input:focus-visible + .cb-anamorph__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.cb-anamorph__world { position: absolute; left: 50%; top: 55%; width: 0; height: 0; transform-style: preserve-3d; transform: rotateX(-18deg) rotateY(31deg); will-change: transform; }
.cb-anamorph__block { position: absolute; left: ${-S / 2}px; top: ${-S / 2}px; width: ${S}px; height: ${S}px; transform-style: preserve-3d; }
.cb-anamorph__face { position: absolute; display: block; transition: opacity 0.3s; }
.cb-anamorph__face--front { inset: 0; background: var(--front); transform: translateZ(7px); }
.cb-anamorph__face--s { left: 0; right: 0; height: 14px; background: var(--side-a); }
.cb-anamorph__face--t { top: -7px; transform: rotateX(90deg); }
.cb-anamorph__face--u { bottom: -7px; transform: rotateX(-90deg); }
.cb-anamorph__face--e { top: 0; bottom: 0; width: 14px; background: var(--side-b); }
.cb-anamorph__face--l { left: -7px; transform: rotateY(-90deg); }
.cb-anamorph__face--r { right: -7px; transform: rotateY(90deg); }
/* Head-on the sides would peek out at the edges, so they fade as the blocks line up. */
.cb-anamorph__input:checked + .cb-anamorph__art .cb-anamorph__face--s, .cb-anamorph__input:checked + .cb-anamorph__art .cb-anamorph__face--e { opacity: 0; transition: opacity 0.25s 0.5s; }
@media (prefers-reduced-motion: reduce) {
.cb-anamorph__face { transition: none !important; }
}
`;
export function AnamorphCheckbox() {
const root = useRef<HTMLLabelElement>(null);
const input = useRef<HTMLInputElement>(null);
const world = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = root.current;
const box = input.current;
const scene = world.current;
if (!el || !box || !scene) return;
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
let rx = -18;
let ry = 31;
let px = 0;
let py = 0;
let raf = 0;
let visible = false;
let last = 0;
const tick = (t: number) => {
const dt = Math.min(0.05, (t - last) / 1000 || 0.016);
last = t;
// Unticked it drifts, and the cursor pushes it round so you can see it is really 3D.
const on = box.checked;
const gy = on ? 0 : 31 + Math.sin(t / 1900) * 7 + px * 12;
const gx = on ? 0 : -18 + Math.sin(t / 1300) * 4 + py * 10;
const ease = 1 - Math.exp(-dt * (on ? 6 : 3));
rx += (gx - rx) * ease;
ry += (gy - ry) * ease;
if (on && Math.abs(rx) < 0.01 && Math.abs(ry) < 0.01) {
rx = 0;
ry = 0;
}
scene.style.transform = `rotateX(${rx.toFixed(3)}deg) rotateY(${ry.toFixed(3)}deg)`;
raf = visible && !(on && rx === 0 && ry === 0) ? requestAnimationFrame(tick) : 0;
};
const wake = () => {
if (!raf && visible) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
};
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (calm) return;
if (visible) wake();
});
io.observe(el);
const move = (e: PointerEvent) => {
const b = el.getBoundingClientRect();
px = (e.clientX - b.left) / b.width - 0.5;
py = (e.clientY - b.top) / b.height - 0.5;
};
const leave = () => {
px = 0;
py = 0;
};
const change = () => {
if (calm) {
rx = box.checked ? 0 : -18;
ry = box.checked ? 0 : 31;
scene.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
} else wake();
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerleave', leave);
box.addEventListener('change', change);
return () => {
io.disconnect();
cancelAnimationFrame(raf);
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerleave', leave);
box.removeEventListener('change', change);
};
}, []);
return (
<>
<style>
{css}
</style>
<label ref={root} className="cb-anamorph">
<input ref={input} className="cb-anamorph__input" type="checkbox" />
<span className="cb-anamorph__art" aria-hidden="true">
<span ref={world} className="cb-anamorph__world">
{BLOCKS.map((transform, i) => (
<span key={i} className="cb-anamorph__block" style={{ transform } as CSSProperties}>
<span className="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t" />
<span className="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u" />
<span className="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l" />
<span className="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r" />
<span className="cb-anamorph__face cb-anamorph__face--front" />
</span>
))}
</span>
</span>
Snap to grid
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initCbAnamorph(el) on the mounted element instead.
<label class="cb-anamorph">
<input class="cb-anamorph__input" type="checkbox"/>
<span class="cb-anamorph__art" aria-hidden="true">
<span class="cb-anamorph__world">
<span class="cb-anamorph__block" style="transform:translate3d(-48.58px, -1.68px, 104px) scale(0.838) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
<span class="cb-anamorph__block" style="transform:translate3d(-43.85px, 17.05px, -56px) scale(1.087) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
<span class="cb-anamorph__block" style="transform:translate3d(-21.51px, 31.69px, 32px) scale(0.95) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
<span class="cb-anamorph__block" style="transform:translate3d(-5.84px, 18.42px, -112px) scale(1.175) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
<span class="cb-anamorph__block" style="transform:translate3d(11.20px, -1.76px, 76px) scale(0.881) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
<span class="cb-anamorph__block" style="transform:translate3d(31.53px, -20.42px, -24px) scale(1.038) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
<span class="cb-anamorph__block" style="transform:translate3d(39.05px, -30.35px, 120px) scale(0.813) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
<span class="cb-anamorph__block" style="transform:translate3d(74.37px, -62.26px, -84px) scale(1.131) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
<span class="cb-anamorph__block" style="transform:translate3d(77.16px, -67.26px, 48px) scale(0.925) rotate(45deg)">
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--t"></span>
<span class="cb-anamorph__face cb-anamorph__face--s cb-anamorph__face--u"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--l"></span>
<span class="cb-anamorph__face cb-anamorph__face--e cb-anamorph__face--r"></span>
<span class="cb-anamorph__face cb-anamorph__face--front"></span>
</span>
</span>
</span>
Snap to grid
</label>.cb-anamorph { --front: light-dark(#373a40, #f1f2f3); --side-a: light-dark(#5f626a, #a7abb2); --side-b: light-dark(#6d7179, #7d8087); flex: none; position: relative; display: flex; flex-direction: column; align-items: center; gap: 2px; color: light-dark(#42454c, #e5e6e9); font-size: 14px; font-weight: 500; cursor: pointer; -webkit-user-select: none; user-select: none; }
.cb-anamorph__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.cb-anamorph__art { position: relative; display: block; width: 256px; height: 190px; border-radius: 12px; perspective: 640px; perspective-origin: 50% 55%; }
.cb-anamorph__input:focus-visible + .cb-anamorph__art { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.cb-anamorph__world { position: absolute; left: 50%; top: 55%; width: 0; height: 0; transform-style: preserve-3d; transform: rotateX(-18deg) rotateY(31deg); will-change: transform; }
.cb-anamorph__block { position: absolute; left: -15px; top: -15px; width: 30px; height: 30px; transform-style: preserve-3d; }
.cb-anamorph__face { position: absolute; display: block; transition: opacity 0.3s; }
.cb-anamorph__face--front { inset: 0; background: var(--front); transform: translateZ(7px); }
.cb-anamorph__face--s { left: 0; right: 0; height: 14px; background: var(--side-a); }
.cb-anamorph__face--t { top: -7px; transform: rotateX(90deg); }
.cb-anamorph__face--u { bottom: -7px; transform: rotateX(-90deg); }
.cb-anamorph__face--e { top: 0; bottom: 0; width: 14px; background: var(--side-b); }
.cb-anamorph__face--l { left: -7px; transform: rotateY(-90deg); }
.cb-anamorph__face--r { right: -7px; transform: rotateY(90deg); }
/* Head-on the sides would peek out at the edges, so they fade as the blocks line up. */
.cb-anamorph__input:checked + .cb-anamorph__art .cb-anamorph__face--s, .cb-anamorph__input:checked + .cb-anamorph__art .cb-anamorph__face--e { opacity: 0; transition: opacity 0.25s 0.5s; }
@media (prefers-reduced-motion: reduce) {
.cb-anamorph__face { transition: none !important; }
}function initCbAnamorph(root) {
var box = root.querySelector('.cb-anamorph__input');
var scene = root.querySelector('.cb-anamorph__world');
var calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
var rx = -18;
var ry = 31;
var px = 0;
var py = 0;
var raf = 0;
var visible = false;
var last = 0;
function tick(t) {
var dt = Math.min(0.05, (t - last) / 1000 || 0.016);
last = t;
// Unticked it drifts, and the cursor pushes it round so you can see it is really 3D.
var on = box.checked;
var gy = on ? 0 : 31 + Math.sin(t / 1900) * 7 + px * 12;
var gx = on ? 0 : -18 + Math.sin(t / 1300) * 4 + py * 10;
var ease = 1 - Math.exp(-dt * (on ? 6 : 3));
rx += (gx - rx) * ease;
ry += (gy - ry) * ease;
if (on && Math.abs(rx) < 0.01 && Math.abs(ry) < 0.01) {
rx = 0;
ry = 0;
}
scene.style.transform = 'rotateX(' + rx.toFixed(3) + 'deg) rotateY(' + ry.toFixed(3) + 'deg)';
raf = visible && !(on && rx === 0 && ry === 0) ? requestAnimationFrame(tick) : 0;
}
function wake() {
if (!raf && visible) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
}
var io = new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (calm) return;
if (visible) wake();
});
io.observe(root);
function onMove(e) {
var b = root.getBoundingClientRect();
px = (e.clientX - b.left) / b.width - 0.5;
py = (e.clientY - b.top) / b.height - 0.5;
}
function onLeave() {
px = 0;
py = 0;
}
function onChange() {
if (calm) {
rx = box.checked ? 0 : -18;
ry = box.checked ? 0 : 31;
scene.style.transform = 'rotateX(' + rx + 'deg) rotateY(' + ry + 'deg)';
} else wake();
}
root.addEventListener('pointermove', onMove);
root.addEventListener('pointerleave', onLeave);
box.addEventListener('change', onChange);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
root.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerleave', onLeave);
box.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.cb-anamorph').forEach(initCbAnamorph);Goal: Create a "Anamorph" checkbox.
Visual behavior: An optical illusion: a cloud of blocks hanging at different depths looks like nothing, until the camera swings head-on and they line up into one clean tick.
Technique:
- Nine blocks sit at different depths along z. Each is also moved and scaled by k = (P - z) / P, where P is the perspective distance: that exactly cancels what perspective does to it, so all nine land on one flat tick, at the same size, only when the world is seen straight on.
- Every frame the world eases towards its target angles: head-on when ticked, otherwise a slow drift that the cursor pushes around, so you can see it is really 3D. The side faces fade out once it lines up, and the loop stops when it has arrived or is off screen.
Accessibility: A native checkbox inside a <label>, so "Snap to grid" is its name. The blocks are aria-hidden and keyboard focus outlines them. Under prefers-reduced-motion nothing drifts and the view jumps between its two angles.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Anamorph" checkbox.
Visual behavior: An optical illusion: a cloud of blocks hanging at different depths looks like nothing, until the camera swings head-on and they line up into one clean tick.
Technique:
- Nine blocks sit at different depths along z. Each is also moved and scaled by k = (P - z) / P, where P is the perspective distance: that exactly cancels what perspective does to it, so all nine land on one flat tick, at the same size, only when the world is seen straight on.
- Every frame the world eases towards its target angles: head-on when ticked, otherwise a slow drift that the cursor pushes around, so you can see it is really 3D. The side faces fade out once it lines up, and the loop stops when it has arrived or is off screen.
Accessibility: A native checkbox inside a <label>, so "Snap to grid" is its name. The blocks are aria-hidden and keyboard focus outlines them. Under prefers-reduced-motion nothing drifts and the view jumps between its two angles.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
An optical illusion: a cloud of blocks hanging at different depths looks like nothing, until the camera swings head-on and they line up into one clean tick.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, 3d, mouse tracking, continuous
- Techniques
- raf, perspective, preserve 3d, 3d transform, event position
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native checkbox inside a <label>, so "Snap to grid" is its name. The blocks are aria-hidden and keyboard focus outlines them. Under prefers-reduced-motion nothing drifts and the view jumps between its two angles.