"use client";
import { useEffect, useId, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { cn } from "@/lib/utils";
export type CrossingEasing = "expressive" | "standard" | "linear";
export type CrossingExit = "fade" | "hold" | "slide";
export interface CrossingItem {
id: string;
title: string;
meta: string;
body: string;
tone: string;
}
export interface CrossingProps {
items?: CrossingItem[];
duration?: number;
easing?: CrossingEasing;
shareTitle?: boolean;
exit?: CrossingExit;
radius?: number;
viewTransition?: boolean;
announce?: boolean;
accentColor?: string;
reducedMotion?: boolean;
className?: string;
}
const EASING: Record<CrossingEasing, string> = {
expressive: "cubic-bezier(0.22, 1, 0.36, 1)",
standard: "cubic-bezier(0.4, 0, 0.2, 1)",
linear: "linear",
};
function sheet(
ns: string,
duration: number,
easing: string,
exit: CrossingExit,
): string {
const at = `:root[data-crossing="${ns}"]`;
const group = (n: string) => `${at}::view-transition-group(${ns}-${n})`;
const old = (n: string) => `${at}::view-transition-old(${ns}-${n})`;
const fresh = (n: string) => `${at}::view-transition-new(${ns}-${n})`;
const ms = (fraction: number) => `${Math.round(duration * fraction)}ms`;
const heroExit =
exit === "hold"
? `${old("hero")} { animation-name: none; opacity: 1; }`
: exit === "slide"
? `${old("hero")} { animation-name: ${ns}-slide; }`
: `${old("hero")} { animation-name: ${ns}-out; }`;
const blend =
exit === "fade"
? [old("body"), fresh("body"), old("title"), fresh("title")]
: [
old("body"),
fresh("body"),
old("title"),
fresh("title"),
old("hero"),
fresh("hero"),
];
return `
${at}::view-transition-old(root),
${at}::view-transition-new(root) { animation-duration: 0s; }
@keyframes ${ns}-out { to { opacity: 0; } }
@keyframes ${ns}-in { from { opacity: 0; transform: translateY(8px); } }
@keyframes ${ns}-fade { from { opacity: 0; } }
@keyframes ${ns}-slide { to { opacity: 0; transform: translateY(-12px); } }
${group("hero")}, ${group("title")}, ${group("body")} {
animation-duration: ${duration}ms;
animation-timing-function: ${easing};
overflow: clip;
}
/* Both halves of every pair are pinned to the box their group is currently at.
The UA default is a 100% inline size with an AUTO block size, which means a
snapshot whose aspect ratio differs from its destination grows to whatever
height that width implies — and the pseudo-elements live in the top layer,
so nothing clips it. Measured: a 149x169 tile morphing to 470x124 drew its
outgoing copy 532px tall, straight through the bottom of the panel and over
the page beneath. Cover crops instead of overflowing; the title gets contain,
because letterboxing text is the only way to keep it from being scaled to
three times its size on the way across. */
${old("hero")}, ${fresh("hero")}, ${old("body")}, ${fresh("body")} {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
${old("title")}, ${fresh("title")} {
inline-size: 100%;
block-size: 100%;
object-fit: contain;
object-position: left center;
}
/* The browser blends the two halves of every pair with plus-lighter, which
ADDS them. That is exactly right for its own cross-fade, where the two
opacities always sum to one, and exactly wrong for anything else: hold the
outgoing tile at full opacity and fade the incoming one in over it and the
sum climbs toward two, so the gradient washes out to pastel and snaps back to
its real colour the instant the transition ends. It reads as a brightness
flash on every crossing and there is nothing in the component's own CSS to
blame for it. Any timing that is not a symmetric cross-fade has to opt out. */
${blend.join(",\n")} {
mix-blend-mode: normal;
}
${old("hero")}, ${fresh("hero")} {
animation-duration: ${duration}ms;
animation-timing-function: ${easing};
}
${heroExit}
/* The body and the title hand over rather than cross-fade. Run both halves at
once over the whole duration — the browser default — and every word of the
outgoing view sits at half opacity on top of every word of the incoming one
for the length of the transition: two headings, two paragraphs, three ghost
tiles behind a title. Separating them in time costs nothing and the
double-exposure is simply gone. The gap is where the tile does its work. */
${old("body")} {
animation-name: ${ns}-out;
animation-duration: ${ms(0.4)};
animation-timing-function: ease-out;
animation-fill-mode: both;
}
${fresh("body")} {
animation-name: ${ns}-in;
animation-duration: ${ms(0.55)};
animation-delay: ${ms(0.45)};
animation-timing-function: ${easing};
animation-fill-mode: both;
}
${old("title")} {
animation-name: ${ns}-out;
animation-duration: ${ms(0.3)};
animation-fill-mode: both;
}
${fresh("title")} {
animation-name: ${ns}-fade;
animation-duration: ${ms(0.35)};
animation-delay: ${ms(0.45)};
animation-fill-mode: both;
}
`;
}
export default function Crossing({
items = [],
duration = 380,
easing = "expressive",
shareTitle = true,
exit = "hold",
radius = 12,
viewTransition = true,
announce = true,
accentColor = "#a855f7",
reducedMotion = false,
className,
}: CrossingProps) {
const ns = `sg${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
const [openId, setOpenId] = useState<string | null>(null);
const [crossing, setCrossing] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [said, setSaid] = useState("");
const backRef = useRef<HTMLButtonElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const open = items.find((item) => item.id === openId) ?? null;
useEffect(() => {
const root = document.documentElement;
return () => root.removeAttribute("data-crossing");
}, []);
const cross = (nextId: string | null, id: string, label: string) => {
setSaid(label);
const commit = () => setOpenId(nextId);
const root = document.documentElement;
if (
!viewTransition ||
reducedMotion ||
typeof document.startViewTransition !== "function"
) {
setCrossing(id);
commit();
return;
}
flushSync(() => {
setCrossing(id);
setBusy(true);
});
root.setAttribute("data-crossing", ns);
const transition = document.startViewTransition(() => {
flushSync(commit);
});
const done = () => {
root.removeAttribute("data-crossing");
setBusy(false);
};
if (transition && transition.finished) {
transition.finished.then(done, done);
} else {
done();
}
};
useEffect(() => {
if (openId) {
backRef.current?.focus({ preventScroll: true });
return;
}
if (!crossing) return;
rootRef.current
?.querySelector<HTMLElement>(`[data-card="${crossing}"]`)
?.focus({ preventScroll: true });
}, [openId, crossing]);
const heroName = (id: string) =>
crossing === id ? `${ns}-hero` : undefined;
const titleName = (id: string) =>
shareTitle && crossing === id ? `${ns}-title` : undefined;
return (
<>
<style>{sheet(ns, duration, EASING[easing], exit)}</style>
<div
ref={rootRef}
className={cn(
"relative flex w-full flex-col overflow-hidden border border-hairline bg-panel p-5",
className,
)}
style={{ borderRadius: radius }}
>
<div
className="flex min-h-0 flex-1 flex-col"
style={{ viewTransitionName: `${ns}-body` }}
>
<div className="mb-4 flex h-5 items-center">
{open ? (
<button
ref={backRef}
type="button"
onClick={() => cross(null, open.id, "Back to the gallery")}
className="inline-flex items-center gap-1.5 font-display text-[10px] tracking-[0.18em] text-ink-dim uppercase transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ outlineColor: accentColor }}
>
<svg
viewBox="0 0 24 24"
className="size-3"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M15 5 8 12l7 7" />
</svg>
Back
</button>
) : (
<span className="font-display text-[10px] tracking-[0.18em] text-ink-mute uppercase">
{items.length} areas
</span>
)}
</div>
{open ? (
<div className="flex min-h-0 flex-1 flex-col">
<div
className="mb-4 w-full flex-1"
style={{
background: open.tone,
borderRadius: radius - 3,
viewTransitionName: heroName(open.id),
}}
/>
<h3
className="font-display text-[13px] tracking-[0.16em] text-ink uppercase"
style={{ viewTransitionName: titleName(open.id) }}
>
{open.title}
</h3>
<p className="mt-2 font-sans text-[13px] leading-relaxed text-ink-dim">
{open.body}
</p>
</div>
) : (
<div className="grid min-h-0 flex-1 grid-cols-3 gap-3">
{items.map((item) => (
<button
key={item.id}
type="button"
data-card={item.id}
onClick={() =>
cross(item.id, item.id, `${item.title}, detail`)
}
className="group/card flex min-h-0 flex-col text-left focus-visible:outline-2 focus-visible:outline-offset-2"
style={{ borderRadius: radius - 3, outlineColor: accentColor }}
>
<div
className="w-full flex-1 transition-transform duration-200 group-hover/card:-translate-y-0.5"
style={{
background: item.tone,
borderRadius: radius - 3,
viewTransitionName: heroName(item.id),
boxShadow:
crossing === item.id && !busy && !openId
? `0 0 0 2px ${accentColor}`
: undefined,
}}
/>
<p
className="mt-2 font-display text-[10px] tracking-[0.14em] text-ink uppercase"
style={{ viewTransitionName: titleName(item.id) }}
>
{item.title}
</p>
<p className="mt-0.5 font-sans text-[11px] text-ink-mute">
{item.meta}
</p>
</button>
))}
</div>
)}
</div>
</div>
{announce ? (
<span aria-live="polite" className="sr-only">
{said}
</span>
) : null}
</>
);
}