# Loupe — Animated UI Component for React

> Magnifier lens that turns any child — image, SVG, or subtree — into an inspectable surface. Spring-smoothed pointer follow or a pinned mode, a machined bezel with a velocity-driven specular arc, focus-blur on the surface behind, and full keyboard walking. Pure transform/clip compositing, zero canvas; a ghost lens drifts over the surface when untouched.

- Page: https://crucibleui.com/components/utility/loupe
- Category: [utility](https://crucibleui.com/components/utility)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/loupe
```

Registry JSON: https://crucibleui.com/r/loupe.json

Import from `@/components/crucible/loupe`.

## Props (`LoupeProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | — | The surface to inspect — an image, an SVG, or any React subtree. |
| `zoom?` | `number` | `1.6` | Magnification inside the lens. |
| `size?` | `number` | `176` | Lens diameter, in px. |
| `pinned?` | `boolean` | `false` | Pin the lens at a fixed spot instead of tracking the pointer. |
| `pinnedPosition?` | `{ x: number; y: number }` | `{ x: 0.5, y: 0.5 }` | Lens center as fractions of the surface (0–1) while `pinned`. |
| `active?` | `boolean` | — | Controlled active state — when set, overrides internal hover/focus. |
| `onActiveChange?` | `(active: boolean) => void` | — | Fires whenever the lens opens or closes (pointer, focus, or keyboard). |
| `focusBlur?` | `boolean` | `true` | Softly blur + dim the surface outside the lens while it is active. |
| `focusBlurStrength?` | `number` | `2` | Blur radius, in px, applied behind an active lens. |
| `animateEntrance?` | `boolean` | `true` | Play the fast scale-settle entrance when the lens appears. |
| `keyboardStep?` | `number` | `24` | Pixels the lens walks per arrow-key press while focused. |
| `rimColor?` | `string` | `"rgba(255,255,255,0.72)"` | Rim + specular color of the machined bezel. |
| `idle?` | `boolean` | `true` | Idle autoplay: after 1.5s with no pointer or focus, a ghost lens drifts slowly over the surface (no focus-blur, no `onActiveChange`), so the effect sells itself in thumbnails and heroes. A real pointer or focus takes over instantly. Ignored while `pinned` or controlled via `active`; off under reduced motion; sleeps offscreen. Set `false` for zero idle work. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children">`.

## Usage example

```tsx
import { Loupe } from "@/components/crucible/loupe";

/** A dense engraved specimen plate — fine hairlines, tick rings and micro-text
 *  that only resolve under magnification. Deterministic (no random in render). */
function SpecimenPlate() {
  const ticks = Array.from({ length: 72 });
  const guilloche = Array.from({ length: 5 });
  return (
    <svg
      viewBox="0 0 400 300"
      className="h-full w-full"
      role="img"
      aria-label="Engraved specimen plate with fine graduations and micro-text"
    >
      <defs>
        <radialGradient id="loupe-vig" cx="50%" cy="42%" r="75%">
          <stop offset="0%" stopColor="#1c1c22" />
          <stop offset="100%" stopColor="#0a0a0c" />
        </radialGradient>
      </defs>

      <rect width="400" height="300" fill="url(#loupe-vig)" />

      {/* fine background grid */}
      <g stroke="#ffffff" strokeWidth="0.35" opacity="0.08">
        {Array.from({ length: 40 }).map((_, i) => (
          <line key={`v${i}`} x1={i * 10} y1={0} x2={i * 10} y2={300} />
        ))}
        {Array.from({ length: 30 }).map((_, i) => (
          <line key={`h${i}`} x1={0} y1={i * 10} x2={400} y2={i * 10} />
        ))}
      </g>

      {/* guilloché rings */}
      <g fill="none" stroke="#d4d4d8" strokeWidth="0.4" opacity="0.5">
        {guilloche.map((_, ring) => (
          <circle key={ring} cx={200} cy={150} r={40 + ring * 14} />
        ))}
      </g>

      {/* graduated tick dial */}
      <g stroke="#f4f4f5">
        {ticks.map((_, i) => {
          const a = (i / ticks.length) * Math.PI * 2;
          const major = i % 6 === 0;
          const r0 = major ? 108 : 114;
          const r1 = 120;
          return (
            <line
              key={i}
              x1={200 + Math.cos(a) * r0}
              y1={150 + Math.sin(a) * r0}
              x2={200 + Math.cos(a) * r1}
              y2={150 + Math.sin(a) * r1}
              strokeWidth={major ? 0.9 : 0.4}
              opacity={major ? 0.9 : 0.5}
            />
          );
        })}
      </g>

      {/* micro numerals around the dial */}
      <g fill="#e4e4e7" fontSize="4.5" fontFamily="ui-monospace, monospace" opacity="0.85">
        {Array.from({ length: 12 }).map((_, i) => {
          const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
          return (
            <text
              key={i}
              x={200 + Math.cos(a) * 100}
              y={150 + Math.sin(a) * 100 + 1.5}
              textAnchor="middle"
            >
              {String(i * 30).padStart(3, "0")}
            </text>
          );
        })}
      </g>

      {/* central crosshair + hub */}
      <g stroke="#fafafa" strokeWidth="0.5" opacity="0.7">
        <line x1={160} y1={150} x2={240} y2={150} />
        <line x1={200} y1={110} x2={200} y2={190} />
      </g>
      <circle cx={200} cy={150} r={5} fill="none" stroke="#fafafa" strokeWidth="0.6" />
      <circle cx={200} cy={150} r={1.1} fill="#fafafa" />

      {/* micro-text plate — rewards the loupe */}
      <g fill="#a1a1aa" fontFamily="ui-monospace, monospace" opacity="0.9">
        <text x={16} y={24} fontSize="6" letterSpacing="1.5" fill="#e4e4e7">
          SPECIMEN No. 0447-B
        </text>
        <text x={16} y={278} fontSize="3.6">
          lat 41.8781 N · lon 087.6298 W · scale 1:20 000 · rev 12
        </text>
        <text x={384} y={24} fontSize="3.6" textAnchor="end">
          tol ±0.005 mm
        </text>
        <text x={384} y={278} fontSize="3.6" textAnchor="end">
          plate 004 of 128
        </text>
      </g>
    </svg>
  );
}

export default function LoupeDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <Loupe
        zoom={2.1}
        size={168}
        className="h-[300px] w-[400px] max-w-[92%] border border-white/10 shadow-2xl"
      >
        <SpecimenPlate />
      </Loupe>
      <p className="pointer-events-none absolute bottom-6 z-10 text-sm text-neutral-500">
        Hover to inspect — or focus and walk the lens with the arrow keys.
      </p>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`loupe.tsx`)

```tsx
"use client";

import {
  AnimatePresence,
  motion,
  useMotionTemplate,
  useMotionValue,
  useSpring,
  useTransform,
  useVelocity,
} from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";
import { cn } from "@/lib/utils";

export interface LoupeProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
  /** The surface to inspect — an image, an SVG, or any React subtree. */
  children: React.ReactNode;
  /** Magnification inside the lens. @default 1.6 */
  zoom?: number;
  /** Lens diameter, in px. @default 176 */
  size?: number;
  /** Pin the lens at a fixed spot instead of tracking the pointer. @default false */
  pinned?: boolean;
  /** Lens center as fractions of the surface (0–1) while `pinned`. @default { x: 0.5, y: 0.5 } */
  pinnedPosition?: { x: number; y: number };
  /** Controlled active state — when set, overrides internal hover/focus. */
  active?: boolean;
  /** Fires whenever the lens opens or closes (pointer, focus, or keyboard). */
  onActiveChange?: (active: boolean) => void;
  /** Softly blur + dim the surface outside the lens while it is active. @default true */
  focusBlur?: boolean;
  /** Blur radius, in px, applied behind an active lens. @default 2 */
  focusBlurStrength?: number;
  /** Play the fast scale-settle entrance when the lens appears. @default true */
  animateEntrance?: boolean;
  /** Pixels the lens walks per arrow-key press while focused. @default 24 */
  keyboardStep?: number;
  /** Rim + specular color of the machined bezel. @default "rgba(255,255,255,0.72)" */
  rimColor?: string;
  /**
   * Idle autoplay: after 1.5s with no pointer or focus, a ghost lens drifts
   * slowly over the surface (no focus-blur, no `onActiveChange`), so the
   * effect sells itself in thumbnails and heroes. A real pointer or focus
   * takes over instantly. Ignored while `pinned` or controlled via `active`;
   * off under reduced motion; sleeps offscreen. Set `false` for zero idle
   * work. @default true
   */
  idle?: boolean;
}

const IDLE_MS = 1500;

const FOLLOW = { stiffness: 520, damping: 42, mass: 0.6 } as const;

function clamp(v: number, lo: number, hi: number) {
  return v < lo ? lo : v > hi ? hi : v;
}

/**
 * Loupe — a jeweler's magnifier for any child. Hovering summons a circular
 * lens that shows the underlying content enlarged; the lens follows the pointer
 * with a whisper of spring-smoothing, or sits pinned at a fixed coordinate.
 *
 * Crucible signature: a machined bezel, not a comedy magnifying glass —
 * neutral hue family. A hairline bright rim, a ground-glass edge falloff, and a
 * single almost-subliminal specular arc along the upper-left rim that drifts
 * with pointer velocity, selling curved glass with no rainbow fringing. While
 * active, the surface outside the lens softly blurs to concentrate attention.
 * Keyboard: focusing the wrapper opens the lens centered and arrow keys walk
 * it in steps. Reduced motion keeps full magnification (it is positional, not a
 * flourish) but drops the follow-smoothing, entrance scale, and rim-arc drift —
 * the lens simply snaps. Pure transform/clip compositing, zero canvas.
 * Untouched, a ghost lens idles across the surface (`idle`); with `idle={false}`
 * there is no work at all while unhovered.
 */
export function Loupe({
  children,
  className,
  zoom = 1.6,
  size = 176,
  pinned = false,
  pinnedPosition = { x: 0.5, y: 0.5 },
  active: controlledActive,
  onActiveChange,
  focusBlur = true,
  focusBlurStrength = 2,
  animateEntrance = true,
  keyboardStep = 24,
  rimColor = "rgba(255,255,255,0.72)",
  idle = true,
  ...props
}: LoupeProps) {
  const reducedMotion = useReducedMotion();
  const r = size / 2;

  const [surface, setSurface] = React.useState({ w: 0, h: 0 });
  const [internalActive, setInternalActive] = React.useState(false);
  const [ghosting, setGhosting] = React.useState(false);
  const isControlled = controlledActive !== undefined;
  const userActive = pinned || (isControlled ? (controlledActive as boolean) : internalActive);
  const active = userActive || ghosting;

  const px = pinnedPosition.x;
  const py = pinnedPosition.y;

  // Raw pointer/keyboard center, in px within the surface.
  const cx = useMotionValue(0);
  const cy = useMotionValue(0);
  // Spring-smoothed follow. Under reduced motion we read the raw value directly.
  const scx = useSpring(cx, FOLLOW);
  const scy = useSpring(cy, FOLLOW);
  const posX = reducedMotion ? cx : scx;
  const posY = reducedMotion ? cy : scy;

  // Lens box (top-left) and the magnified copy's transform derive from the same
  // smoothed center, so glass and content never disagree.
  const lensLeft = useTransform(posX, (v) => v - r);
  const lensTop = useTransform(posY, (v) => v - r);
  const innerX = useTransform(posX, (v) => r - v * zoom);
  const innerY = useTransform(posY, (v) => r - v * zoom);
  const innerTransform = useMotionTemplate`translate(${innerX}px, ${innerY}px) scale(${zoom})`;

  // Specular arc drifts a few degrees off horizontal pointer velocity.
  const vx = useVelocity(scx);
  const arcDrift = useTransform(vx, (v) => clamp(v * 0.012, -16, 16));
  const arcAngle = useTransform(arcDrift, (d) => -132 + d);

  // ---- idle ghost --------------------------------------------------------
  const ghostEnabled = idle && !pinned && !isControlled && !reducedMotion;
  const lastPointerRef = React.useRef(0);
  const ghostOnRef = React.useRef(false);
  const userActiveRef = React.useRef(userActive);
  const surfaceRef = React.useRef(surface);
  React.useEffect(() => {
    userActiveRef.current = userActive;
    surfaceRef.current = surface;
  }, [userActive, surface]);

  const wrapperRef = useVisibilityPause<HTMLDivElement>(
    (elapsed) => {
      const { w, h } = surfaceRef.current;
      const want =
        !userActiveRef.current && w > 0 && performance.now() - lastPointerRef.current > IDLE_MS;
      if (!want) {
        if (ghostOnRef.current) {
          ghostOnRef.current = false;
          setGhosting(false);
        }
        return;
      }
      // Slow Lissajous over the surface, kept a lens-radius-ish off the edges.
      const mx = Math.min(r * 0.8, w * 0.3);
      const my = Math.min(r * 0.8, h * 0.3);
      const x = mx + (w - 2 * mx) * (0.5 + 0.5 * Math.sin(elapsed * 0.33 + 0.6));
      const y = my + (h - 2 * my) * (0.5 + 0.5 * Math.sin(elapsed * 0.23 + 2.2));
      cx.set(x);
      cy.set(y);
      if (!ghostOnRef.current) {
        ghostOnRef.current = true;
        // Appear in place rather than swooping in from the last position.
        scx.jump(x);
        scy.jump(y);
        setGhosting(true);
      }
    },
    { paused: !ghostEnabled }
  );

  React.useEffect(() => {
    lastPointerRef.current = performance.now();
    if (!ghostEnabled && ghostOnRef.current) {
      ghostOnRef.current = false;
      setGhosting(false);
    }
  }, [ghostEnabled]);

  const markPointer = React.useCallback(() => {
    lastPointerRef.current = performance.now();
    if (ghostOnRef.current) {
      ghostOnRef.current = false;
      setGhosting(false);
    }
  }, []);

  const emitActive = React.useCallback(
    (next: boolean) => {
      if (!isControlled) setInternalActive(next);
      onActiveChange?.(next);
    },
    [isControlled, onActiveChange]
  );

  // Measure the surface so the magnified copy can be sized 1:1 to it.
  React.useEffect(() => {
    const el = wrapperRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;
      setSurface({ w: width, h: height });
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, [wrapperRef]);

  // Keep the pinned lens parked on its coordinate as the surface resizes.
  React.useEffect(() => {
    if (!pinned || surface.w === 0) return;
    cx.set(clamp(px, 0, 1) * surface.w);
    cy.set(clamp(py, 0, 1) * surface.h);
  }, [pinned, px, py, surface.w, surface.h, cx, cy]);

  const setCenter = React.useCallback(
    (nx: number, ny: number) => {
      cx.set(clamp(nx, 0, surface.w));
      cy.set(clamp(ny, 0, surface.h));
    },
    [cx, cy, surface.w, surface.h]
  );

  const handlePointerMove = React.useCallback(
    (event: React.PointerEvent<HTMLDivElement>) => {
      if (pinned || event.pointerType === "touch") return;
      const node = wrapperRef.current;
      if (!node) return;
      const rect = node.getBoundingClientRect();
      const x = event.clientX - rect.left;
      const y = event.clientY - rect.top;
      if (ghostOnRef.current) {
        // Pointer began inside (no enter event): take over from the ghost.
        scx.jump(x);
        scy.jump(y);
        if (!internalActive) emitActive(true);
      }
      markPointer();
      cx.set(x);
      cy.set(y);
    },
    [pinned, cx, cy, scx, scy, wrapperRef, markPointer, internalActive, emitActive]
  );

  const handlePointerEnter = React.useCallback(
    (event: React.PointerEvent<HTMLDivElement>) => {
      if (pinned || event.pointerType === "touch") return;
      markPointer();
      const node = wrapperRef.current;
      if (node) {
        const rect = node.getBoundingClientRect();
        const x = event.clientX - rect.left;
        const y = event.clientY - rect.top;
        cx.set(x);
        cy.set(y);
        // Seat the smoothed follow at the entry point so the lens appears
        // under the cursor instead of swooping in from its last position.
        scx.jump(x);
        scy.jump(y);
      }
      emitActive(true);
    },
    [pinned, cx, cy, scx, scy, emitActive, wrapperRef, markPointer]
  );

  const handlePointerLeave = React.useCallback(
    (event: React.PointerEvent<HTMLDivElement>) => {
      if (pinned || event.pointerType === "touch") return;
      lastPointerRef.current = performance.now();
      // Keep the lens up if focus is still driving it via keyboard.
      if (wrapperRef.current?.contains(document.activeElement)) return;
      emitActive(false);
    },
    [pinned, emitActive, wrapperRef]
  );

  const handleFocus = React.useCallback(() => {
    if (pinned) return;
    markPointer();
    // Focusing places the lens dead-center, ready to walk with the arrows.
    // Skip the re-centering when a hover already opened the lens (a click
    // focuses the wrapper too — the lens must stay under the cursor).
    if (userActive) return;
    setCenter(surface.w / 2, surface.h / 2);
    scx.jump(clamp(surface.w / 2, 0, surface.w));
    scy.jump(clamp(surface.h / 2, 0, surface.h));
    emitActive(true);
  }, [pinned, userActive, setCenter, surface.w, surface.h, scx, scy, emitActive, markPointer]);

  const handleBlur = React.useCallback(() => {
    if (pinned) return;
    lastPointerRef.current = performance.now();
    emitActive(false);
  }, [pinned, emitActive]);

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLDivElement>) => {
      if (pinned) return;
      const step = keyboardStep;
      let handled = true;
      switch (event.key) {
        case "ArrowLeft":
          setCenter(cx.get() - step, cy.get());
          break;
        case "ArrowRight":
          setCenter(cx.get() + step, cy.get());
          break;
        case "ArrowUp":
          setCenter(cx.get(), cy.get() - step);
          break;
        case "ArrowDown":
          setCenter(cx.get(), cy.get() + step);
          break;
        case "Home":
          setCenter(0, 0);
          break;
        case "End":
          setCenter(surface.w, surface.h);
          break;
        case "Escape":
          (event.currentTarget as HTMLDivElement).blur();
          break;
        default:
          handled = false;
      }
      if (handled) event.preventDefault();
    },
    [pinned, keyboardStep, setCenter, cx, cy, surface.w, surface.h]
  );

  const showLens = active && surface.w > 0;

  return (
    <div
      ref={wrapperRef}
      data-crucible="loupe"
      tabIndex={0}
      role="group"
      aria-label="Magnifiable surface — focus and use arrow keys to inspect"
      className={cn(
        "relative isolate overflow-hidden rounded-xl outline-none",
        "focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950",
        className
      )}
      onPointerMove={handlePointerMove}
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
      onFocus={handleFocus}
      onBlur={handleBlur}
      onKeyDown={handleKeyDown}
      {...props}
    >
      {/* Base surface — blurs and dims a touch while the lens is up. */}
      <div
        className="h-full w-full"
        style={{
          filter: focusBlur && userActive ? `blur(${focusBlurStrength}px) brightness(0.82)` : "none",
          transition: reducedMotion ? "none" : "filter 0.28s ease",
          willChange: "filter",
        }}
      >
        {children}
      </div>

      <AnimatePresence>
        {showLens && (
          <motion.div
            key="lens"
            aria-hidden
            initial={
              !animateEntrance || reducedMotion
                ? { opacity: 0, scale: 1 }
                : { opacity: 0, scale: 0.92 }
            }
            animate={{ opacity: 1, scale: 1 }}
            exit={{ opacity: 0, scale: reducedMotion ? 1 : 0.98 }}
            transition={{
              opacity: { duration: 0.14, ease: "easeOut" },
              scale: reducedMotion
                ? { duration: 0 }
                : { type: "spring", stiffness: 520, damping: 30, mass: 0.6 },
            }}
            className="pointer-events-none absolute overflow-hidden rounded-full"
            style={{
              left: lensLeft,
              top: lensTop,
              width: size,
              height: size,
              backgroundColor: "rgb(10 10 12)",
              boxShadow:
                "0 12px 34px -10px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06)",
            }}
          >
            {/* Magnified copy, sized 1:1 to the surface then scaled. */}
            <motion.div
              className="absolute top-0 left-0"
              style={{
                width: surface.w,
                height: surface.h,
                transformOrigin: "0 0",
                transform: innerTransform,
              }}
            >
              {children}
            </motion.div>

            {/* Ground-glass edge falloff + a soft top-left sheen. */}
            <div
              className="pointer-events-none absolute inset-0 rounded-full"
              style={{
                background:
                  "radial-gradient(circle at 50% 50%, transparent 54%, rgba(0,0,0,0.28) 100%), radial-gradient(circle at 32% 28%, rgba(255,255,255,0.10), transparent 46%)",
              }}
            />

            {/* Specular arc riding the upper-left rim; drifts with velocity. */}
            <motion.div
              className="pointer-events-none absolute inset-0 rounded-full"
              style={{
                rotate: reducedMotion ? -132 : arcAngle,
                background: `conic-gradient(from 0deg at 50% 50%, transparent 0deg, ${rimColor} 26deg, rgba(255,255,255,0.05) 70deg, transparent 120deg, transparent 360deg)`,
                WebkitMask:
                  "radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px))",
                mask: "radial-gradient(farthest-side, transparent calc(100% - 2.5px), #000 calc(100% - 2.5px))",
                opacity: 0.9,
              }}
            />

            {/* Hairline machined rim. */}
            <div
              className="pointer-events-none absolute inset-0 rounded-full"
              style={{
                boxShadow: `inset 0 0 0 1px ${rimColor}, inset 0 0 12px rgba(0,0,0,0.35)`,
              }}
            />
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```
