# Sightline — Scroll Animation for React

> A pinned scroll section where an inline media card grows to full-bleed through a shaped SVG aperture (rounded, circle, pill or any custom path) — corners relax to square, a scrim rises, and the caption rides the aperture's edge before settling as an overlay. Reduced motion skips the pin for a static crossfade.

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

## Install

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

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

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

## Props (`SightlineProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children?` | `React.ReactNode` | — | Media shown through the aperture. Any node works (an `<img>`, `<video>`, a gradient, a UI mock); it is stretched to fill the full-bleed frame. Takes precedence over `src`. When neither is given a seeded procedural plate (layered dusk ridgelines) renders. |
| `src?` | `string` | — | Image or video URL (videos are detected by extension and play muted + looped). |
| `alt?` | `string` | `""` | Alt text for `src` images. Leave empty for decorative media. |
| `shape?` | `SightlineShape` | `"rounded"` | Aperture outline the media grows through. |
| `path?` | `string` | `an arched window` | Custom aperture outline for `shape="path"`: an SVG path `d` string drawn in a 0–1 unit box (e.g. `"M0.5 0 L1 1 L0 1 Z"`). It scales from the center, keeping its aspect. |
| `pinLength?` | `number` | `220` | Section height as a percentage of the viewport (or the `scroller`'s height); the extra length is the pinned scroll distance. |
| `caption?` | `React.ReactNode` | — | Caption that rides the aperture's bottom edge, then settles as an overlay once full-bleed. |
| `dim?` | `number` | `0.6` | Maximum scrim darkness laid over the media as it reaches full-bleed (0–1). |
| `radius?` | `number` | `28` | Corner radius of the starting card in px (`rounded` shape). |
| `scroller?` | `React.RefObject<HTMLElement \| null>` | `the viewport` | Scroll container to track instead of the window. Pass a ref to an `overflow-y-auto` ancestor; the pin then sizes itself to THAT box. |
| `surface?` | `string` | `"#0a0a0a"` | Surface color around the card. Only painted in the reduced-motion crossfade (the card's surround fades out to reveal the full-bleed media). |

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

## Usage example

```tsx
"use client";

import * as React from "react";

import { Sightline } from "@/components/crucible/sightline";

export default function SightlineDemo() {
  const scrollRef = React.useRef<HTMLDivElement>(null);

  return (
    <div
      ref={scrollRef}
      className="relative isolate h-[520px] w-full overflow-y-auto overflow-x-hidden rounded-xl bg-neutral-950 [scrollbar-width:thin]"
    >
      <div className="flex h-[200px] flex-col items-center justify-end gap-1 pb-10 text-center">
        <p className="text-sm font-medium text-white/70">Scroll down ↓</p>
        <p className="text-xs text-white/35">The card opens up to fill the frame.</p>
      </div>

      <Sightline
        scroller={scrollRef}
        caption={
          <>
            <span className="block text-[11px] font-medium tracking-[0.2em] text-white/50 uppercase">
              Keep scrolling
            </span>
            <span className="block">The aperture widens until it is the whole view.</span>
          </>
        }
      />

      <div className="flex h-[260px] items-center justify-center px-6 text-center">
        <p className="max-w-xs text-sm text-white/45">
          Scroll back up to close the aperture again.
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

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

## Source (`sightline.tsx`)

```tsx
"use client";

import * as React from "react";
import { useInView, useMotionValueEvent, useScroll, useSpring } from "motion/react";

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

export type SightlineShape = "rounded" | "circle" | "pill" | "path";

export interface SightlineProps
  extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
  /**
   * Media shown through the aperture. Any node works (an `<img>`, `<video>`,
   * a gradient, a UI mock); it is stretched to fill the full-bleed frame.
   * Takes precedence over `src`. When neither is given a seeded procedural
   * plate (layered dusk ridgelines) renders.
   */
  children?: React.ReactNode;
  /** Image or video URL (videos are detected by extension and play muted + looped). */
  src?: string;
  /** Alt text for `src` images. Leave empty for decorative media. @default "" */
  alt?: string;
  /** Aperture outline the media grows through. @default "rounded" */
  shape?: SightlineShape;
  /**
   * Custom aperture outline for `shape="path"`: an SVG path `d` string drawn in
   * a 0–1 unit box (e.g. `"M0.5 0 L1 1 L0 1 Z"`). It scales from the center,
   * keeping its aspect. @default an arched window
   */
  path?: string;
  /** Section height as a percentage of the viewport (or the `scroller`'s height); the extra length is the pinned scroll distance. @default 220 */
  pinLength?: number;
  /** Caption that rides the aperture's bottom edge, then settles as an overlay once full-bleed. */
  caption?: React.ReactNode;
  /** Maximum scrim darkness laid over the media as it reaches full-bleed (0–1). @default 0.6 */
  dim?: number;
  /** Corner radius of the starting card in px (`rounded` shape). @default 28 */
  radius?: number;
  /**
   * Scroll container to track instead of the window. Pass a ref to an
   * `overflow-y-auto` ancestor; the pin then sizes itself to THAT box.
   * @default the viewport
   */
  scroller?: React.RefObject<HTMLElement | null>;
  /**
   * Surface color around the card. Only painted in the reduced-motion
   * crossfade (the card's surround fades out to reveal the full-bleed media).
   * @default "#0a0a0a"
   */
  surface?: string;
}

/* ------------------------------------------------------------------------ */
/* Geometry                                                                  */
/* ------------------------------------------------------------------------ */

const DEFAULT_PATH =
  "M0 1 L0 0.5 C0 0.2239 0.2239 0 0.5 0 C0.7761 0 1 0.2239 1 0.5 L1 1 Z";

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const easeInOut = (t: number) =>
  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const smooth = (a: number, b: number, v: number) => {
  const t = clamp01((v - a) / (b - a));
  return t * t * (3 - 2 * t);
};

/** Aperture reaches full-bleed at this progress; the remainder is the overlay hold. */
const GROW_START = 0.06;
const GROW_END = 0.8;

interface Frame {
  /** Aperture box in px, centered in the stage. */
  w: number;
  h: number;
  r: number;
  /** Media parallax scale. */
  zoom: number;
  /** 0→1 growth (eased). */
  e: number;
}

function cardSize(W: number, H: number, shape: SightlineShape) {
  const w = Math.min(W * 0.62, 520);
  const h = Math.min(H * 0.52, w * 0.68);
  if (shape === "circle") {
    const d = Math.min(W * 0.5, H * 0.52, 340);
    return { w: d, h: d };
  }
  if (shape === "pill") return { w: Math.min(W * 0.66, 540), h: Math.min(H * 0.3, 150) };
  if (shape === "path") {
    const s = Math.min(W * 0.46, H * 0.56, 320);
    return { w: s, h: s };
  }
  return { w, h };
}

function frameAt(p: number, W: number, H: number, shape: SightlineShape, radius: number): Frame {
  const e = easeInOut(clamp01((p - GROW_START) / (GROW_END - GROW_START)));
  const c = cardSize(W, H, shape);
  const zoom = lerp(1.16, 1, e);
  if (shape === "circle") {
    const d = lerp(c.w, Math.hypot(W, H) + 2, e);
    return { w: d, h: d, r: d / 2, zoom, e };
  }
  if (shape === "path") {
    // A free-form outline never tiles the frame by itself — overshoot so
    // its interior swallows the stage, and a union rect closes the corners.
    const s = lerp(c.w, Math.max(W, H) * 2.6, e);
    return { w: s, h: s, r: 0, zoom, e };
  }
  const w = lerp(c.w, W + 2, e);
  const h = lerp(c.h, H + 2, e);
  const r0 = shape === "pill" ? c.h / 2 : radius;
  return { w, h, r: r0 * Math.pow(1 - e, 1.4), zoom, e };
}

/* ------------------------------------------------------------------------ */
/* Procedural plate — seeded dusk ridgelines (pure math, SSR-identical)      */
/* ------------------------------------------------------------------------ */

function ridge(seed: number, base: number, amp: number): string {
  const pts: string[] = [];
  const n = 48;
  for (let i = 0; i <= n; i++) {
    const x = (i / n) * 1600;
    const t = i / n;
    const y =
      base +
      Math.sin(t * 6.1 + seed * 1.7) * amp * 0.55 +
      Math.sin(t * 13.3 + seed * 3.1) * amp * 0.28 +
      Math.sin(t * 29.7 + seed * 5.3) * amp * 0.12;
    pts.push(`${x.toFixed(1)} ${y.toFixed(1)}`);
  }
  return `M0 1000 L${pts.join(" L")} L1600 1000 Z`;
}

const RIDGES = [
  { d: ridge(1, 560, 70), fill: "#3b4a63", opacity: 0.55 },
  { d: ridge(2, 640, 85), fill: "#26324a", opacity: 0.8 },
  { d: ridge(3, 730, 95), fill: "#18202f", opacity: 0.95 },
  { d: ridge(4, 840, 80), fill: "#0d121c", opacity: 1 },
];

function SightlinePlate() {
  const id = React.useId().replace(/:/g, "");
  return (
    <svg
      aria-hidden
      focusable="false"
      className="absolute inset-0 h-full w-full"
      viewBox="0 0 1600 1000"
      preserveAspectRatio="xMidYMid slice"
    >
      <defs>
        <linearGradient id={`${id}-sky`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#0a101c" />
          <stop offset="0.3" stopColor="#1e2b44" />
          <stop offset="0.5" stopColor="#5d7393" />
          <stop offset="0.6" stopColor="#c3cfdd" />
        </linearGradient>
        <radialGradient id={`${id}-sun`} cx="0.5" cy="0.5" r="0.5">
          <stop offset="0" stopColor="#f8fafc" stopOpacity="0.95" />
          <stop offset="0.25" stopColor="#e2e8f0" stopOpacity="0.5" />
          <stop offset="1" stopColor="#e2e8f0" stopOpacity="0" />
        </radialGradient>
        <linearGradient id={`${id}-haze`} x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#c7d2e0" stopOpacity="0" />
          <stop offset="1" stopColor="#c7d2e0" stopOpacity="0.18" />
        </linearGradient>
      </defs>
      <rect width="1600" height="1000" fill={`url(#${id}-sky)`} />
      <circle cx="1010" cy="470" r="320" fill={`url(#${id}-sun)`} />
      <circle cx="1010" cy="470" r="30" fill="#f8fafc" />
      {RIDGES.map((r, i) => (
        <g key={i}>
          <path d={r.d} fill={r.fill} opacity={r.opacity} />
          {i < RIDGES.length - 1 && <path d={r.d} fill={`url(#${id}-haze)`} />}
        </g>
      ))}
    </svg>
  );
}

/* ------------------------------------------------------------------------ */
/* Media                                                                     */
/* ------------------------------------------------------------------------ */

const VIDEO_RE = /\.(mp4|webm|mov|m4v|ogv)(\?|#|$)/i;

function SightlineMedia({ children, src, alt }: Pick<SightlineProps, "children" | "src" | "alt">) {
  if (children) {
    return <div className="absolute inset-0 [&>*]:h-full [&>*]:w-full">{children}</div>;
  }
  if (src && VIDEO_RE.test(src)) {
    return (
      <video
        className="absolute inset-0 h-full w-full object-cover"
        src={src}
        autoPlay
        muted
        loop
        playsInline
        aria-label={alt || undefined}
      />
    );
  }
  if (src) {
    // eslint-disable-next-line @next/next/no-img-element
    return <img className="absolute inset-0 h-full w-full object-cover" src={src} alt={alt ?? ""} draggable={false} />;
  }
  return <SightlinePlate />;
}

/* ------------------------------------------------------------------------ */
/* Sightline                                                                 */
/* ------------------------------------------------------------------------ */

/**
 * Sightline — a pinned scroll section in which an inline media card grows to
 * full-bleed through a shaped aperture (rounded card, circle, pill or any
 * custom path), its corners relaxing to square while a scrim rises and the
 * caption rides the aperture's lower edge before settling as an overlay.
 */
export function Sightline({
  children,
  src,
  alt = "",
  shape = "rounded",
  path = DEFAULT_PATH,
  pinLength = 220,
  caption,
  dim = 0.6,
  radius = 28,
  scroller,
  surface = "#0a0a0a",
  className,
  style,
  ...props
}: SightlineProps) {
  const reduced = useReducedMotion();
  const uid = React.useId().replace(/:/g, "");
  const clipId = `sightline-${uid}-clip`;

  const sectionRef = React.useRef<HTMLElement>(null);
  const stageRef = React.useRef<HTMLDivElement>(null);
  const mediaRef = React.useRef<HTMLDivElement>(null);
  const scrimRef = React.useRef<HTMLDivElement>(null);
  const captionRef = React.useRef<HTMLElement>(null);
  const captionInnerRef = React.useRef<HTMLDivElement>(null);
  const ruleRef = React.useRef<HTMLSpanElement>(null);
  const shapeElRef = React.useRef<SVGRectElement & SVGPathElement>(null);
  const unionRef = React.useRef<SVGRectElement>(null);
  const rimRef = React.useRef<SVGRectElement & SVGPathElement>(null);
  const rimSvgRef = React.useRef<SVGSVGElement>(null);

  // Stage size (px) — clip fractions + caption placement derive from it.
  const size = React.useRef({ W: 960, H: 540 });
  // Scroller viewport height when pinned inside a container (null = window → vh units).
  const [boxH, setBoxH] = React.useState<number | null>(null);

  React.useEffect(() => {
    const el = scroller?.current;
    if (!el) return;
    // ResizeObserver fires once on observe, so this also seeds the height.
    const ro = new ResizeObserver(() => setBoxH(el.clientHeight));
    ro.observe(el);
    return () => ro.disconnect();
  }, [scroller]);

  const { scrollYProgress } = useScroll({
    target: sectionRef,
    container: scroller,
    offset: ["start start", "end end"],
  });
  const progress = useSpring(scrollYProgress, { stiffness: 260, damping: 42, restDelta: 0.0004 });

  const paint = React.useCallback(
    (p: number) => {
      const { W, H } = size.current;
      if (W <= 0 || H <= 0) return;
      const f = frameAt(p, W, H, shape, radius);
      const x = (W - f.w) / 2;
      const y = (H - f.h) / 2;

      const el = shapeElRef.current;
      const rim = rimRef.current;
      if (shape === "path") {
        const sx = f.w / W;
        const sy = f.h / H;
        el?.setAttribute(
          "transform",
          `translate(${(x / W).toFixed(5)} ${(y / H).toFixed(5)}) scale(${sx.toFixed(5)} ${sy.toFixed(5)})`
        );
        rim?.setAttribute("transform", `translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${f.w.toFixed(3)})`);
        // Union rect closes the frame once the outline has swallowed most of it.
        const u = smooth(0.55, 1, f.e);
        const uw = u * (W + 2);
        const uh = u * (H + 2);
        const ur = unionRef.current;
        if (ur) {
          ur.setAttribute("x", String((W - uw) / 2 / W));
          ur.setAttribute("y", String((H - uh) / 2 / H));
          ur.setAttribute("width", String(uw / W));
          ur.setAttribute("height", String(uh / H));
        }
      } else if (el) {
        el.setAttribute("x", String(x / W));
        el.setAttribute("y", String(y / H));
        el.setAttribute("width", String(f.w / W));
        el.setAttribute("height", String(f.h / H));
        el.setAttribute("rx", String(f.r / W));
        el.setAttribute("ry", String(f.r / H));
        if (rim) {
          rim.setAttribute("x", x.toFixed(2));
          rim.setAttribute("y", y.toFixed(2));
          rim.setAttribute("width", f.w.toFixed(2));
          rim.setAttribute("height", f.h.toFixed(2));
          rim.setAttribute("rx", f.r.toFixed(2));
        }
      }
      if (rimSvgRef.current) rimSvgRef.current.style.opacity = String(1 - smooth(0.45, 0.95, f.e));

      if (mediaRef.current) mediaRef.current.style.transform = `scale(${f.zoom.toFixed(4)})`;
      if (scrimRef.current) scrimRef.current.style.opacity = String(dim * smooth(0.3, 1, p));

      // Caption: ride just below the aperture edge, clamp inside the frame,
      // then settle as an overlay past 0.85.
      const cap = captionRef.current;
      if (cap) {
        const capH = cap.offsetHeight;
        const edge = shape === "path" ? y + f.h : y + f.h;
        const riding = edge + 18;
        const settledY = H - capH - Math.max(28, H * 0.08);
        const top = Math.min(riding, settledY);
        cap.style.transform = `translate3d(0, ${top.toFixed(2)}px, 0)`;
        const s = smooth(0.82, 0.97, p);
        if (captionInnerRef.current) {
          captionInnerRef.current.style.transform = `scale(${(1 + s * 0.28).toFixed(4)})`;
          captionInnerRef.current.style.color = `rgb(255 255 255 / ${(0.62 + s * 0.38).toFixed(3)})`;
        }
        if (ruleRef.current) {
          ruleRef.current.style.opacity = String(s);
          ruleRef.current.style.transform = `scaleX(${s.toFixed(4)})`;
        }
      }
    },
    [shape, radius, dim]
  );

  useMotionValueEvent(progress, "change", paint);

  // Measure the sticky stage and repaint on any size change.
  React.useLayoutEffect(() => {
    if (reduced) return;
    const stage = stageRef.current;
    if (!stage) return;
    const measure = () => {
      size.current = { W: stage.clientWidth, H: stage.clientHeight };
      if (rimSvgRef.current) {
        rimSvgRef.current.setAttribute("viewBox", `0 0 ${size.current.W} ${size.current.H}`);
      }
      paint(progress.get());
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(stage);
    if (captionRef.current) ro.observe(captionRef.current);
    return () => ro.disconnect();
  }, [reduced, paint, progress, boxH]);

  if (reduced) {
    return (
      <SightlineStatic
        className={className}
        style={style}
        caption={caption}
        shape={shape}
        path={path}
        radius={radius}
        dim={dim}
        surface={surface}
        scroller={scroller}
        boxH={boxH}
        {...props}
      >
        <SightlineMedia src={src} alt={alt}>
          {children}
        </SightlineMedia>
      </SightlineStatic>
    );
  }

  const sectionHeight = boxH != null ? (boxH * pinLength) / 100 : `${pinLength}vh`;
  const stageHeight = boxH != null ? boxH : "100vh";

  return (
    <section
      ref={sectionRef}
      data-crucible="sightline"
      className={cn("relative w-full", className)}
      style={{ height: sectionHeight, ...style }}
      {...props}
    >
      <div
        ref={stageRef}
        className="sticky top-0 isolate w-full overflow-hidden"
        style={{ height: stageHeight }}
      >
        <svg aria-hidden focusable="false" width="0" height="0" className="absolute">
          <defs>
            <clipPath id={clipId} clipPathUnits="objectBoundingBox">
              {shape === "path" ? (
                <>
                  <path ref={shapeElRef} d={path} />
                  <rect ref={unionRef} x="0.5" y="0.5" width="0" height="0" />
                </>
              ) : (
                <rect ref={shapeElRef} x="0.2" y="0.25" width="0.6" height="0.5" />
              )}
            </clipPath>
          </defs>
        </svg>

        <figure className="absolute inset-0 m-0">
          <div
            className="absolute inset-0 overflow-hidden"
            style={{ clipPath: `url(#${clipId})`, WebkitClipPath: `url(#${clipId})` }}
          >
            <div ref={mediaRef} className="absolute inset-0 will-change-transform">
              <SightlineMedia src={src} alt={alt}>
                {children}
              </SightlineMedia>
            </div>
            <div
              ref={scrimRef}
              aria-hidden
              className="pointer-events-none absolute inset-0 opacity-0"
              style={{
                background:
                  "linear-gradient(to top, rgb(0 0 0 / 0.95) 0%, rgb(0 0 0 / 0.55) 45%, rgb(0 0 0 / 0.35) 100%)",
              }}
            />
          </div>

          {/* Hairline rim tracing the aperture — fades as it reaches full-bleed. */}
          <svg
            ref={rimSvgRef}
            aria-hidden
            focusable="false"
            className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
          >
            {shape === "path" ? (
              <path
                ref={rimRef}
                d={path}
                fill="none"
                stroke="rgb(255 255 255 / 0.22)"
                strokeWidth={1}
                vectorEffect="non-scaling-stroke"
              />
            ) : (
              <rect ref={rimRef} fill="none" stroke="rgb(255 255 255 / 0.2)" strokeWidth={1} />
            )}
          </svg>

          {caption != null && (
            <figcaption
              ref={captionRef}
              className="pointer-events-none absolute inset-x-0 top-0 flex justify-center px-6 will-change-transform"
            >
              <div
                ref={captionInnerRef}
                className="flex origin-top flex-col items-center gap-2 text-center text-sm font-medium tracking-tight text-white/60 [text-shadow:0_1px_12px_rgb(0_0_0/0.5)]"
              >
                {caption}
                <span ref={ruleRef} aria-hidden className="block h-px w-10 origin-center bg-white/60 opacity-0" />
              </div>
            </figcaption>
          )}
        </figure>
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------------ */
/* Reduced motion — no pin; the card's surround crossfades away on enter     */
/* ------------------------------------------------------------------------ */

interface SightlineStaticProps
  extends Omit<React.ComponentPropsWithoutRef<"section">, "children"> {
  children: React.ReactNode;
  caption?: React.ReactNode;
  shape: SightlineShape;
  path: string;
  radius: number;
  dim: number;
  surface: string;
  scroller?: React.RefObject<HTMLElement | null>;
  boxH: number | null;
}

function SightlineStatic({
  children,
  caption,
  shape,
  path,
  radius,
  dim,
  surface,
  scroller,
  boxH,
  className,
  style,
  ...props
}: SightlineStaticProps) {
  const ref = React.useRef<HTMLElement>(null);
  const maskId = `sightline-${React.useId().replace(/:/g, "")}-mask`;
  const inView = useInView(ref, { root: scroller, amount: 0.8, once: true });
  const [dims, setDims] = React.useState({ W: 960, H: 540 });

  React.useLayoutEffect(() => {
    const el = ref.current;
    if (!el) return;
    const measure = () => setDims({ W: el.clientWidth, H: el.clientHeight });
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  const { W, H } = dims;
  const f = frameAt(0, W, H, shape, radius);
  const x = (W - f.w) / 2;
  const y = (H - f.h) / 2;
  const fade = "opacity 900ms cubic-bezier(0.22, 1, 0.36, 1)";

  return (
    <section
      ref={ref}
      data-crucible="sightline"
      className={cn("relative isolate w-full overflow-hidden", className)}
      style={{ height: boxH ?? "100vh", ...style }}
      {...props}
    >
      <figure className="absolute inset-0 m-0">
        {children}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0"
          style={{
            opacity: inView ? dim : 0,
            transition: fade,
            background: "linear-gradient(to top, rgb(0 0 0 / 0.95) 0%, rgb(0 0 0 / 0.55) 45%, rgb(0 0 0 / 0.35) 100%)",
          }}
        />
        {/* Surround painted in the surface color with a card-shaped hole. */}
        <svg
          aria-hidden
          focusable="false"
          className="pointer-events-none absolute inset-0 h-full w-full"
          viewBox={`0 0 ${W} ${H}`}
          style={{ opacity: inView ? 0 : 1, transition: fade }}
        >
          <defs>
            <mask id={maskId}>
              <rect width={W} height={H} fill="#fff" />
              {shape === "path" ? (
                <path d={path} fill="#000" transform={`translate(${x} ${y}) scale(${f.w})`} />
              ) : (
                <rect x={x} y={y} width={f.w} height={f.h} rx={f.r} fill="#000" />
              )}
            </mask>
          </defs>
          <rect width={W} height={H} fill={surface} mask={`url(#${maskId})`} />
        </svg>
        {caption != null && (
          <figcaption className="pointer-events-none absolute inset-x-0 bottom-[8%] flex justify-center px-6">
            <div className="text-center text-base font-medium tracking-tight text-white [text-shadow:0_1px_12px_rgb(0_0_0/0.5)]">
              {caption}
            </div>
          </figcaption>
        )}
      </figure>
    </section>
  );
}
```
