# Casement — Animated Card for React

> 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.

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

## Install

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

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

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

## Props (`CasementProps`)

| 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. |

## Usage example

```tsx
import { Casement, type CasementItem } from "@/components/crucible/casement";

const ITEMS: CasementItem[] = [
  {
    id: "north-facing",
    title: "North-facing study",
    meta: "Interior — 4 min read",
    accent: "#5b6b8c",
    action: "Read",
    content: (
      <p>
        Light that never turns harsh. A north window trades brilliance for
        constancy: the same soft, shadowless wash from breakfast to dusk, which is
        why studios and reading rooms have chased it for centuries. Pair it with
        pale walls and the room seems to hold its own quiet daylight long after the
        sun has moved on.
      </p>
    ),
  },
  {
    id: "tide-tables",
    title: "Tide tables",
    meta: "Field notes — 6 min read",
    accent: "#3f6b63",
    action: "Read",
    content: (
      <p>
        Two highs, two lows, and a rhythm older than any clock. Learning to read
        the tables turns a coastline into a timetable — when the flats open, when
        the channel fills, when to be somewhere else entirely. The numbers are dry;
        the water they describe is anything but.
      </p>
    ),
  },
  {
    id: "long-exposure",
    title: "Long exposure",
    meta: "Technique — 5 min read",
    accent: "#6a5680",
    action: "Read",
    content: (
      <p>
        Hold the shutter open and time itself becomes the subject. Moving water
        smooths to fog, headlights stretch into ribbons, a crowd dissolves while
        the architecture stays razor-sharp. The trick is subtraction: a tripod, a
        dark filter, and the patience to let a single frame gather thirty seconds of
        the world at once.
      </p>
    ),
  },
  {
    id: "cold-brew",
    title: "Cold brew, slowly",
    meta: "Kitchen — 3 min read",
    accent: "#7a5a3c",
    action: "Read",
    content: (
      <p>
        No heat, no rush — just coarse grounds and cold water left to their own
        devices overnight. Time does the extracting, pulling sweetness and body out
        of the beans while leaving the bitterness behind. Strain it once, keep it
        cold, and a week of unhurried mornings is waiting in the jar.
      </p>
    ),
  },
];

export default function CasementDemo() {
  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.035),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full max-w-sm flex-col items-center gap-5 px-4">
        <Casement items={ITEMS} className="w-full" />
        <p className="text-sm text-neutral-500">Click a card — it opens in place.</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 (`casement.tsx`)

```tsx
"use client";

import { AnimatePresence, motion, type Transition } from "motion/react";
import * as React from "react";
import { createPortal } from "react-dom";

import { useReducedMotion } from "@/hooks/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. If the layout
  // animation never reports completion (e.g. opened on mount with nothing to
  // morph from), reveal the body anyway so it can never stay hidden.
  React.useEffect(() => {
    if (!activeItem) {
      setLanded(false);
      return;
    }
    const t = window.setTimeout(() => setLanded(true), 900);
    return () => window.clearTimeout(t);
  }, [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)}
      style={{ ["--casement-accent" as string]: accent }}
    >
      {/* 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)] focus-visible:ring-1 focus-visible:ring-[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>
  );
}
```
