卡片展开弹层
NewApp Store 式卡片展开:卡片经共享 layoutId 直接变形为居中弹层,背景整页重模糊;扩展内容在表面落定后淡入,关闭时沿原路缩回原位并归还焦点。
TSXcomponents/previews/motion/expanding-card.preview.tsx
"use client";
import { FileCode2 } from "lucide-react";
import { ExpandingCard } from "@/components/motion/expanding-card";
function FileRow({ name }: { name: string }) {
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2">
<FileCode2 className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-xs text-foreground">
{name}
</span>
</div>
);
}
export function ExpandingCardPreview() {
return (
<div className="flex w-full justify-center rounded-2xl bg-[#eef1f5] p-6 sm:p-10">
<div className="grid w-full max-w-2xl grid-cols-1 gap-4 sm:grid-cols-2">
<ExpandingCard
title="Design tokens"
summary="Radius, shadow and type scales, ready to paste."
>
<div className="flex flex-col gap-2">
<FileRow name="tokens.css" />
<FileRow name="radius.md" />
<FileRow name="shadows.md" />
</div>
<p className="mt-3 text-sm text-muted-foreground">
Drop the file straight into your project — every value already
matches the components on this page.
</p>
</ExpandingCard>
<ExpandingCard
title="Motion recipes"
summary="Spring presets tuned for product UI."
>
<div className="flex flex-col gap-2">
<FileRow name="springs.ts" />
<FileRow name="easing.md" />
<FileRow name="guidelines.md" />
</div>
<p className="mt-3 text-sm text-muted-foreground">
The same curves driving this card power every other motion
component in the library.
</p>
</ExpandingCard>
</div>
</div>
);
}
TSXcomponents/motion/expanding-card.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/expanding-card
import { ArrowUpRight, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
// Shared-layout "card expands into a modal" pattern (App Store style): the
// collapsed card *is* the trigger button, and shares a layoutId with the
// expanded panel portalled to <body>. Motion FLIPs between the two boxes —
// grow on open, shrink back on close — so it reads as one surface morphing
// rather than a card that opens a separate dialog.
//
// The trigger stays mounted (just hidden) the whole time it's open, instead
// of unmounting via AnimatePresence like a typical trigger/panel swap. That
// keeps its place in the document flow so a surrounding grid of cards never
// reflows while the modal is up — only its paint toggles.
export interface ExpandingCardProps {
/** Shown in both the collapsed card and the expanded modal; morphs continuously between them. */
title: ReactNode;
/** Shown in both states, under the title. */
summary?: ReactNode;
/** Expanded-only content — fades in once the card has landed. */
children: ReactNode;
/** Collapsed-state affordance text. */
expandHint?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Collapsed card. */
className?: string;
/** Expanded modal surface. */
modalClassName?: string;
}
// Both surfaces set radius via inline `style` (never a Tailwind class) —
// Motion only interpolates `borderRadius`/`boxShadow` during a layout
// animation when they arrive as animatable style values, not static CSS.
const CLOSED_RADIUS = 24;
const OPEN_RADIUS = 32;
// Shared layoutId morph = a FLIP between the card's box and the modal's box.
// SPRING_LAYOUT, not SPRING_PANEL: ease.ts scopes SPRING_LAYOUT to exactly
// this ("shared-layout glides... panels morphing between positions"), while
// SPRING_PANEL is tuned for overlays that materialize in place with no box
// to carry across.
const MORPH = SPRING_LAYOUT;
export function ExpandingCard({
title,
summary,
children,
expandHint = "Expand",
open: openProp,
defaultOpen = false,
onOpenChange,
className,
modalClassName,
}: ExpandingCardProps) {
const uid = useId();
const surfaceId = `${uid}-surface`;
const titleId = `${uid}-title`;
const summaryId = `${uid}-summary`;
const reduce = useReducedMotion();
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [mounted, setMounted] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const wasOpenRef = useRef(false);
const controlled = openProp !== undefined;
const open = controlled ? openProp : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
useEffect(() => setMounted(true), []);
// Lock page scroll while the modal is open (same effect shape as MorphingModal).
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Escape closes.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, setOpen]);
// Move focus into the panel on open — mirrors CommandPalette's autofocus.
useEffect(() => {
if (!open || !mounted) return;
const raf = requestAnimationFrame(() => closeButtonRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open, mounted]);
// Return focus to the trigger once the modal has actually closed.
useEffect(() => {
if (open) {
wasOpenRef.current = true;
return;
}
if (wasOpenRef.current) {
wasOpenRef.current = false;
triggerRef.current?.focus();
}
}, [open]);
return (
<>
<motion.button
ref={triggerRef}
type="button"
layoutId={reduce ? undefined : surfaceId}
transition={reduce ? undefined : MORPH}
style={{ borderRadius: CLOSED_RADIUS }}
onClick={() => setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
aria-hidden={open ? true : undefined}
className={cn(
"group relative flex w-full flex-col gap-2 border border-border bg-card p-5 text-left shadow-sm outline-none transition-colors",
"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20",
open && "invisible",
className,
)}
>
<motion.h3
layoutId={reduce ? undefined : titleId}
transition={reduce ? undefined : MORPH}
className="text-base font-semibold text-foreground"
>
{title}
</motion.h3>
{summary ? (
<motion.p
layoutId={reduce ? undefined : summaryId}
transition={reduce ? undefined : MORPH}
className="text-sm text-muted-foreground"
>
{summary}
</motion.p>
) : null}
<span className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
{expandHint}
<ArrowUpRight className="h-3.5 w-3.5" />
</span>
</motion.button>
{mounted
? createPortal(
<div
aria-hidden={!open}
className={cn(
"fixed inset-0 z-[90]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<motion.div
initial={false}
animate={{ opacity: open ? 1 : 0 }}
// Exit faster than enter (AGENTS.md motion rule) — mirrors
// CommandPalette's backdrop timing.
transition={{ duration: open ? 0.2 : 0.14, ease: EASE_OUT }}
onClick={() => setOpen(false)}
className={cn(
"absolute inset-0 bg-background/10 [backdrop-filter:blur(14px)_saturate(140%)] [-webkit-backdrop-filter:blur(14px)_saturate(140%)]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="panel"
layoutId={reduce ? undefined : surfaceId}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
initial={reduce ? { opacity: 0 } : undefined}
animate={reduce ? { opacity: 1 } : undefined}
exit={
reduce
? {
opacity: 0,
transition: { duration: 0.14, ease: EASE_OUT },
}
: undefined
}
transition={
reduce ? { duration: 0.16, ease: EASE_OUT } : MORPH
}
style={{ borderRadius: OPEN_RADIUS }}
className={cn(
"pointer-events-auto relative flex max-h-[85vh] w-full max-w-lg flex-col gap-2 overflow-y-auto border border-border bg-card p-6 text-left shadow-2xl will-change-transform sm:p-8",
modalClassName,
)}
>
<button
ref={closeButtonRef}
type="button"
onClick={() => setOpen(false)}
aria-label="Close"
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/20"
>
<X className="h-4 w-4" />
</button>
<motion.h3
id={titleId}
layoutId={reduce ? undefined : titleId}
transition={reduce ? undefined : MORPH}
className="pr-10 text-xl font-semibold text-foreground sm:text-2xl"
>
{title}
</motion.h3>
{summary ? (
<motion.p
layoutId={reduce ? undefined : summaryId}
transition={reduce ? undefined : MORPH}
className="text-sm text-muted-foreground"
>
{summary}
</motion.p>
) : null}
<motion.div
initial={
reduce ? { opacity: 0 } : { opacity: 0, y: 10 }
}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: reduce ? 0 : 6,
transition: { duration: 0.12, ease: EASE_OUT },
}}
transition={{
duration: 0.24,
ease: EASE_OUT,
delay: reduce ? 0 : 0.1,
}}
className="mt-3 border-t border-border pt-4"
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>,
document.body,
)
: null}
</>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/expanding-card
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/expanding-card.tsx
"use client";
// ui-lab-ten.vercel.app/components/motion/expanding-card
import { ArrowUpRight, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
// Shared-layout "card expands into a modal" pattern (App Store style): the
// collapsed card *is* the trigger button, and shares a layoutId with the
// expanded panel portalled to <body>. Motion FLIPs between the two boxes —
// grow on open, shrink back on close — so it reads as one surface morphing
// rather than a card that opens a separate dialog.
//
// The trigger stays mounted (just hidden) the whole time it's open, instead
// of unmounting via AnimatePresence like a typical trigger/panel swap. That
// keeps its place in the document flow so a surrounding grid of cards never
// reflows while the modal is up — only its paint toggles.
export interface ExpandingCardProps {
/** Shown in both the collapsed card and the expanded modal; morphs continuously between them. */
title: ReactNode;
/** Shown in both states, under the title. */
summary?: ReactNode;
/** Expanded-only content — fades in once the card has landed. */
children: ReactNode;
/** Collapsed-state affordance text. */
expandHint?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Collapsed card. */
className?: string;
/** Expanded modal surface. */
modalClassName?: string;
}
// Both surfaces set radius via inline `style` (never a Tailwind class) —
// Motion only interpolates `borderRadius`/`boxShadow` during a layout
// animation when they arrive as animatable style values, not static CSS.
const CLOSED_RADIUS = 24;
const OPEN_RADIUS = 32;
// Shared layoutId morph = a FLIP between the card's box and the modal's box.
// SPRING_LAYOUT, not SPRING_PANEL: ease.ts scopes SPRING_LAYOUT to exactly
// this ("shared-layout glides... panels morphing between positions"), while
// SPRING_PANEL is tuned for overlays that materialize in place with no box
// to carry across.
const MORPH = SPRING_LAYOUT;
export function ExpandingCard({
title,
summary,
children,
expandHint = "Expand",
open: openProp,
defaultOpen = false,
onOpenChange,
className,
modalClassName,
}: ExpandingCardProps) {
const uid = useId();
const surfaceId = `${uid}-surface`;
const titleId = `${uid}-title`;
const summaryId = `${uid}-summary`;
const reduce = useReducedMotion();
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [mounted, setMounted] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const wasOpenRef = useRef(false);
const controlled = openProp !== undefined;
const open = controlled ? openProp : internalOpen;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
useEffect(() => setMounted(true), []);
// Lock page scroll while the modal is open (same effect shape as MorphingModal).
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// Escape closes.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, setOpen]);
// Move focus into the panel on open — mirrors CommandPalette's autofocus.
useEffect(() => {
if (!open || !mounted) return;
const raf = requestAnimationFrame(() => closeButtonRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open, mounted]);
// Return focus to the trigger once the modal has actually closed.
useEffect(() => {
if (open) {
wasOpenRef.current = true;
return;
}
if (wasOpenRef.current) {
wasOpenRef.current = false;
triggerRef.current?.focus();
}
}, [open]);
return (
<>
<motion.button
ref={triggerRef}
type="button"
layoutId={reduce ? undefined : surfaceId}
transition={reduce ? undefined : MORPH}
style={{ borderRadius: CLOSED_RADIUS }}
onClick={() => setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
aria-hidden={open ? true : undefined}
className={cn(
"group relative flex w-full flex-col gap-2 border border-border bg-card p-5 text-left shadow-sm outline-none transition-colors",
"hover:border-(--color-border-strong) focus-visible:ring-2 focus-visible:ring-foreground/20",
open && "invisible",
className,
)}
>
<motion.h3
layoutId={reduce ? undefined : titleId}
transition={reduce ? undefined : MORPH}
className="text-base font-semibold text-foreground"
>
{title}
</motion.h3>
{summary ? (
<motion.p
layoutId={reduce ? undefined : summaryId}
transition={reduce ? undefined : MORPH}
className="text-sm text-muted-foreground"
>
{summary}
</motion.p>
) : null}
<span className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground transition-colors group-hover:text-foreground">
{expandHint}
<ArrowUpRight className="h-3.5 w-3.5" />
</span>
</motion.button>
{mounted
? createPortal(
<div
aria-hidden={!open}
className={cn(
"fixed inset-0 z-[90]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
>
<motion.div
initial={false}
animate={{ opacity: open ? 1 : 0 }}
// Exit faster than enter (AGENTS.md motion rule) — mirrors
// CommandPalette's backdrop timing.
transition={{ duration: open ? 0.2 : 0.14, ease: EASE_OUT }}
onClick={() => setOpen(false)}
className={cn(
"absolute inset-0 bg-background/10 [backdrop-filter:blur(14px)_saturate(140%)] [-webkit-backdrop-filter:blur(14px)_saturate(140%)]",
open ? "pointer-events-auto" : "pointer-events-none",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center p-4">
<AnimatePresence initial={false}>
{open ? (
<motion.div
key="panel"
layoutId={reduce ? undefined : surfaceId}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
initial={reduce ? { opacity: 0 } : undefined}
animate={reduce ? { opacity: 1 } : undefined}
exit={
reduce
? {
opacity: 0,
transition: { duration: 0.14, ease: EASE_OUT },
}
: undefined
}
transition={
reduce ? { duration: 0.16, ease: EASE_OUT } : MORPH
}
style={{ borderRadius: OPEN_RADIUS }}
className={cn(
"pointer-events-auto relative flex max-h-[85vh] w-full max-w-lg flex-col gap-2 overflow-y-auto border border-border bg-card p-6 text-left shadow-2xl will-change-transform sm:p-8",
modalClassName,
)}
>
<button
ref={closeButtonRef}
type="button"
onClick={() => setOpen(false)}
aria-label="Close"
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-foreground/20"
>
<X className="h-4 w-4" />
</button>
<motion.h3
id={titleId}
layoutId={reduce ? undefined : titleId}
transition={reduce ? undefined : MORPH}
className="pr-10 text-xl font-semibold text-foreground sm:text-2xl"
>
{title}
</motion.h3>
{summary ? (
<motion.p
layoutId={reduce ? undefined : summaryId}
transition={reduce ? undefined : MORPH}
className="text-sm text-muted-foreground"
>
{summary}
</motion.p>
) : null}
<motion.div
initial={
reduce ? { opacity: 0 } : { opacity: 0, y: 10 }
}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: reduce ? 0 : 6,
transition: { duration: 0.12, ease: EASE_OUT },
}}
transition={{
duration: 0.24,
ease: EASE_OUT,
delay: reduce ? 0 : 0.1,
}}
className="mt-3 border-t border-border pt-4"
>
{children}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>,
document.body,
)
: null}
</>
);
}
API 参考
titleReactNodeShown in both the collapsed card and the expanded modal; morphs continuously between them.
—summary?ReactNodeShown in both states, under the title.
—childrenReactNodeExpanded-only content — fades in once the card has landed.
—expandHint?stringCollapsed-state affordance text.
Expandopen?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—className?stringCollapsed card.
—modalClassName?stringExpanded modal surface.
—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.