Sightline
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.
motionfree
"use client";
import * as React from "react";
import { useInView, useMotionValueEvent, useScroll, useSpring } from "motion/react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/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>
);
}
Installation
CLI
npx shadcn@latest add @crucible/sightlineManual — install dependencies, then copy the source
npm install motionProps
| 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 all props of Omit<React.ComponentPropsWithoutRef<"section">, "children"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.