Skip to content
WANCRAFT

B.08Cards & Containers

Expandable Project Card

A portfolio card that expands into a full project dialog through a shared-layout animation.

ReactMotionTailwindadvanced

Overview

Expandable Project Card shows a compact project tile — image, category, year and title. Activating it morphs the same image and heading into a modal dialog with the description, tags and an optional link, using Motion's shared layout (layoutId) and a spring you control. The dialog is a native modal <dialog> rendered in the top layer, so it escapes overflow and transformed ancestors; it locks page scroll, traps Tab, closes on Escape, the backdrop or the close button, and returns focus to the card. On small screens it becomes a full-height sheet.

01Installation

Method 01 · Registry

CLI verified

Uses the shadcn CLI in a project initialised with shadcn init. Installs the file to components/ui/expandable-project-card.tsx and adds its dependencies.

npx shadcn@latest add https://wancraft.my/r/expandable-project-card.json

Method 02 · Copy and paste

  1. 1. Install the dependencies below.
  2. 2. Copy the source from the Code tab into components/ui/expandable-project-card.tsx.
  3. 3. Import it and adjust props.
npm install motion
Dependencies
motion
Peer
react ≥ 18, tailwindcss ≥ 4
Added
1 Oct 2026
Updated
—

02Usage

The default configuration. Change properties above to generate a tailored example in the Usage tab.

example.tsx
import { ExpandableProjectCard } from "@/components/ui/expandable-project-card";

export function ExpandableProjectCardExample() {
  return (
    <ExpandableProjectCard
      className="w-80"
      title="Tidewater"
      category="Brand identity"
      year={2026}
      description="A coastal research institute rebuilt around a single tidal line."
      tags={["Identity", "Type", "Motion"]}
      image="/projects/tidewater.jpg"
      href="https://example.com"
    />
  );
}

03Props

PropTypeDefaultDescription
titlestring—Project title; labels the dialog.
categorystring—Short category label.
yearstring | number—Year shown next to the category.
descriptionstring—Body copy shown in the dialog; describes the dialog (aria-describedby).
imagestring—Image URL or data URI. Without it, an accent gradient is shown.
imageAltstring""Alt text. Empty marks the image as decorative.
tagsstring[][]Tags listed in the dialog.
href / linkLabelstring— / "View project"Optional external link (opens in a new tab) and its label.
spring{ stiffness: number; damping: number; mass: number }{ stiffness: 300, damping: 32, mass: 0.9 }Spring for the shared-layout expansion.
aspect"16/10" | "4/3" | "1/1""16/10"Image ratio, shared by the card and the dialog so the image never stretches.
roundednumber18Corner radius in px (animated during the morph).
accentstring"#FF7448"Placeholder gradient, link, hover and focus colour.
backdropBlurbooleantrueBlur the page behind the dialog.
closeLabelstring"Close"Accessible name of the close button.
onOpenChange(open: boolean) => void—Called when the dialog opens or closes.

04Features

  • 01Shared-layout morph (layoutId) of the card, image, category and title into the dialog
  • 02Native modal <dialog> in the top layer — escapes overflow: hidden and transformed parents
  • 03Full-height bottom sheet on small screens, centred panel from 640px
  • 04Image prop with a gradient fallback; corner radius animates during the morph
  • 05Body scroll lock with scrollbar compensation
motion · layoutmotion · springinput · clickinput · keyboardinput · hover

05Accessibility

  • The card is a <button> with aria-haspopup="dialog" and aria-expanded.
  • The dialog is modal (showModal + aria-modal), labelled by the project title and described by the description.
  • Focus moves to the close button on open, Tab is trapped inside, and focus returns to the card after the closing animation.
  • Escape, the close button and a backdrop click all close the dialog; the rest of the page is inert while open.
  • With prefers-reduced-motion the morph, fades and staggered content appear instantly.

06Limitations

  • Each card owns its dialog; for very long lists, consider lifting the open state to render a single dialog.
  • The image keeps the same aspect ratio in both states to avoid distortion during the morph.