Subframe
Animation & motionExploded product view

Inside the lens

A graphite camera separates into a silver mount, an iris and a coated glass element along one continuous axis.

Click the camera or the Explode view control to separate or assemble the optics.

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 { useEffect, useId, useState } from "react"

// Every surface uses the same orthographic camera: 35° yaw, 12° elevation.
const yaw = (35 * Math.PI) / 180
const elevation = (12 * Math.PI) / 180
const basis = {
  xx: Math.cos(yaw),
  xy: Math.sin(yaw) * Math.sin(elevation),
  yy: Math.cos(elevation),
  zx: -Math.sin(yaw),
  zy: Math.cos(yaw) * Math.sin(elevation),
}
const project = (x: number, y: number, z: number) => [
  basis.xx * x + basis.zx * z,
  basis.xy * x + basis.yy * y + basis.zy * z,
]
const plane = (x: number, y: number, z: number) => {
  const [tx, ty] = project(x, y, z)
  return `matrix(${basis.xx},${basis.xy},0,${basis.yy},${tx},${ty})`
}
const face = (points: number[][]) => points.map(([x, y, z]) => project(x, y, z).join(",")).join(" ")
const ring = (outer: number, inner: number) =>
  `M${-outer},0a${outer},${outer} 0 1,0 ${outer * 2},0a${outer},${outer} 0 1,0 ${
    -outer * 2
  },0 M${-inner},0a${inner},${inner} 0 1,1 ${inner * 2},0a${inner},${inner} 0 1,1 ${-inner * 2},0`
const irisShape = "M-43 0a43 43 0 1 0 86 0a43 43 0 1 0 -86 0M0 -17l14.72 8.5v17L0 17l-14.72-8.5v-17Z"
const bodyOutline = "-106,-53 -97,-63 97,-63 106,-54 106,54 97,63 -97,63 -106,54"

export default function ExplodedCamera({ active = true }: { active?: boolean }) {
  const uid = useId().replace(/:/g, "")
  const [reduced, setReduced] = useState(true)
  const [instant, setInstant] = useState(false)
  const [exploded, setExploded] = useState(false)
  useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setReduced(q.matches)
    sync()
    q.addEventListener("change", sync)
    return () => q.removeEventListener("change", sync)
  }, [])
  const toggle = (detail: number) => {
    if (!active) return
    setInstant(detail === 0)
    setExploded((v) => !v)
  }
  const opticalDepth = exploded ? [12, 144, 276] : [7, 23, 43]
  return (
    <section className="sf-exploded-camera" data-still={!active || reduced || instant} data-open={exploded}>
      <button
        type="button"
        className="ec-object"
        aria-label={exploded ? "Assemble camera" : "Explode camera"}
        aria-pressed={exploded}
        disabled={!active}
        onClick={(e) => toggle(e.detail)}
      >
        <svg viewBox="0 0 600 285" aria-hidden="true">
          <defs>
            <linearGradient id={`${uid}-body`} x1="0" x2=".3" y1="0" y2="1">
              <stop stopColor="#535d60" />
              <stop offset=".44" stopColor="#30383b" />
              <stop offset="1" stopColor="#202729" />
            </linearGradient>
            <linearGradient id={`${uid}-metal`} x1="0" x2=".8" y1="0" y2="1">
              <stop stopColor="#f2f3eb" />
              <stop offset=".32" stopColor="#b2b9b8" />
              <stop offset=".63" stopColor="#687678" />
              <stop offset="1" stopColor="#d6ded9" />
            </linearGradient>
            <linearGradient id={`${uid}-iris`} x1="0" x2="1" y1="0" y2="1">
              <stop stopColor="#737c7c" />
              <stop offset="1" stopColor="#252e32" />
            </linearGradient>
            <radialGradient id={`${uid}-glass`} cx="32%" cy="22%" r="86%">
              <stop stopColor="#86aea5" />
              <stop offset=".43" stopColor="#21434a" />
              <stop offset="1" stopColor="#111e28" />
            </radialGradient>
            <filter id={`${uid}-shadow`} x="-50%" y="-200%" width="200%" height="500%">
              <feGaussianBlur stdDeviation="8" />
            </filter>
          </defs>
          <ellipse
            cx="310"
            cy="232"
            rx={exploded ? 157 : 99}
            ry="9"
            fill="#33454a"
            opacity=".16"
            filter={`url(#${uid}-shadow)`}
          />
          <g
            className="ec-part"
            style={{ transform: `translate(${exploded ? 360 : 311}px, ${exploded ? 119 : 137}px)` }}
          >
            <polygon
              points={bodyOutline}
              transform={plane(0, 0, -25)}
              fill="#263135"
              stroke="#566469"
              strokeWidth=".6"
            />
            <polygon
              points={face([
                [-97, -63, 0],
                [-97, -63, -25],
                [97, -63, -25],
                [106, -54, -25],
                [106, -54, 0],
                [97, -63, 0],
              ])}
              fill="#798589"
              stroke="#8e999b"
              strokeWidth=".6"
            />
            <polygon
              points={face([
                [106, -54, 0],
                [106, -54, -25],
                [106, 54, -25],
                [97, 63, -25],
                [97, 63, 0],
                [106, 54, 0],
              ])}
              fill="#394448"
              stroke="#536064"
              strokeWidth=".6"
            />
            <g className="ec-plane" transform={plane(0, 0, 0)}>
              <polygon
                points={bodyOutline}
                fill={`url(#${uid}-body)`}
                stroke="#657276"
                strokeWidth=".7"
                strokeLinejoin="round"
              />
              <rect x="-82" y="-46" width="29" height="14" rx="2" fill="#131e22" stroke="#8e9c9c" strokeWidth=".8" />
              <rect x="-78" y="-42" width="21" height="6" rx="1" fill="#54716f" />
              <circle cx="-12" cy="6" r="47" fill="#172327" stroke="#8d9999" strokeWidth="1.2" />
              <rect x="-33" y="-9" width="42" height="30" rx="1" fill="#496567" />
              <path d="M-32 -8h40" stroke="#819799" strokeWidth=".8" />
            </g>
            {opticalDepth.map((z, i) => (
              <g
                className="ec-part ec-optic"
                data-layer={i}
                key={i}
                style={{ transform: `translate(${basis.zx * z}px,${basis.zy * z}px)` }}
              >
                <g className="ec-plane" transform={plane(-12, 6, -4)}>
                  <path
                    d={i === 1 ? irisShape : ring(49, 40)}
                    fillRule="evenodd"
                    fill="#243236"
                    stroke="#344449"
                    strokeWidth=".5"
                  />
                </g>
                <g className="ec-plane" transform={plane(-12, 6, 0)}>
                  {i === 0 && (
                    <path
                      d={ring(49, 40)}
                      fill={`url(#${uid}-metal)`}
                      fillRule="evenodd"
                      stroke="#d2dad6"
                      strokeWidth=".6"
                    />
                  )}
                  {i === 1 && (
                    <>
                      <path
                        d={irisShape}
                        fill={`url(#${uid}-iris)`}
                        fillRule="evenodd"
                        stroke="#a0acab"
                        strokeWidth=".65"
                      />
                      {[0, 60, 120, 180, 240, 300].map((a) => (
                        <path
                          key={a}
                          d="M0 -17L29 -31"
                          transform={`rotate(${a})`}
                          stroke="#19282c"
                          strokeWidth=".65"
                          opacity=".7"
                        />
                      ))}
                    </>
                  )}
                  {i === 2 && (
                    <>
                      <circle r="49" fill="#273236" stroke="#aab8b6" strokeWidth=".65" />
                      <circle r="42" fill={`url(#${uid}-glass)`} stroke="#6f8e8c" strokeWidth="1" />
                      <path
                        d="M-32 -19A37 37 0 0 1 15 -34"
                        fill="none"
                        stroke="#d0e7de"
                        strokeWidth="1.3"
                        strokeLinecap="round"
                        opacity=".65"
                      />
                    </>
                  )}
                </g>
              </g>
            ))}
          </g>
        </svg>
      </button>
      <button
        type="button"
        className="ec-control"
        aria-pressed={exploded}
        disabled={!active}
        onClick={(e) => toggle(e.detail)}
      >
        {exploded ? "Assemble" : "Explode view"}
        <span aria-hidden="true">{exploded ? "−" : "+"}</span>
      </button>
      <style>{`.sf-exploded-camera{position:relative;width:100%;height:100%;min-height:280px;overflow:hidden;background:radial-gradient(ellipse at 44% 35%,#f3f5f2,#ced8dd);color:#35464e;font-family:Arial,sans-serif}.sf-exploded-camera *{box-sizing:border-box}.sf-exploded-camera button{touch-action:manipulation;cursor:pointer;color:inherit}.sf-exploded-camera .ec-object{position:absolute;left:0;top:23px;width:100%;height:285px;border:0;padding:0;background:none;outline-offset:-18px}.sf-exploded-camera .ec-object>svg{display:block;width:100%;height:100%;overflow:visible}.sf-exploded-camera .ec-part{transition:transform 600ms cubic-bezier(.22,1,.36,1)}.sf-exploded-camera .ec-control{position:absolute;bottom:21px;left:50%;transform:translateX(-50%);min-width:145px;height:44px;padding:0 17px;border:1px solid #a5b1b7;border-radius:24px;background:linear-gradient(#f8faf8b3,#edf1ef80);box-shadow:0 1px 1px #6b7b8217,inset 0 1px 0 #fff9;display:flex;justify-content:space-between;gap:24px;align-items:center;font:11px Arial,sans-serif}.sf-exploded-camera .ec-control span{font-size:18px;font-weight:300;line-height:1}.sf-exploded-camera .ec-control:active:not(:disabled){background:#c7d2d5}.sf-exploded-camera button:disabled{cursor:default}.sf-exploded-camera button:focus-visible{outline:2px solid #3f606e}.sf-exploded-camera .ec-control:focus-visible{outline-offset:3px}.sf-exploded-camera[data-still=true] *{transition:none!important}@media(prefers-reduced-motion:reduce){.sf-exploded-camera *{transition:none!important}}`}</style>
    </section>
  )
}

Where it works well

Explain a layered product through a clear, reversible exploded illustration.

Reduced motion

The optical assembly changes pose immediately for keyboard and reduced motion. Host pause disables both controls and settles the current pose.

Things to know

A stylized projected SVG assembly, not a physically accurate lens prescription, CAD model or manufacturing diagram. The view angle is authored and fixed.

More examples