card-morph
v0.1.2
Published
Morphing card transition component for React.
Maintainers
Readme
card-morph
card-morph is a React component for smooth card-to-detail transitions.

Features
- Card morph handoff measured in DOM space for pixel-accurate close alignment
- Automatic close-target stabilization for transformed, blurred, or faded parent shells
- Open and close easing with tuned defaults
- Reusable geometry hooks (
measureClosedGeometry,getExpandedGeometry,writeGeometry) - Protected motion root, so custom content transforms do not overwrite the card morph
- Keyboard accessibility (
Enter+Spaceto open,Escapeto close) - Body-scroll lock with scroll-position preservation and phase callbacks
- Framework-neutral styling contract (your CSS, your theme)
Install
npm install card-morphor
yarn add card-morphPackage exports
import { CardMorph } from "card-morph";Quick setup
Render CardMorph where a card should expand into a detail view. You provide
the closed card, the expanded overlay, and three geometry callbacks. The
component handles DOM measurement, easing, keyboard open, Escape-to-close, body
scroll locking, and the final handoff back to the real card.
import { CardMorph, type CardMorphBox } from "card-morph";
type StoryGeometry = CardMorphBox & {
heroHeight: number;
titleFontSize: number;
};
const mix = (from: number, to: number, progress: number) => from + (to - from) * progress;
function readClosedGeometry(card: HTMLElement, rect: CardMorphBox): StoryGeometry {
const title = card.querySelector<HTMLElement>(".story-card__title");
const titleFontSize = title ? Number.parseFloat(getComputedStyle(title).fontSize) : 28;
return {
...rect,
heroHeight: rect.height,
titleFontSize
};
}
export function StoryCard() {
return (
<CardMorph<StoryGeometry>
ariaLabel="Open story"
className="story-card"
getExpandedGeometry={(box) => ({
...box,
heroHeight: Math.min(window.innerHeight * 0.64, 720),
titleFontSize: 44
})}
measureClosedGeometry={readClosedGeometry}
writeGeometry={(element, from, to, progress) => {
element.style.setProperty("--story-hero-height", `${mix(from.heroHeight, to.heroHeight, progress)}px`);
element.style.setProperty(
"--story-title-font-size",
`${mix(from.titleFontSize, to.titleFontSize, progress)}px`
);
}}
renderClosed={() => <CardContent expanded={false} />}
renderOverlay={({ close, phase }) => (
<CardContent expanded onClose={close} phase={phase} />
)}
/>
);
}Use CSS variables from writeGeometry in your card styles:
.story-card__hero {
height: var(--story-hero-height);
}
.story-card__title {
font-size: var(--story-title-font-size);
}Your geometry type is yours. Start with CardMorphBox (x, y, width,
height) and add the values your content needs to interpolate: image height,
title size, padding, opacity, or anything else that should move with the morph.
writeGeometry receives an inner content wrapper, not the moving overlay shell.
Use it for CSS variables, opacity, transforms, and content-level interpolation
without overwriting the root card motion.
API
Geometry Callbacks
These callbacks define the morph. They all share your generic geometry type, so the values you measure in the closed card are the same values you write during the animation.
| Callback | Description |
| --- | --- |
| measureClosedGeometry(card, rect) | Reads the resting card. rect is the measured CardMorphBox; return it with any extra content values you want to animate. |
| getExpandedGeometry(box) | Returns the matching geometry for the expanded state. box is full viewport by default, or the value returned by getExpandedBox. |
| writeGeometry(element, from, to, progress) | Runs during motion with progress from 0 to 1. Interpolate from to to and write CSS variables or content transforms to element. |
Render State
Both renderClosed and renderOverlay receive:
type CardMorphRenderState = {
close: () => void;
isExpanded: boolean;
open: () => void;
phase: CardMorphPhase;
};Use open or close for custom buttons, isExpanded to switch content, and
phase for timed reveals.
Phases
type CardMorphPhase = "closed" | "opening" | "open" | "closing" | "settled";| Phase | Meaning |
| --- | --- |
| closed | The overlay is not mounted; the real card is interactive. |
| opening | The overlay is morphing from the measured card to the expanded box. |
| open | The overlay is fully expanded and responsive to resize. |
| closing | The overlay is morphing back to the latest measured card position. |
| settled | The final close handoff frame before the overlay unmounts. |
Props
| Prop | Type | Description |
| --- | --- | --- |
| ariaLabel | string | Accessible label for the pressable closed card. |
| className | string | Base class applied to the closed card and overlay. |
| renderClosed | (state) => ReactNode | Renders the resting card content. |
| renderOverlay | (state) => ReactNode | Renders the expanded and closing overlay content. |
| measureClosedGeometry | (card, rect) => TGeometry | Reads the closed card geometry. |
| getExpandedGeometry | (box) => TGeometry | Describes the expanded content geometry. |
| writeGeometry | (element, from, to, progress) => void | Writes interpolated content geometry each frame. |
| getExpandedBox | () => CardMorphBox | Overrides the expanded bounds. Defaults to the viewport. |
| getMeasurementRoot | (card) => HTMLElement \| null | Advanced override for the element temporarily neutralized before close measurement. The package automatically handles the common transformed-parent case. |
| onPhaseChange | (phase) => void | Fires when the component enters a new phase. |
| lockBodyScroll | boolean | Locks body scroll while the overlay is mounted. Defaults to true. |
| openDuration | number | Open duration in milliseconds. Defaults to 380. |
| closeDuration | number | Close duration in milliseconds. Defaults to 460. |
| openEase | (progress) => number | Custom easing for the open animation. |
| closeEase | (progress) => number | Custom easing for the close animation. |
| openRadius | number | Border radius at the expanded state. Defaults to 0. |
| closedRadius | number | Border radius at the closed state. Defaults to 29. |
| pressableClassName | string | Class added to the interactive closed card. Defaults to is-pressable. |
| ghostClassName | string | Class added to the hidden closed card while the overlay is mounted. Defaults to is-hidden. |
| overlayClassName | string | Class added to the fixed overlay. Defaults to is-overlay. |
| expandedClassName | string | Class added while expanded content is active. Defaults to is-expanded. |
| closingClassName | string | Class added during the close animation. Defaults to is-closing. |
| settledClassName | string | Class added for the final close handoff frame. Defaults to is-settled. |
Repo layout
src/components/card-morph/— reusable component sourcesrc/index.ts— package entrypointvite.lib.config.ts— package build entrytsconfig.lib.json— declaration output settingssrc/App.tsx— demo app for integration testing
Demo development
npm run devstarts local preview on127.0.0.1npm run buildbuilds the demo and package bundle/types
The npm package does not include demo images. Demo-only artwork lives in
public/assets/ for the GitHub repo and local preview.
License and Use
card-morph is released under the MIT License. The package is provided as-is,
without warranty or liability for production use. The demo artwork in this repo
is original and included for preview purposes; bring your own visuals for your
app and test the interaction in your own layout before shipping.
