Subframe
Components & interactionsProduct tour spotlight

Guided inspection

A library plan moves beneath a precisely aligned spotlight as a three-stop walkthrough explores its rooms.

Start the walkthrough; advance through three points or use arrow keys. Escape exits.

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 * as React from "react"

const STOPS = [
  {
    name: "Archive",
    note: "Low shelves keep the collection within reach.",
    x: 90,
    y: 125,
    w: 90,
    h: 60,
    scale: 1.65,
  },
  {
    name: "Reading room",
    note: "North light reaches the entire shared table.",
    x: 250,
    y: 75,
    w: 150,
    h: 100,
    scale: 1.45,
  },
  {
    name: "Quiet garden",
    note: "An open edge connects the library to the trees.",
    x: 360,
    y: 200,
    w: 100,
    h: 80,
    scale: 1.55,
  },
]
export default function Example({ active = true }: { active?: boolean }) {
  const [step, setStep] = React.useState<number | null>(null),
    [keyboard, setKeyboard] = React.useState(false)
  const root = React.useRef<HTMLDivElement>(null),
    start = React.useRef<HTMLButtonElement>(null),
    next = React.useRef<HTMLButtonElement>(null)
  const selected = step === null ? null : STOPS[step],
    s = selected?.scale ?? 1
  const tx = selected ? 300 - (selected.x + selected.w / 2) * s : 0,
    ty = selected ? 130 - (selected.y + selected.h / 2) * s : 0
  const maskId = "gi-" + React.useId().replaceAll(":", "")
  function change(value: number | null) {
    setStep(value)
    if (value === null) requestAnimationFrame(() => start.current?.focus())
    else requestAnimationFrame(() => next.current?.focus())
  }
  return (
    <div
      ref={root}
      className="sf-guided-inspection"
      data-quiet={!active || keyboard}
      onPointerDownCapture={() => setKeyboard(false)}
      onKeyDownCapture={() => setKeyboard(true)}
      onKeyDown={(e) => {
        if (e.key === "Escape" && step !== null) {
          e.preventDefault()
          change(null)
        }
        if (step !== null && e.key === "ArrowRight") {
          e.preventDefault()
          change((step + 1) % 3)
        }
        if (step !== null && e.key === "ArrowLeft") {
          e.preventDefault()
          change((step + 2) % 3)
        }
      }}
    >
      <header>
        <span>Public library</span>
      </header>
      <div className="gi-stage">
        <svg className="gi-map" viewBox="0 0 600 310" aria-label="Library floor plan with three guided points">
          <g className="gi-camera" style={{ transform: `translate(${tx}px,${ty}px) scale(${s})` }}>
            <path d="M70 55H440V190H495V285H70Z" fill="#e5ede0" stroke="#23362b" strokeWidth="5" />
            <path
              d="M190 55V120M190 165V285M190 190H360M360 190V285M440 190H400"
              fill="none"
              stroke="#23362b"
              strokeWidth="5"
            />
            <path d="M70 125H60V185H70M250 55H410M440 205V270" stroke="#61755e" strokeWidth="3" fill="none" />
            <path
              d="M190 120A45 45 0 0 1 235 165H190M400 190A40 40 0 0 0 360 150V190"
              fill="none"
              stroke="#90a58a"
              strokeWidth="1"
            />
            <rect x="260" y="90" width="132" height="66" rx="4" fill="#c0cdae" stroke="#798f70" />
            {Array.from({ length: 5 }, (_, i) => (
              <g key={i} fill="#788d6c">
                <rect x={267 + i * 26} y="77" width="13" height="7" rx="1" />
                <rect x={267 + i * 26} y="163" width="13" height="7" rx="1" />
              </g>
            ))}
            {Array.from({ length: 4 }, (_, i) => (
              <g key={i}>
                <rect x="87" y={70 + i * 26} width="68" height="9" fill="#667d5f" />
                {Array.from({ length: 8 }, (_, j) => (
                  <path key={j} d={`M${91 + j * 8} ${70 + i * 26}v9`} stroke="#bed1b3" strokeWidth="1" />
                ))}
              </g>
            ))}
            {Array.from({ length: 3 }, (_, i) => (
              <g key={i}>
                <rect x={211 + i * 43} y="213" width="28" height="50" fill="#bacbad" />
                <path d={`M${225 + i * 43} 215v46`} stroke="#6d8564" />
              </g>
            ))}
            <path d="M395 223h66v39h-66Z" fill="#cedbbf" stroke="#789270" />
            {[
              { x: 423, y: 241 },
              { x: 475, y: 310 },
              { x: 514, y: 249 },
              { x: 529, y: 199 },
              { x: 500, y: 80 },
              { x: 45, y: 241 },
            ].map((p, i) => (
              <g key={i} fill="#9eb48b" stroke="#6e8762">
                <circle cx={p.x} cy={p.y} r={16 + (i % 3) * 4} />
                <path d={`M${p.x - 9} ${p.y}h18M${p.x} ${p.y - 9}v18`} strokeWidth=".5" />
              </g>
            ))}
            <text x="85" y="261" fontSize="9" fill="#526b48">
              ARCHIVE
            </text>
            <text x="260" y="181" fontSize="9" fill="#526b48">
              READING ROOM
            </text>
          </g>
          <defs>
            <mask id={maskId}>
              <rect width="600" height="310" fill="white" />
              {selected && (
                <rect
                  className="gi-focus"
                  x={selected.x * s + tx - 8}
                  y={selected.y * s + ty - 8}
                  width={selected.w * s + 16}
                  height={selected.h * s + 16}
                  rx="4"
                  fill="black"
                />
              )}
            </mask>
          </defs>
          <rect
            width="600"
            height="310"
            fill="#142519"
            opacity={selected ? 0.45 : 0}
            mask={`url(#${maskId})`}
            style={{ pointerEvents: "none" }}
          />
          {!selected &&
            STOPS.map((p, i) => (
              <g key={p.name}>
                <circle cx={p.x + p.w / 2} cy={p.y + p.h / 2} r="10" fill="#ed5a2c" />
                <text x={p.x + p.w / 2} y={p.y + p.h / 2 + 3} textAnchor="middle" fontSize="9" fill="white">
                  {i + 1}
                </text>
              </g>
            ))}
        </svg>
      </div>
      <div className="gi-controls">
        {selected ? (
          <>
            <div>
              <span>{step! + 1} / 3</span>
              <h3>{selected.name}</h3>
              <p>{selected.note}</p>
            </div>
            <div className="gi-actions">
              <button onClick={() => change(null)} aria-label="Close tour">
                ×
              </button>
              <button disabled={step === 0} onClick={() => change(step! - 1)} aria-label="Previous point">
                ←
              </button>
              <button
                ref={next}
                onClick={() => change(step === 2 ? null : step! + 1)}
                aria-label={step === 2 ? "Finish tour" : "Next point"}
              >
                {step === 2 ? "✓" : "→"}
              </button>
            </div>
          </>
        ) : (
          <>
            <button ref={start} onClick={() => change(0)}>
              Walk through <span aria-hidden="true">↗</span>
            </button>
          </>
        )}
      </div>
      <style>{`.sf-guided-inspection{width:100%;height:100%;min-height:280px;position:relative;overflow:hidden;container-type:size;background:#eff3e9;color:#20342b;font-family:Arial,sans-serif;display:flex;flex-direction:column}.sf-guided-inspection *{box-sizing:border-box}.sf-guided-inspection header{height:36px;flex-shrink:0;padding:0 20px;display:flex;align-items:center;justify-content:space-between;font-size:9px;letter-spacing:.035em}.sf-guided-inspection header span{color:#677d67}.sf-guided-inspection .gi-stage{position:relative;flex:1;min-height:0;overflow:hidden}.sf-guided-inspection .gi-map{width:100%;height:100%;display:block}.sf-guided-inspection .gi-camera{transform-origin:0 0;transition:transform .55s cubic-bezier(.22,.75,.22,1)}.sf-guided-inspection .gi-focus{transition:x .55s cubic-bezier(.22,.75,.22,1),y .55s cubic-bezier(.22,.75,.22,1),width .55s cubic-bezier(.22,.75,.22,1),height .55s cubic-bezier(.22,.75,.22,1)}.sf-guided-inspection .gi-controls{display:flex;justify-content:space-between;align-items:center;gap:12px;min-height:72px;padding:12px 20px;border-top:1px solid #b8c7b3;background:#f8faf4;flex-shrink:0}.sf-guided-inspection button{border:0;background:none;min-width:44px;min-height:44px;color:inherit;font:12px Arial,sans-serif;cursor:pointer;touch-action:manipulation}.sf-guided-inspection button:focus-visible{outline:2px solid #ed5a2c;outline-offset:2px}.sf-guided-inspection button:disabled{opacity:.3;cursor:default}.sf-guided-inspection .gi-controls>button{background:#20342b;color:#fff;padding:0 14px;border-radius:3px;display:flex;align-items:center;gap:20px}.sf-guided-inspection .gi-controls h3{font-size:15px;font-weight:500;margin:3px 0}.sf-guided-inspection .gi-controls p{font-size:10px;line-height:1.4;max-width:280px;margin:4px 0 0;color:#60725f}.sf-guided-inspection .gi-controls div>span{font:9px monospace;color:#7f9279}.sf-guided-inspection .gi-actions{display:flex;gap:2px}.sf-guided-inspection .gi-actions button{font-size:20px}.sf-guided-inspection[data-quiet=true] *{transition:none!important}@container(max-width:400px){.sf-guided-inspection header{padding:0 14px;font-size:8px}.sf-guided-inspection .gi-controls{padding:10px 14px;gap:4px}.sf-guided-inspection .gi-controls p{max-width:145px;font-size:9px}.sf-guided-inspection .gi-actions{gap:0}.sf-guided-inspection .gi-actions button{min-width:44px}}@media(prefers-reduced-motion:reduce){.sf-guided-inspection *{transition:none!important}}`}</style>
    </div>
  )
}

Where it works well

Guide inspection of a diagram, product or technical illustration through one shared camera transform.

Reduced motion

Camera and spotlight update immediately; the three steps remain available.

Things to know

A local SVG floor plan, not a DOM element targeting or page-onboarding service.

More examples