Cube windows
A box turns in 3D as the section scrolls by and tilts to the pointer; its outline becomes the clip-path of the photo inside. The photo itself never moves.
<div class="cube-window relative aspect-[4/3] overflow-hidden rounded-xl">
<img src="photo.jpg" alt="A photo of the studio" class="block h-full w-full object-cover">
</div>
<!-- same mount script as the CSS tab; Tailwind only styles the box and the image -->
<div class="cube-window">
<img src="photo.jpg" alt="A photo of the studio">
</div>
<script type="module">
import { track, prefersReducedMotion, onMotionChange } from 'scrollvars'
// mask3d-core: the projection math. Kept commented in its own TypeScript
// source (scripts/fx-lib/mask3d-core.ts in the scrollvars repo, unit-tested
// there); this is the plain-JS build of it, so read the source for why the
// union of faces works if you are going to edit this.
function corners(box, pose, focal) {
const { w, h, d } = box;
const { ry, rx } = pose;
const cosY = Math.cos(ry), sinY = Math.sin(ry);
const cosX = Math.cos(rx), sinX = Math.sin(rx);
const pts = [];
for (let i = 0; i < 8; i++) {
let x = (i & 1 ? 0.5 : -0.5) * w, y = (i & 2 ? 0.5 : -0.5) * h, z = (i & 4 ? 0.5 : -0.5) * d;
[x, z] = [x * cosY + z * sinY, -x * sinY + z * cosY];
[y, z] = [y * cosX - z * sinX, y * sinX + z * cosX];
const s = focal / (focal + z);
pts.push([x * s, y * s]);
}
return pts;
}
function hull(points) {
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const cross = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const half = (list) => {
const out = [];
for (const q of list) {
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop();
out.push(q);
}
return out.slice(0, -1);
};
return [...half(sorted), ...half([...sorted].reverse())];
}
const r1 = (n) => Math.round(n * 10) / 10;
function toClipPath(points, ox, oy, scale) {
return `polygon(${points.map(([x, y]) => `${r1(ox + x * scale)}px ${r1(oy + y * scale)}px`).join(",")})`;
}
function bounds(box, poses, focal) {
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
for (const pose of poses) {
for (const [x, y] of corners(box, pose, focal)) {
x0 = Math.min(x0, x);
x1 = Math.max(x1, x);
y0 = Math.min(y0, y);
y1 = Math.max(y1, y);
}
}
return { x0, x1, y0, y1 };
}
function cubeWindowNumbers(value, count) {
const list = String(value ?? '').trim().split(/\s+/).map(Number)
return list.length === count && list.every(Number.isFinite) ? list : null
}
function mountCubeWindow(el, boxSize, turn) {
// Below Safari 13.1 without compat(): fail visible, not fail loud. No
// clip-path is ever set, so the whole photo stays visible, same contract
// as the no-JS/old-engine case below.
if (typeof ResizeObserver !== 'function') return () => {}
const FOCAL = 2.4, HOVER = { y: .5, x: .35 }, MARGIN = 6, EASE = .12
const box3 = { w: boxSize[0], h: boxSize[1], d: boxSize[2] }
const [ry0, ry1, rx0, rx1] = turn
const poseAt = (t, hx, hy) => ({
ry: ry0 + (ry1 - ry0) * t + hx * HOVER.y,
rx: rx0 + (rx1 - rx0) * t - hy * HOVER.x,
})
let t = .5, fit = null, last = '', raf = 0
const hover = { x: 0, y: 0 }, target = { x: 0, y: 0 }
// Every pose the scroll travel times the pointer tilt can reach, so the
// element fits the shape without cutting a corner off at either extreme.
const refit = () => {
const w = el.clientWidth, h = el.clientHeight
if (!w || !h || (fit && fit.w === w && fit.h === h)) return false
const poses = []
for (let s = 0; s <= 20; s++)
for (const hx of [-1, 0, 1]) for (const hy of [-1, 0, 1]) poses.push(poseAt(s / 20, hx, hy))
const b = bounds(box3, poses, FOCAL)
const k = Math.min((w - MARGIN * 2) / (b.x1 - b.x0), (h - MARGIN * 2) / (b.y1 - b.y0))
fit = { w, h, k, ox: w / 2 - ((b.x0 + b.x1) / 2) * k, oy: h / 2 - ((b.y0 + b.y1) / 2) * k }
return true
}
const draw = () => {
if (!fit) return
const still = prefersReducedMotion()
const pose = poseAt(still ? .5 : t, still ? 0 : hover.x, still ? 0 : hover.y)
const clip = toClipPath(hull(corners(box3, pose, FOCAL)), fit.ox, fit.oy, fit.k)
if (clip !== last) { el.style.clipPath = clip; last = clip }
}
// Pointer inertia is the only loop of our own, and only while it settles.
const settle = () => {
raf = 0
hover.x += (target.x - hover.x) * EASE
hover.y += (target.y - hover.y) * EASE
draw()
if (Math.abs(target.x - hover.x) + Math.abs(target.y - hover.y) > .002) raf = requestAnimationFrame(settle)
}
const onMove = (e) => {
if (prefersReducedMotion()) return
const r = el.getBoundingClientRect()
target.x = ((e.clientX - r.left) / r.width) * 2 - 1
target.y = ((e.clientY - r.top) / r.height) * 2 - 1
if (!raf) raf = requestAnimationFrame(settle)
}
const onLeave = () => { target.x = target.y = 0; if (!raf) raf = requestAnimationFrame(settle) }
const ro = new ResizeObserver(() => { if (refit()) { last = ''; draw() } })
ro.observe(el)
refit()
draw()
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerleave', onLeave)
const stopMotion = onMotionChange(() => { hover.x = hover.y = target.x = target.y = 0; draw() })
// scrollvars: travel t (0..1) through the viewport, from its shared frame loop
const stopTrack = track(el, { view: false, onTravel: (v) => { t = v; draw() } })
return () => {
stopTrack(); stopMotion(); ro.disconnect()
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave)
if (raf) cancelAnimationFrame(raf)
el.style.clipPath = '' // no JS / stopped: the whole photo shows again
}
}
mountCubeWindow(
document.querySelector('.cube-window'),
[.9, .5, .5], // width, height, depth: a share of the element
[-1.4, .35, .8, -.1] // ry0 ry1 rx0 rx1 (radians), across the scroll travel
)
</script>
.cube-window { position: relative; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 12px; }
.cube-window img { display: block; width: 100%; height: 100%; object-fit: cover; }
/* no JS / old engines: no clip-path is ever set, so the whole photo stays visible */
// From the frontend package root (for example, apps/frontend):
// npm i scrollvars
// npx scrollvars add cube-windows --dir src/components/fx
// Creates src/components/fx/CubeWindows.tsx. Then, in src/app/page.tsx:
import { CubeWindows } from '../components/fx/CubeWindows'
function Portrait() {
return (
<CubeWindows box={[.9, .5, .5]} turn={[-1.4, .35, .8, -.1]}>
<img src="photo.jpg" alt="A photo of the studio" />
</CubeWindows>
)
}
// CSS is included in the copied component. Install once; reuse it anywhere.
Full CubeWindows.tsx source (copy without CLI)
Save this as src/components/fx/CubeWindows.tsx in the frontend package.
// ScrollVars fx · cube-windows
// Requires: npm i scrollvars · no preset stylesheet required
// A box turns in 3D as the section scrolls by and tilts to the pointer; its
// outline becomes the clip-path of whatever this renders inside it. The
// content itself never moves, it is only seen through the window.
//
// The projection math below (mask3d-core) is a self-contained, dependency-free
// block: copy it once per project even if you use it in more than one place,
// or lift it into its own module. It is unit-tested on its own upstream
// (test/mask3d-core.test.mjs in the scrollvars repo) before it lands here.
'use client'
import * as React from 'react'
import { onMotionChange, prefersReducedMotion, track } from 'scrollvars'
/**
* cube-windows' projection math: a box turns in 3D, its 8 corners project to
* 2D, and their convex hull is the outline. Correct for any CONVEX solid
* with no holes; a concave shape or one with a hole (glyph letters, a star)
* needs the projected faces themselves unioned instead, which costs more.
*
* Measured against that faces-union approach on this exact box recipe
* (demo/bench/harness/mask3d-cost.mjs, three runs, 300 boxes, 240 frames):
* the hull ran consistently cheaper, about a quarter less per-frame task
* time, and produces the identical silhouette for a plain box (checked by
* nonzero-winding point-in-path sampling, not eyeballed). The simpler,
* cheaper solid wins for the one recipe that exists; nothing here is kept
* for a shape that has not shipped.
*
* This module is the source of truth: it is unit-tested directly (compiled
* in test/mask3d-core.test.mjs with the repo's own esbuild, the same way the
* fx gallery's plain-JS pane is derived from it, scripts/fx-data.mjs) so a
* change here proves itself before it goes back into a live page.
*/
type Point = [number, number]
type Box = { w: number; h: number; d: number }
type Pose = { ry: number; rx: number }
// The 8 corners of the box, turned by `pose` and projected with perspective
// `focal`. Element-width units in, element-width units out (times the
// perspective scale each corner earns from its own depth).
function corners(box: Box, pose: Pose, focal: number): Point[] {
const { w, h, d } = box
const { ry, rx } = pose
const cosY = Math.cos(ry), sinY = Math.sin(ry)
const cosX = Math.cos(rx), sinX = Math.sin(rx)
const pts: Point[] = []
for (let i = 0; i < 8; i++) {
let x = (i & 1 ? .5 : -.5) * w, y = (i & 2 ? .5 : -.5) * h, z = (i & 4 ? .5 : -.5) * d
;[x, z] = [x * cosY + z * sinY, -x * sinY + z * cosY]
;[y, z] = [y * cosX - z * sinX, y * sinX + z * cosX]
const s = focal / (focal + z)
pts.push([x * s, y * s])
}
return pts
}
// The outline of a convex solid is the convex hull of its corners (monotone
// chain, Andrew's algorithm): 4 points face-on, up to 6 from a corner-on
// turn. Any orientation works, no area or winding bookkeeping needed.
function hull(points: Point[]): Point[] {
const sorted = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1])
const cross = (o: Point, a: Point, b: Point) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
const half = (list: Point[]): Point[] => {
const out: Point[] = []
for (const q of list) {
while (out.length >= 2 && cross(out[out.length - 2], out[out.length - 1], q) <= 0) out.pop()
out.push(q)
}
return out.slice(0, -1)
}
return [...half(sorted), ...half([...sorted].reverse())]
}
const r1 = (n: number): number => Math.round(n * 10) / 10
function toClipPath(points: Point[], ox: number, oy: number, scale: number): string {
return `polygon(${points.map(([x, y]) => `${r1(ox + x * scale)}px ${r1(oy + y * scale)}px`).join(',')})`
}
// The extent every corner can reach across `poses`, so a recipe can fit the
// box inside its element without cutting a corner off at any reachable turn.
function bounds(box: Box, poses: Pose[], focal: number) {
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity
for (const pose of poses) {
for (const [x, y] of corners(box, pose, focal)) {
x0 = Math.min(x0, x); x1 = Math.max(x1, x)
y0 = Math.min(y0, y); y1 = Math.max(y1, y)
}
}
return { x0, x1, y0, y1 }
}
const css = `
.cube-window { position: relative; aspect-ratio: 4 / 3; overflow: hidden; border-radius: 12px; }
.cube-window img, .cube-window video { display: block; width: 100%; height: 100%; object-fit: cover; }
/* no JS / old engines: no clip-path is ever set, so the content stays fully visible */
`
// Same recipe as the CSS tab's mountCubeWindow, in React idiom: an effect
// owns the whole lifecycle (fit, pointer inertia, scroll travel, reduced
// motion, cleanup), the component only renders the box and its content.
function useCubeWindow(ref: React.RefObject<HTMLDivElement | null>, boxSize: [number, number, number], turn: [number, number, number, number]) {
React.useEffect(() => {
const el = ref.current
if (!el) return
// Below Safari 13.1 without compat(): fail visible, not fail loud. An
// unguarded ResizeObserver here throws and unmounts the React root,
// the opposite of the content-stays-whole contract this recipe
// documents everywhere else.
if (typeof ResizeObserver !== 'function') return
const FOCAL = 2.4, HOVER = { y: .5, x: .35 }, MARGIN = 6, EASE = .12
const box3: Box = { w: boxSize[0], h: boxSize[1], d: boxSize[2] }
const [ry0, ry1, rx0, rx1] = turn
const poseAt = (t: number, hx: number, hy: number): Pose => ({
ry: ry0 + (ry1 - ry0) * t + hx * HOVER.y,
rx: rx0 + (rx1 - rx0) * t - hy * HOVER.x,
})
let t = .5, fit: { w: number; h: number; k: number; ox: number; oy: number } | null = null, last = '', raf = 0
const hover = { x: 0, y: 0 }, target = { x: 0, y: 0 }
const refit = () => {
const w = el.clientWidth, h = el.clientHeight
if (!w || !h || (fit && fit.w === w && fit.h === h)) return false
const poses: Pose[] = []
for (let s = 0; s <= 20; s++)
for (const hx of [-1, 0, 1]) for (const hy of [-1, 0, 1]) poses.push(poseAt(s / 20, hx, hy))
const b = bounds(box3, poses, FOCAL)
const k = Math.min((w - MARGIN * 2) / (b.x1 - b.x0), (h - MARGIN * 2) / (b.y1 - b.y0))
fit = { w, h, k, ox: w / 2 - ((b.x0 + b.x1) / 2) * k, oy: h / 2 - ((b.y0 + b.y1) / 2) * k }
return true
}
const draw = () => {
if (!fit) return
const still = prefersReducedMotion()
const pose = poseAt(still ? .5 : t, still ? 0 : hover.x, still ? 0 : hover.y)
const clip = toClipPath(hull(corners(box3, pose, FOCAL)), fit.ox, fit.oy, fit.k)
if (clip !== last) { el.style.clipPath = clip; last = clip }
}
const settle = () => {
raf = 0
hover.x += (target.x - hover.x) * EASE
hover.y += (target.y - hover.y) * EASE
draw()
if (Math.abs(target.x - hover.x) + Math.abs(target.y - hover.y) > .002) raf = requestAnimationFrame(settle)
}
const onMove = (e: PointerEvent) => {
if (prefersReducedMotion()) return
const r = el.getBoundingClientRect()
target.x = ((e.clientX - r.left) / r.width) * 2 - 1
target.y = ((e.clientY - r.top) / r.height) * 2 - 1
if (!raf) raf = requestAnimationFrame(settle)
}
const onLeave = () => { target.x = target.y = 0; if (!raf) raf = requestAnimationFrame(settle) }
const ro = new ResizeObserver(() => { if (refit()) { last = ''; draw() } })
ro.observe(el)
refit()
draw()
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerleave', onLeave)
const stopMotion = onMotionChange(() => { hover.x = hover.y = target.x = target.y = 0; draw() })
const stopTrack = track(el, { view: false, onTravel: (v) => { t = v; draw() } })
return () => {
stopTrack(); stopMotion(); ro.disconnect()
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave)
if (raf) cancelAnimationFrame(raf)
el.style.clipPath = ''
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boxSize[0], boxSize[1], boxSize[2], turn[0], turn[1], turn[2], turn[3]])
}
export function CubeWindows({
box: boxSize = [.9, .5, .5],
turn = [-1.4, .35, .8, -.1],
className,
children,
nonce,
}: {
/** Width, height, depth of the box, as a share of the element's width. */
box?: [number, number, number]
/** ry from, ry to, rx from, rx to (radians), across the element's scroll travel. */
turn?: [number, number, number, number]
className?: string
children?: React.ReactNode
nonce?: string
}) {
const ref = React.useRef<HTMLDivElement>(null)
useCubeWindow(ref, boxSize, turn)
return (
<div ref={ref} className={className ? 'cube-window ' + className : 'cube-window'}>
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: css }} />
{children}
</div>
)
}