Anamorph

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/checkbox-anamorph
Source on GitHub

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.

More checkboxes

See all
Answer sheet
Buckle
Cat in a box
Checkmate