Documentation menu
MorphCard
A shared image carries the card into a full-screen view, and home again.
Example
The same component used in the preview. Works in JSX and TSX.
import { MorphCard, MorphClose } from 'morphui';
import './demo.css';
export default function CardDemo() {
return (
<MorphCard aria-label="A study in motion" card={
<button className="demo-card" type="button" aria-label="Open A study in motion">
<span className="demo-art" data-morph-item="art" />
<span className="demo-card-caption">A study in motion<span aria-hidden="true">↗</span></span>
</button>
}>
<article className="demo-feature">
<span className="demo-art demo-art-wide" data-morph-item="art" />
<MorphClose><button className="demo-close" type="button" aria-label="Close story">×</button></MorphClose>
<div className="demo-story">
<p className="demo-note">A study in motion</p>
<h2>The detail becomes<br />the whole picture.</h2>
<p>The image stays with you as the card opens. It follows its own path, separate from the content coming into focus around it.</p>
<p>Close this view and watch it find its way home.</p>
<MorphClose><button className="demo-primary" type="button">Back to the card</button></MorphClose>
</div>
</article>
</MorphCard>
);
}Preview stylesheet demo.css
.demo-primary,.demo-chip,.demo-card,.demo-close { font:inherit; cursor:pointer; border:0; }
.demo-primary { display:inline-flex; align-items:center; justify-content:center; min-height:48px; padding:0 25px; border-radius:999px; background:var(--morph-accent); color:var(--morph-on-accent); font-weight:500; font-size:15px; white-space:nowrap; }
.demo-chip { padding:14px 22px; border-radius:16px; background:var(--morph-chip); color:var(--morph-text); font-size:15px; }
.demo-primary:active,.demo-chip:active { transform:scale(.98); }
.demo-dialog-panel { width:min(460px,100%); height:min(650px,100%); border-radius:34px; }
.demo-window-panel { width:min(530px,100%); height:min(570px,100%); border-radius:34px; }
.demo-sheet { padding:60px 36px 32px; display:flex; flex-direction:column; gap:20px; }
.demo-sheet h2 { font-size:30px; line-height:1.2; letter-spacing:-.025em; margin:0; font-weight:550; }
.demo-sheet p { margin:0; color:var(--morph-muted); font-size:15px; line-height:1.65; }
.demo-sheet form { display:flex; flex-direction:column; gap:20px; }
.demo-sheet label { display:flex; flex-direction:column; gap:8px; font-size:14px; }
.demo-sheet input { width:100%; height:48px; padding:0 14px; color:var(--morph-text); background:var(--morph-surface); border:1px solid var(--morph-border); border-radius:10px; font:inherit; }
.demo-sheet input::placeholder { color:var(--morph-muted); }
.demo-emblem { display:grid; place-items:center; width:44px; height:44px; border-radius:16px; background:var(--morph-accent-soft); color:var(--morph-accent); font-size:26px; font-weight:600; }
.demo-close { position:absolute; top:18px; right:18px; display:grid; place-items:center; width:38px; height:38px; border-radius:50%; background:var(--morph-raised); color:var(--morph-text); font-size:23px; line-height:1; z-index:2; }
.demo-note { font-size:12px; color:var(--morph-muted); }
.demo-detail { display:flex; justify-content:space-between; padding:12px 0; border-bottom:1px solid var(--morph-border); font-size:14px; }
.demo-success { display:flex; flex-direction:column; gap:24px; }
.demo-success h3 { margin:0; font-size:24px; }
.demo-card { width:240px; padding:0; overflow:hidden; border-radius:24px; background:var(--morph-bg); color:var(--morph-text); text-align:left; box-shadow:0 8px 28px rgb(0 0 0 / .06); }
.demo-art { display:block; width:100%; height:200px; background:radial-gradient(circle at 66% 74%,var(--morph-accent) 0 26%,transparent 26.4%),radial-gradient(circle at 66% 74%,transparent 0 36%,color-mix(in srgb,var(--morph-accent) 34%,transparent) 36.4% 37.6%,transparent 38%),radial-gradient(circle at 26% 26%,var(--morph-muted) 0 7%,transparent 7.4%),var(--morph-raised); }
.demo-card-caption { display:flex; align-items:center; justify-content:space-between; padding:17px 19px; font-size:13px; }
.demo-card-caption span { font-size:18px; }
.demo-art-wide { height:46dvh; }
.demo-story { max-width:680px; margin:0 auto; padding:40px 28px 80px; }
.demo-story h2 { font-size:clamp(32px,5vw,52px); line-height:1.1; letter-spacing:-.03em; margin:18px 0 24px; }
.demo-story p:not(.demo-note) { color:var(--morph-muted); font-size:17px; line-height:1.7; margin:0 0 24px; }
.demo-feature { min-height:100%; }
@media(max-width:480px) { .demo-sheet { padding:66px 24px 28px; } .demo-dialog-panel { height:min(640px,100%); } }Usage
Pass the card through the card prop. Put the same data-morph-item value on the image in the card and the matching image in the expanded content.
The card uses the full-screen variant. Its shared image has an independent visual layer, so closing blur and content lag do not hide the image on its way back.
Use your own image
Use the same image source at both ends. CSS controls its size and crop. Give each image an explicit size or aspect ratio to keep measurements stable.
<MorphCard
aria-label="Mountain journal"
card={
<button aria-label="Read Mountain journal">
<img data-morph-item="cover" src="/mountains.jpg" alt="" width={240} height={200} />
</button>
}
>
<img data-morph-item="cover" src="/mountains.jpg" alt="Mountain landscape" width={1200} height={600} />
<h2>Mountain journal</h2>
<MorphClose><button>Back to the card</button></MorphClose>
</MorphCard>
Make the card accessible
Use a real button for the trigger and give it a useful accessible name. Use meaningful alternative text for content images. If the button’s label already describes the image, an empty alt avoids announcing the same thing twice.
Keep the return visible
Keep the trigger mounted while its panel is open. The closing transition measures its current position. Avoid swapping or removing the trigger image until the close has finished.
API reference
MorphCard props
| Prop | Type / default | Description |
|---|---|---|
card | ReactElementRequired | A single element that forwards its ref to a DOM node. |
children | ReactNodeRequired | Content rendered inside the panel. |
dismissOnTintClick | booleantrue | Close when the surrounding backdrop is clicked. |
panelClassName | string— | Classes on the visible panel. Use this to set its size and appearance. |
className | string— | Classes on the outer native dialog. |
aria-label | string— | An accessible name for the dialog. Set this for every instance. |
onOpenChange | (open: boolean) => void— | Notification when opening starts and closing finishes. |
MorphClose accepts one button as its child. The optional callback reports changes; this release does not expose a controlled open prop.