Casement
A list or grid of dark-glass cards that morph into a centered overlay via a genuine shared-element FLIP — media, title, and action travel continuously, radii stay unsheared, the frame overshoots ~1% and settles, and body copy fades up only after it lands. Full dialog semantics with focus trap, Escape, and outside-click; reduced motion swaps instantly.
motionfree
"use client";
import { AnimatePresence, motion, type Transition } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
/** A single expandable entry in a {@link Casement}. */
export interface CasementItem {
/** Stable, unique id — drives the shared-element `layoutId`s and controlled state. */
id: string;
/** Headline shown collapsed and expanded — travels through the morph. */
title: string;
/** Secondary line under the title (artist, category, timestamp…). */
meta?: string;
/** Optional media URL. When omitted, a deterministic dark gradient stands in. */
image?: string;
/** Alt text for `image`. Decorative media should pass `""`. */
imageAlt?: string;
/** Accent hex used to tint the placeholder gradient (and the media crossfade). */
accent?: string;
/** Label for the action control. @default "View" */
action?: string;
/** When set, the expanded action renders as a real link to this href. */
actionHref?: string;
/** Rich body copy revealed *after* the morph settles — never participates in the FLIP. */
content?: React.ReactNode;
}
export interface CasementProps {
/** The cards to render. Each expands into a centered overlay on click. */
items: CasementItem[];
/** Collapsed arrangement. @default "list" */
layout?: "list" | "grid";
/** Controlled active id (`null` = closed). Omit for uncontrolled use. */
activeId?: string | null;
/** Initial active id in uncontrolled mode. @default null */
defaultActiveId?: string | null;
/** Fires whenever the active card changes (open with an id, close with `null`). */
onActiveChange?: (id: string | null) => void;
/** Called when a non-link action button is pressed in the expanded card. */
onAction?: (item: CasementItem) => void;
/** Backdrop dim opacity, 0–1. @default 0.62 */
backdropOpacity?: number;
/** Backdrop blur radius in px (the "breath of blur"). @default 8 */
backdropBlur?: number;
/** Expanded card max width in px. @default 460 */
expandedMaxWidth?: number;
/** Close when the backdrop is clicked. @default true */
closeOnOutsideClick?: boolean;
/** Close on the Escape key. @default true */
closeOnEscape?: boolean;
/** Morph the media through the FLIP. When false it crossfades instead. @default true */
morphMedia?: boolean;
/** Single cool accent hex for focus rings / hover. @default "#93c5fd" */
accent?: string;
/** Extra classes for the collapsed list/grid wrapper. */
className?: string;
}
/** FNV-1a — deterministic so the placeholder gradient is identical across SSR/CSR. */
function hashString(input: string): number {
let h = 2166136261;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function mediaBackground(item: CasementItem): string {
if (item.accent) return `linear-gradient(140deg, ${item.accent} -12%, #0a0c11 118%)`;
const h = hashString(item.id) % 360;
return `linear-gradient(140deg, hsl(${h} 22% 33%) -12%, hsl(${(h + 32) % 360} 20% 11%) 118%)`;
}
const FOCUSABLE =
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
/**
* Casement — a list or grid of dark-glass cards where clicking one performs a
* genuine shared-element morph: the media, title, and action travel continuously
* from their collapsed seats into a centered overlay card. It overshoots ~1% and
* settles like a sash meeting its frame, corner radii stay continuous through the
* FLIP (no shearing), the backdrop dims and gains a breath of blur, siblings
* recede, and body copy fades up only *after* the frame lands — kept crisp. Full
* dialog semantics: focus moves in and traps, Escape and outside-click close,
* focus returns to the originating card. Reduced motion swaps instantly with a
* plain crossfade and no blur animation.
*/
export function Casement({
items,
layout = "list",
activeId,
defaultActiveId = null,
onActiveChange,
onAction,
backdropOpacity = 0.62,
backdropBlur = 8,
expandedMaxWidth = 460,
closeOnOutsideClick = true,
closeOnEscape = true,
morphMedia = true,
accent = "#93c5fd",
className,
}: CasementProps) {
const reducedMotion = useReducedMotion();
const uid = React.useId().replace(/[:]/g, "");
// --- controlled / uncontrolled active state ------------------------------
const isControlled = activeId !== undefined;
const [uncontrolled, setUncontrolled] = React.useState<string | null>(defaultActiveId);
const currentId = isControlled ? activeId! : uncontrolled;
const activeItem = React.useMemo(
() => items.find((it) => it.id === currentId) ?? null,
[items, currentId]
);
const triggerRef = React.useRef<HTMLButtonElement | null>(null);
const dialogRef = React.useRef<HTMLDivElement>(null);
const [landed, setLanded] = React.useState(false);
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
const setActive = React.useCallback(
(id: string | null) => {
if (!isControlled) setUncontrolled(id);
onActiveChange?.(id);
},
[isControlled, onActiveChange]
);
const open = React.useCallback(
(id: string, node: HTMLButtonElement) => {
triggerRef.current = node;
setLanded(false);
setActive(id);
},
[setActive]
);
const close = React.useCallback(() => {
setActive(null);
// Return focus to the card that opened this dialog, once React has committed.
const el = triggerRef.current;
requestAnimationFrame(() => el?.focus());
}, [setActive]);
// Reset the body-copy reveal whenever the active card changes.
React.useEffect(() => {
if (!activeItem) setLanded(false);
}, [activeItem]);
// --- focus trap + Escape --------------------------------------------------
React.useEffect(() => {
if (!activeItem) return;
const dialog = dialogRef.current;
const raf = requestAnimationFrame(() => dialog?.focus());
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (closeOnEscape) {
e.preventDefault();
e.stopPropagation();
close();
}
return;
}
if (e.key !== "Tab" || !dialog) return;
const focusable = Array.from(
dialog.querySelectorAll<HTMLElement>(FOCUSABLE)
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (e.shiftKey) {
if (active === first || active === dialog) {
e.preventDefault();
last.focus();
}
} else if (active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown, true);
return () => {
cancelAnimationFrame(raf);
document.removeEventListener("keydown", onKeyDown, true);
};
}, [activeItem, closeOnEscape, close]);
// --- scroll lock (scrollbar-compensated, no layout jump) ------------------
React.useEffect(() => {
if (!activeItem) return;
const { body, documentElement: html } = document;
const gap = window.innerWidth - html.clientWidth;
const prevOverflow = body.style.overflow;
const prevPad = body.style.paddingRight;
body.style.overflow = "hidden";
if (gap > 0) body.style.paddingRight = `${gap}px`;
return () => {
body.style.overflow = prevOverflow;
body.style.paddingRight = prevPad;
};
}, [activeItem]);
// --- transitions ----------------------------------------------------------
const morphTransition: Transition = reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 330, damping: 30, mass: 0.9 };
const idFor = (kind: string, itemId: string) => `${uid}-${kind}-${itemId}`;
const isList = layout === "list";
return (
<div data-crucible="casement" className={cn("w-full", className)}>
{/* Collapsed cells. The whole cell is a real button; the inner media /
title / action are decorative shared elements that morph. */}
<motion.div
animate={{
opacity: activeItem ? 0.55 : 1,
scale: activeItem && !reducedMotion ? 0.985 : 1,
}}
transition={reducedMotion ? { duration: 0.15 } : { type: "spring", stiffness: 300, damping: 30 }}
className={cn(
isList
? "flex flex-col gap-2"
: "grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3"
)}
>
{items.map((item) => {
const isActive = item.id === currentId;
return (
<motion.button
key={item.id}
type="button"
layoutId={idFor("card", item.id)}
onClick={(e) => open(item.id, e.currentTarget)}
aria-haspopup="dialog"
aria-expanded={isActive}
aria-label={`${item.title}${item.meta ? `, ${item.meta}` : ""} — expand`}
transition={morphTransition}
style={{ borderRadius: 16 }}
// Hide the collapsed cell while it is the active (morphed) card so
// the grid keeps its slot but nothing double-renders behind the dialog.
animate={{ opacity: isActive ? 0 : 1 }}
className={cn(
"group relative overflow-hidden border border-white/10 bg-white/[0.035] text-left outline-none",
"transition-colors hover:border-white/20 hover:bg-white/[0.06]",
"focus-visible:border-[var(--casement-accent)]",
isList ? "flex items-center gap-4 p-2.5" : "flex flex-col"
)}
>
<motion.span
layoutId={morphMedia ? idFor("media", item.id) : undefined}
aria-hidden
transition={morphTransition}
style={{
borderRadius: isList ? 12 : 0,
background: mediaBackground(item),
backgroundImage: item.image ? `url(${item.image})` : undefined,
backgroundSize: "cover",
backgroundPosition: "center",
}}
className={cn(
"block shrink-0",
isList ? "size-14" : "aspect-[16/10] w-full"
)}
/>
<span className={cn("min-w-0", isList ? "flex-1" : "p-3.5")}>
<motion.span
layoutId={idFor("title", item.id)}
transition={morphTransition}
className="block truncate text-[15px] font-semibold tracking-tight text-white"
>
{item.title}
</motion.span>
{item.meta ? (
<motion.span
layoutId={idFor("meta", item.id)}
transition={morphTransition}
className="mt-0.5 block truncate text-xs text-neutral-400"
>
{item.meta}
</motion.span>
) : null}
</span>
<motion.span
layoutId={idFor("action", item.id)}
aria-hidden
transition={morphTransition}
className={cn(
"shrink-0 rounded-full border border-white/15 bg-white/[0.06] px-3.5 py-1.5 text-xs font-medium text-white/90",
"transition-colors group-hover:border-white/30 group-hover:bg-white/10",
isList ? "mr-1.5" : "mx-3.5 mb-3.5 self-start"
)}
>
{item.action ?? "View"}
</motion.span>
</motion.button>
);
})}
</motion.div>
{/* One overlay portal for the expanded card + backdrop. */}
{mounted &&
createPortal(
<AnimatePresence>
{activeItem ? (
<div
key="casement-overlay"
style={{ ["--casement-accent" as string]: accent }}
>
{/* Backdrop: dims + a breath of blur. Reduced motion fades the
dim only, with the blur applied statically (never animated). */}
<motion.div
aria-hidden
className="fixed inset-0 z-[90]"
style={{ backgroundColor: `rgba(4, 5, 8, ${backdropOpacity})` }}
initial={
reducedMotion
? { opacity: 0, backdropFilter: `blur(${backdropBlur}px)` }
: { opacity: 0, backdropFilter: "blur(0px)" }
}
animate={{ opacity: 1, backdropFilter: `blur(${backdropBlur}px)` }}
exit={{ opacity: 0 }}
transition={
reducedMotion
? { duration: 0.12 }
: { duration: 0.34, ease: [0.22, 1, 0.36, 1] }
}
/>
{/* Centering layer doubles as the outside-click catcher. */}
<div
className="fixed inset-0 z-[91] flex items-center justify-center p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget && closeOnOutsideClick) close();
}}
>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={`${uid}-dialog-title`}
tabIndex={-1}
layoutId={idFor("card", activeItem.id)}
transition={morphTransition}
onLayoutAnimationComplete={() => setLanded(true)}
style={{
borderRadius: 16,
width: "100%",
maxWidth: expandedMaxWidth,
}}
className="relative flex max-h-[86vh] flex-col overflow-hidden border border-white/10 bg-neutral-900/85 shadow-2xl shadow-black/60 outline-none ring-1 ring-white/[0.06] backdrop-blur-xl"
>
{/* Header media — the same shared element, now full-bleed. */}
<div className="relative">
{morphMedia ? (
<motion.div
layoutId={idFor("media", activeItem.id)}
aria-hidden
transition={morphTransition}
style={{
borderRadius: 0,
background: mediaBackground(activeItem),
backgroundImage: activeItem.image
? `url(${activeItem.image})`
: undefined,
backgroundSize: "cover",
backgroundPosition: "center",
}}
className="h-44 w-full"
/>
) : (
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
style={{
background: mediaBackground(activeItem),
backgroundImage: activeItem.image
? `url(${activeItem.image})`
: undefined,
backgroundSize: "cover",
backgroundPosition: "center",
}}
className="h-44 w-full"
/>
)}
<button
type="button"
onClick={close}
aria-label="Close"
className="absolute right-3 top-3 grid size-8 place-items-center rounded-full border border-white/10 bg-black/40 text-white/80 backdrop-blur-md transition-colors hover:bg-black/60 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--casement-accent)]"
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-hidden
>
<path
d="M1 1l12 12M13 1L1 13"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{/* Title + meta + action travel; body copy does not. */}
<div className="flex items-start gap-3 p-5">
<div className="min-w-0 flex-1">
<motion.h3
layoutId={idFor("title", activeItem.id)}
id={`${uid}-dialog-title`}
transition={morphTransition}
className="text-lg font-semibold tracking-tight text-white"
>
{activeItem.title}
</motion.h3>
{activeItem.meta ? (
<motion.p
layoutId={idFor("meta", activeItem.id)}
transition={morphTransition}
className="mt-1 text-sm text-neutral-400"
>
{activeItem.meta}
</motion.p>
) : null}
</div>
{activeItem.actionHref ? (
<motion.a
layoutId={idFor("action", activeItem.id)}
href={activeItem.actionHref}
transition={morphTransition}
className="shrink-0 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--casement-accent)]"
>
{activeItem.action ?? "View"}
</motion.a>
) : (
<motion.button
layoutId={idFor("action", activeItem.id)}
type="button"
onClick={() => onAction?.(activeItem)}
transition={morphTransition}
className="shrink-0 rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--casement-accent)]"
>
{activeItem.action ?? "View"}
</motion.button>
)}
</div>
{activeItem.content ? (
<motion.div
initial={reducedMotion ? false : { opacity: 0, y: 10 }}
animate={
landed || reducedMotion
? { opacity: 1, y: 0 }
: { opacity: 0, y: 10 }
}
transition={{
delay: reducedMotion ? 0 : 0.06,
duration: 0.3,
ease: [0.22, 1, 0.36, 1],
}}
className="min-h-0 flex-1 overflow-y-auto px-5 pb-6 text-sm leading-relaxed text-neutral-300 [mask-image:linear-gradient(to_bottom,#000_calc(100%-2rem),transparent)]"
>
{activeItem.content}
</motion.div>
) : null}
</motion.div>
</div>
</div>
) : null}
</AnimatePresence>,
document.body
)}
</div>
);
}
Installation
CLI
npx shadcn@latest add @crucible/casementManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| items | CasementItem[] | The cards to render. Each expands into a centered overlay on click. | |
| layout | "list" | "grid" | "list" | Collapsed arrangement. |
| activeId | string | null | Controlled active id (null = closed). Omit for uncontrolled use. | |
| defaultActiveId | string | null | null | Initial active id in uncontrolled mode. |
| onActiveChange | (id: string | null) => void | Fires whenever the active card changes (open with an id, close with null). | |
| onAction | (item: CasementItem) => void | Called when a non-link action button is pressed in the expanded card. | |
| backdropOpacity | number | 0.62 | Backdrop dim opacity, 0–1. |
| backdropBlur | number | 8 | Backdrop blur radius in px (the "breath of blur"). |
| expandedMaxWidth | number | 460 | Expanded card max width in px. |
| closeOnOutsideClick | boolean | true | Close when the backdrop is clicked. |
| closeOnEscape | boolean | true | Close on the Escape key. |
| morphMedia | boolean | true | Morph the media through the FLIP. When false it crossfades instead. |
| accent | string | "#93c5fd" | Single cool accent hex for focus rings / hover. |
| className | string | Extra classes for the collapsed list/grid wrapper. |
Honors prefers-reduced-motion with a designed static fallback, and passes className through.