Docs
Everything ships from one import: components for the full pattern, a hook for just the engine, and two optional stylesheets.
Install
Requires React 19+. ESM only. TypeScript types included.
Usage
import * as Aperto from "aperto"
import "aperto/styles.css" // recommended presets (optional)
import "aperto/springs.css" // spring easing custom properties (optional)
export function Gallery() {
return (
<Aperto.Root>
<Aperto.Card id="album-1">
<Aperto.Shared name="cover" kind="media">
<img src="/cover.jpg" alt="" />
</Aperto.Shared>
</Aperto.Card>
<Aperto.Detail id="album-1">
<Aperto.Overlay />
<Aperto.Shared name="cover" kind="media">
<img src="/cover.jpg" alt="" />
</Aperto.Shared>
<Aperto.Title>Album title</Aperto.Title>
<Aperto.Close>Close</Aperto.Close>
</Aperto.Detail>
</Aperto.Root>
)
}Card and Detail pair by id; Shared elements pair by name within that pair. During a morph each participant gets a generated, namespaced view-transition-name that is removed when the transition settles — steady-state DOM stays clean, grids and multiple roots never collide.
Root
| Prop | Type | Notes |
|---|---|---|
| defaultOpen | string | null | Card id to render expanded on mount (uncontrolled). |
| open | string | null | Controlled open card id. Pair with onOpenChange. |
| onOpenChange | (id: string | null) => void | Fires on every open/close intent, controlled or not. |
| motion | "auto" | "none" | "none" swaps instantly with no morph — focus, scroll and semantics unchanged. Handy in tests. |
const [open, setOpen] = useState<string | null>(null)
<Aperto.Root open={open} onOpenChange={setOpen}>…</Aperto.Root>Programmatic control
useAperto() works anywhere inside Root — open from a keyboard shortcut, a router effect, a command palette. State reads are per-component subscriptions: opening a card re-renders that card and its detail, never the rest of the grid.
function SearchPalette() {
const { open, close, openId } = Aperto.useAperto()
// Same transition path as tapping the card.
useHotkey("g 1", () => open("album-1"))
useHotkey("Escape", () => close())
return <kbd data-open={openId != null}>g 1</kbd>
}Parts
| Prop | Type | Notes |
|---|---|---|
| Card | id, asChild, ...button props | The collapsed trigger. Renders a <button> with aria-haspopup and aria-expanded. |
| Detail | id, asChild, collapseTo, container | The expanded view. Portalled; role=dialog, aria-modal, Esc closes, background inert, focus managed. |
| Shared | name, kind: "media" | "text" | "auto", asChild | An element that morphs between the pair. kind picks the anti-stretch treatment. |
| Overlay | closeOnClick, asChild | Backdrop, portalled separately so it fades instead of morphing. Click closes by default. |
| Title | asChild | Labels the dialog via aria-labelledby automatically. |
| Close | asChild | Collapses the open detail. |
Every part exposes data-state="collapsed | expanding | expanded | collapsing" and supports asChild. Mark an element inside Detail with data-aperto-autofocus to receive initial focus. collapseTo controls closing when the card is off-screen: "auto" (morph when visible, fade otherwise), "morph" (scroll it into view first), or "fade".
Corners
Border-radius interpolates through the morph automatically — Aperto reads both endpoints and drives the corners on the transition pseudo with the same spring as the box. One rule: the radius has to be declared on the element that morphs. Snapshots don't inherit a parent's overflow: clip, so a square shared element inside a rounded card will fly with square corners.
/* Corners on a Shared element must live ON the element —
a clipping parent's radius is not part of its snapshot. */
<Aperto.Card id="photo-1" className="rounded-3xl overflow-clip">
<Aperto.Shared name="photo" kind="media" className="rounded-3xl">
<img src="/photo.jpg" alt="" />
</Aperto.Shared>
</Aperto.Card>Styling
aperto/styles.css ships sensible defaults: overlay and detail positioning, anti-stretch snapshot treatment, spring timing, and an opacity-fade fallback for reduced motion and unsupported browsers. Everything is overridable — it's all plain CSS on stable hooks.
/* State hooks on every part — e.g. the preset hides the
origin card while its detail is up; opt out per card: */
[data-aperto-card][data-state="expanded"] { opacity: 1; }
/* The morph is plain view-transition CSS */
::view-transition-group(.aperto-card) {
animation-duration: var(--aperto-spring-snappy-duration);
animation-timing-function: var(--aperto-spring-snappy);
}
/* Or override the shorthand hooks from styles.css */
:root {
--aperto-ease: var(--aperto-spring-bounce);
--aperto-duration: var(--aperto-spring-bounce-duration);
--aperto-overlay-bg: rgb(0 0 0 / 0.6);
--aperto-shadow: 0 24px 64px rgb(0 0 0 / 0.35); /* carried by the flying box */
}Browser support
Full morph in every browser with same-document View Transitions — Chrome 111+, Edge 111+, Safari 18+, Firefox 144+. Everywhere else the same components perform an instant swap (or an opacity fade with the preset stylesheet), with scroll lock, focus management and dialog semantics intact. Detection is "startViewTransition" in document — never user-agent sniffing. prefers-reduced-motion always wins.