Subframe
Animation & motionAnamorphic perspective alignment

One exact viewpoint

White fragments painted across three black depth planes resolve into one divided ring at a precise viewpoint.

Drag the planes or use the Viewpoint range, then press Align to reach the registration point.

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 *
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react"

type Config = { planeSeparation: number; viewRange: number; snapRadius: number; snapTime: number }
export const DEFAULT_CONFIG: Config = { planeSeparation: 0.6, viewRange: 30, snapRadius: 3, snapTime: 180 }

type Point = [number, number]
type Plane = { left: number; right: number; depth: number }
const PLANES: Plane[] = [
  { left: -130, right: -43, depth: -150 },
  { left: -43, right: 43, depth: 0 },
  { left: 43, right: 130, depth: 150 },
]
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value))
const circle = (radius: number): Point[] =>
  Array.from({ length: 96 }, (_, index) => {
    const angle = (index / 96) * Math.PI * 2
    return [Math.cos(angle) * radius, Math.sin(angle) * radius]
  })
const OUTLINE = circle(94)
const COUNTER = circle(58)
const CUT: Point[] = [
  [-98, -15],
  [98, -15],
  [98, 15],
  [-98, 15],
]

// Each fragment is the inverse projection of its desired registered silhouette.
function project(point: Point, depth: number, angle: number): Point {
  const distance = 600
  const radians = (angle * Math.PI) / 180
  const x = (point[0] * (distance - depth)) / distance
  const y = (point[1] * (distance - depth)) / distance
  const viewX = x * Math.cos(radians) + depth * Math.sin(radians)
  const viewZ = depth * Math.cos(radians) - x * Math.sin(radians)
  return [300 + (0.86 * distance * viewX) / (distance - viewZ), 170 + (0.86 * distance * y) / (distance - viewZ)]
}
const path = (points: Point[], depth: number, angle: number) =>
  points.map((point, index) => `${index ? "L" : "M"}${project(point, depth, angle).join(",")}`).join("") + "Z"

export default function AnamorphicRegistration({
  active = true,
  config,
}: {
  active?: boolean
  config?: Partial<Config>
}) {
  const settings = { ...DEFAULT_CONFIG, ...config }
  const planeSeparation = clamp(
    Number.isFinite(settings.planeSeparation) ? settings.planeSeparation : DEFAULT_CONFIG.planeSeparation,
    0.2,
    1,
  )
  const viewRange = clamp(Number.isFinite(settings.viewRange) ? settings.viewRange : DEFAULT_CONFIG.viewRange, 10, 50)
  const snapRadius = clamp(Number.isFinite(settings.snapRadius) ? settings.snapRadius : DEFAULT_CONFIG.snapRadius, 0, 6)
  const snapTime = clamp(Number.isFinite(settings.snapTime) ? settings.snapTime : DEFAULT_CONFIG.snapTime, 100, 300)
  const id = useId().replace(/:/g, "")
  const [reduced, setReduced] = useState(true)
  const [aligned, setAligned] = useState(false)
  const [announcement, setAnnouncement] = useState("")
  const [animation, setAnimation] = useState(0)
  const scene = useRef<SVGSVGElement>(null)
  const slider = useRef<HTMLInputElement>(null)
  const readout = useRef<HTMLOutputElement>(null)
  const angle = useRef(Math.min(22, viewRange))
  const outlines = useRef(new Map<number, SVGPathElement>())
  const surfaces = useRef(new Map<number, SVGPathElement>())
  const marks = useRef(new Map<number, SVGPathElement>())
  const cuts = useRef(new Map<number, SVGPathElement>())
  const edges = useRef(new Map<number, SVGPathElement>())
  const tween = useRef<{ from: number; time: number; duration: number } | null>(null)
  const drag = useRef<{ pointer: number; x: number; start: number } | null>(null)
  const options = useRef({ planeSeparation, viewRange, snapRadius, snapTime, reduced })
  options.current = { planeSeparation, viewRange, snapRadius, snapTime, reduced }

  const paint = useCallback((value: number) => {
    angle.current = clamp(value, -options.current.viewRange, options.current.viewRange)
    const yaw = angle.current
    for (let index = 0; index < PLANES.length; index++) {
      const plane = PLANES[index]
      const depth = plane.depth * options.current.planeSeparation
      const corners: Point[] = [
        [plane.left, -107],
        [plane.right, -107],
        [plane.right, 107],
        [plane.left, 107],
      ]
      const face = path(corners, depth, yaw)
      outlines.current.get(index)?.setAttribute("d", face)
      surfaces.current.get(index)?.setAttribute("d", face)
      marks.current.get(index)?.setAttribute("d", path(OUTLINE, depth, yaw) + path(COUNTER, depth, yaw))
      cuts.current.get(index)?.setAttribute("d", path(CUT, depth, yaw))
      const side = yaw < 0 ? plane.left : plane.right
      const top = project([side, -107], depth, yaw)
      const bottom = project([side, 107], depth, yaw)
      const offset = Math.sin((yaw * Math.PI) / 180) * 8
      edges.current.get(index)?.setAttribute("d", `M${top}L${bottom}l${offset},3L${top[0] + offset},${top[1] + 3}Z`)
    }
    if (slider.current) {
      slider.current.value = String(yaw)
    }
    if (readout.current) {
      readout.current.textContent = `${Math.abs(yaw) < 0.05 ? "0.0" : yaw.toFixed(1)}°`
    }
    setAligned(Math.abs(yaw) < 0.05)
  }, [])

  useEffect(() => {
    const media = matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(media.matches)
    update()
    media.addEventListener("change", update)
    return () => media.removeEventListener("change", update)
  }, [])

  useLayoutEffect(() => {
    paint(angle.current)
  }, [planeSeparation, viewRange, paint])

  useEffect(() => {
    if (!active && drag.current) {
      const pointer = drag.current.pointer
      drag.current = null
      if (scene.current?.hasPointerCapture(pointer)) {
        scene.current.releasePointerCapture(pointer)
      }
    }
  }, [active])

  useEffect(() => {
    if (reduced && tween.current) {
      tween.current = null
      paint(0)
      setAnnouncement("The three fragments are aligned into one mark.")
    }
  }, [reduced, paint])

  useEffect(() => {
    if (!active || !tween.current || reduced) {
      return
    }
    let frame = 0
    let last: number | undefined
    const tick = (now: number) => {
      const current = tween.current
      if (!current) {
        return
      }
      if (last !== undefined) {
        current.time += now - last
      }
      last = now
      const progress = clamp(current.time / current.duration, 0, 1)
      paint(current.from * Math.pow(1 - progress, 3))
      if (progress < 1) {
        frame = requestAnimationFrame(tick)
      } else {
        tween.current = null
        setAnnouncement("The three fragments are aligned into one mark.")
      }
    }
    const visibility = () => {
      cancelAnimationFrame(frame)
      last = undefined
      if (!document.hidden && tween.current) {
        frame = requestAnimationFrame(tick)
      }
    }
    visibility()
    document.addEventListener("visibilitychange", visibility)
    return () => {
      cancelAnimationFrame(frame)
      document.removeEventListener("visibilitychange", visibility)
    }
  }, [active, animation, reduced, paint])

  const align = () => {
    if (!active) {
      return
    }
    setAnnouncement("")
    if (reduced) {
      tween.current = null
      paint(0)
      setAnnouncement("The three fragments are aligned into one mark.")
    } else {
      tween.current = { from: angle.current, time: 0, duration: options.current.snapTime }
      setAnimation((value) => value + 1)
    }
  }
  const move = (value: number) => {
    if (!active) {
      return
    }
    tween.current = null
    setAnnouncement("")
    paint(value)
  }
  const finishDrag = (pointer: number) => {
    if (drag.current?.pointer !== pointer) {
      return
    }
    drag.current = null
    if (Math.abs(angle.current) <= options.current.snapRadius) {
      align()
    }
  }

  return (
    <section className="sf-anamorphic-registration" aria-label="Perspective registration">
      <header>
        <span>{aligned ? "One mark." : "Three planes."}</span>
        <output ref={readout} aria-hidden="true">
          22.0°
        </output>
      </header>
      <svg
        ref={scene}
        viewBox="0 0 600 375"
        role="img"
        aria-label={
          aligned
            ? "A single white divided ring, aligned across three depth planes."
            : "White ring fragments distributed across three separated black planes."
        }
        onPointerDown={(event) => {
          if (!active || event.button !== 0) {
            return
          }
          event.preventDefault()
          tween.current = null
          drag.current = { pointer: event.pointerId, x: event.clientX, start: angle.current }
          event.currentTarget.setPointerCapture(event.pointerId)
        }}
        onPointerMove={(event) => {
          if (!active || drag.current?.pointer !== event.pointerId) {
            return
          }
          const width = event.currentTarget.getBoundingClientRect().width
          move(drag.current.start + ((event.clientX - drag.current.x) / width) * options.current.viewRange * 3)
        }}
        onPointerUp={(event) => {
          finishDrag(event.pointerId)
          if (event.currentTarget.hasPointerCapture(event.pointerId)) {
            event.currentTarget.releasePointerCapture(event.pointerId)
          }
        }}
        onPointerCancel={(event) => {
          if (drag.current?.pointer === event.pointerId) {
            drag.current = null
          }
        }}
        onLostPointerCapture={(event) => {
          if (drag.current?.pointer === event.pointerId) {
            drag.current = null
          }
        }}
      >
        <defs>
          {PLANES.map((_, index) => (
            <clipPath id={`${id}-plane-${index}`} key={index}>
              <path
                ref={(node) => {
                  if (node) {
                    outlines.current.set(index, node)
                  } else {
                    outlines.current.delete(index)
                  }
                }}
              />
            </clipPath>
          ))}
        </defs>
        <g>
          {PLANES.map((_, index) => (
            <g key={index}>
              <path
                fill="#696761"
                ref={(node) => {
                  if (node) {
                    edges.current.set(index, node)
                  } else {
                    edges.current.delete(index)
                  }
                }}
              />
              <path
                fill="#171918"
                ref={(node) => {
                  if (node) {
                    surfaces.current.set(index, node)
                  } else {
                    surfaces.current.delete(index)
                  }
                }}
              />
              <g clipPath={`url(#${id}-plane-${index})`}>
                <path
                  fill="#fffdf5"
                  fillRule="evenodd"
                  ref={(node) => {
                    if (node) {
                      marks.current.set(index, node)
                    } else {
                      marks.current.delete(index)
                    }
                  }}
                />
                <path
                  fill="#171918"
                  ref={(node) => {
                    if (node) {
                      cuts.current.set(index, node)
                    } else {
                      cuts.current.delete(index)
                    }
                  }}
                />
              </g>
            </g>
          ))}
        </g>
        <path d="M294 47h12M300 41v12" stroke="#bc3c2b" strokeWidth="1.5" />
        <path d="M294 293h12M300 287v12" stroke="#bc3c2b" strokeWidth="1.5" />
      </svg>
      <footer>
        <label>
          <span>Viewpoint</span>
          <input
            ref={slider}
            type="range"
            disabled={!active}
            min={-viewRange}
            max={viewRange}
            step="0.1"
            defaultValue={Math.min(22, viewRange)}
            aria-label="Viewpoint in degrees"
            onChange={(event) => move(Number(event.target.value))}
            onPointerUp={() => {
              if (Math.abs(angle.current) <= snapRadius) {
                align()
              }
            }}
          />
        </label>
        <button type="button" disabled={!active} onClick={align}>
          <span aria-hidden="true">⌖</span>Align
        </button>
      </footer>
      <p className="ar-sr" aria-live="polite" aria-atomic="true">
        {announcement}
      </p>
      <style>{`.sf-anamorphic-registration{position:relative;width:100%;height:100%;min-height:300px;overflow:hidden;background:#d9d7ce;color:#242622;font-family:Arial,Helvetica,sans-serif;container-type:inline-size}.sf-anamorphic-registration *{box-sizing:border-box}.sf-anamorphic-registration header{position:absolute;z-index:1;left:7%;right:7%;top:8%;display:flex;justify-content:space-between;pointer-events:none;font-size:12px;font-weight:600}.sf-anamorphic-registration output{font:11px ui-monospace,monospace;font-variant-numeric:tabular-nums}.sf-anamorphic-registration svg{display:block;width:100%;height:100%;touch-action:pan-y;cursor:ew-resize;user-select:none}.sf-anamorphic-registration footer{position:absolute;left:7%;right:7%;bottom:5%;display:flex;align-items:center;justify-content:space-between;gap:32px}.sf-anamorphic-registration label{display:flex;align-items:center;gap:16px;flex:1;min-width:0;height:44px;font-size:11px;color:#50524a}.sf-anamorphic-registration input{width:100%;min-width:40px;height:44px;margin:0;accent-color:#a73628;cursor:ew-resize}.sf-anamorphic-registration input:focus-visible{outline:2px solid #a73628;outline-offset:4px;border-radius:2px}.sf-anamorphic-registration button{display:flex;align-items:center;justify-content:center;gap:10px;min-height:44px;min-width:96px;padding:0 14px;border:0;border-radius:2px;color:#fffdf5;background:#a73628;font:12px Arial,sans-serif;cursor:pointer;touch-action:manipulation}.sf-anamorphic-registration button>span{font-size:23px;line-height:1}.sf-anamorphic-registration button:focus-visible{outline:2px solid #171918;outline-offset:3px}.sf-anamorphic-registration button:disabled{opacity:.5;cursor:default}.sf-anamorphic-registration input:disabled{opacity:.5;cursor:default}.sf-anamorphic-registration button:active:not(:disabled){background:#842b20}.sf-anamorphic-registration .ar-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}@media(hover:hover) and (pointer:fine){.sf-anamorphic-registration button:hover{background:#922d23}}@container(max-width:400px){.sf-anamorphic-registration footer{gap:16px}.sf-anamorphic-registration label{gap:8px}.sf-anamorphic-registration button{min-width:80px}}`}</style>
    </section>
  )
}

Where it works well

Reveal a geometric identity through spatial alignment, or explain how a viewpoint unifies separated artwork.

Reduced motion

Viewpoint changes remain direct; Align and near-target release snap immediately without camera travel.

Things to know

Three original planar fragments with a horizontal analytic camera orbit. SVG represents perspective geometry; the small edge extrusion is illustrative, with no full 3D lighting, imported model, or free camera.

More examples