ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

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.

Use it for: Portfolio thumbnails, about-page portraits, product shots. Anywhere a plain crop feels flat.
Knobs: box (width, height, depth, a share of the element), turn (ry0 ry1 rx0 rx1 radians across the scroll travel); pointer tilt is built in

Install: npx scrollvars add cube-windows
Styles: Included in the component

The React example imports a local file copied by the CLI, not an export from scrollvars/react. Run the command from your frontend package root; --dir is relative to that directory. For a Next app using src/, run npx scrollvars add cube-windows --dir src/components/fx before using the example below. Install it once, then reuse CubeWindows with different box, turn, className and children. The copied file does not update when the registry changes.

<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>
  )
}

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.