Cards & Layoutv0.1.0
Expandable Card
A card that expands into a detail view.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/expandable-card.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/expandable-card/expandable-card.tsx"use client"; import { useId, useState, type ComponentProps, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Dialog } from "radix-ui"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; export interface ExpandableCardProps extends Omit<ComponentProps<"button">, "children" | "title" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Card title; also the dialog's accessible name. */ title: string; /** Short summary under the title, in both states. */ description?: string; /** Visual at the top of the card, e.g. an image. Shown at 16:9 in both states. */ media?: ReactNode; /** Detail content, revealed once the card expands. */ children: ReactNode; /** Controlled open state. */ open?: boolean; /** Initial open state when uncontrolled. @default false */ defaultOpen?: boolean; /** Called when the card expands or collapses. */ onOpenChange?: (open: boolean) => void; } const MORPH = { type: "spring", stiffness: 330, damping: 32 } as const; export function ExpandableCard({ title, description, media, children, open, defaultOpen = false, onOpenChange, className, ...props }: ExpandableCardProps) { const reduceMotion = useReducedMotion(); const [internal, setInternal] = useState(defaultOpen); const isOpen = open ?? internal; const id = useId(); const layout = reduceMotion ? { duration: 0 } : MORPH; const setOpen = (next: boolean) => { if (open === undefined) setInternal(next); onOpenChange?.(next); }; // Shared pieces carry the same layoutId in the card and the dialog, so each morphs into place. const mediaBlock = media ? ( <motion.div layoutId={`${id}-media`} transition={layout} className="aspect-[16/9] overflow-hidden"> {media} </motion.div> ) : null; return ( <Dialog.Root open={isOpen} onOpenChange={setOpen}> <Dialog.Trigger asChild> <button type="button" className={cn( "group relative block w-full cursor-pointer rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", className, )} {...props} > {/* While open, an invisible copy keeps the card's space so the grid doesn't reflow. */} {isOpen ? ( <div aria-hidden="true" className="invisible"> {media ? <div className="aspect-[16/9]" /> : null} <div className="p-4"> <p className="font-semibold">{title}</p> {description ? <p className="mt-1 text-sm">{description}</p> : null} </div> </div> ) : ( <motion.div layoutId={`${id}-card`} transition={layout} style={{ borderRadius: 16 }} className="relative z-10 overflow-hidden border border-border bg-card text-card-foreground shadow-xs transition-shadow group-hover:shadow-md" > {mediaBlock} <div className="p-4"> <motion.p layoutId={`${id}-title`} layout="position" transition={layout} className="font-semibold tracking-tight text-foreground"> {title} </motion.p> {description ? ( <motion.p layoutId={`${id}-description`} layout="position" transition={layout} className="mt-1 text-sm text-muted-foreground"> {description} </motion.p> ) : null} </div> </motion.div> )} </button> </Dialog.Trigger> <AnimatePresence> {isOpen ? ( <Dialog.Portal forceMount> <Dialog.Overlay asChild forceMount> <motion.div className="fixed inset-0 z-50 bg-zinc-950/30 backdrop-blur-[2px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.base }} /> </Dialog.Overlay> <div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center p-4"> <Dialog.Content asChild forceMount {...(description ? {} : { "aria-describedby": undefined })}> <motion.div layoutId={`${id}-card`} transition={layout} style={{ borderRadius: 20 }} className="pointer-events-auto relative max-h-[calc(100dvh-2rem)] w-full max-w-lg overflow-y-auto border border-border bg-popover text-popover-foreground shadow-xl outline-none" > {mediaBlock} <div className="p-6"> <Dialog.Title asChild> <motion.h2 layoutId={`${id}-title`} layout="position" transition={layout} className="pr-8 text-xl font-semibold tracking-tight text-foreground"> {title} </motion.h2> </Dialog.Title> {description ? ( <Dialog.Description asChild> <motion.p layoutId={`${id}-description`} layout="position" transition={layout} className="mt-1 text-sm text-muted-foreground"> {description} </motion.p> </Dialog.Description> ) : null} {/* Detail content arrives once the card has mostly opened. */} <motion.div className="mt-5 text-sm leading-6 text-foreground" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={{ duration: duration.slow, ease: ease.out, delay: reduceMotion ? 0 : 0.12 }} > {children} </motion.div> </div> <Dialog.Close aria-label="Close" className="absolute top-3 right-3 z-10 inline-flex size-8 cursor-pointer items-center justify-center rounded-full bg-background/80 text-muted-foreground shadow-xs backdrop-blur outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > <X className="size-4" aria-hidden="true" /> </Dialog.Close> </motion.div> </Dialog.Content> </div> </Dialog.Portal> ) : null} </AnimatePresence> </Dialog.Root> ); }
Usage
import { ExpandableCard } from "@/components/ui/expandable-card/expandable-card";
export function Project() {
return (
<ExpandableCard
title="Aurora Design System"
description="Tokens, components and docs for 40 teams."
media={<img src="/aurora.jpg" alt="" className="size-full object-cover" />}
>
<p>The full case study…</p>
</ExpandableCard>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
title* | string | – | Card title; also the dialog's accessible name. |
description | string | – | Short summary under the title, in both states. |
media | ReactNode | – | Visual at the top of the card, e.g. an image. Shown at 16:9 in both states. |
children* | ReactNode | – | Detail content, revealed once the card expands. |
open | boolean | – | Controlled open state. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open: boolean) => void | – | Called when the card expands or collapses. |
Also accepts every native <button> attribute, including ref and className.
- The card, media, title and description share layout ids with the dialog, so each piece morphs into place, and back on close.
- Media is shown at 16:9 in both states, so it scales uniformly instead of stretching mid-morph.
- While open, an invisible copy holds the card's space, so the surrounding grid never reflows.
Accessibility
- The card is a button that opens a real dialog (Radix): focus moves inside, Escape and the close button dismiss it, and focus returns to the card.
- The title is the dialog's accessible name and the description its description.
- With reduced motion enabled, the card opens and closes without morphing.