Documentation
Morph any box into any other box.
deltached is a small, framework-agnostic controller for shared-element transitions. Hand it two elements — a source and a target — and it animates one growing into the other, plays it back in reverse on close, survives interruption, and carries marked children between the two layouts. Everything visual stays yours; deltached only measures and animates.
Overview
It helps to drop the word "modal". deltached doesn't render dialogs, dropdowns, or lightboxes — it morphs a box into a box. That single primitive composes into all of them, and it nests: a trigger can live inside a panel that itself grew from another trigger.
The pieces you wire up:
- source — any element the target should grow out of (a button, a card, a form field).
- target — the destination, and the surface that actually morphs. deltached pins it out of flow and animates its real box (translate + width/height, never scale), so border-radius and proportions hold at every frame.
- content — the children inside the target, crossfaded (and softly blurred) while the surface travels.
- backdrop — an optional overlay faded in sync.
- persisted children — descendants present in both the source and the target that fly between the two layouts as their own layer instead of crossfading. More below.
Every pattern in these docs is demonstrated live — buttons into forms, fields into selects, cards into fullscreen, thumbnails into a lightbox, toolbar buttons into directional menus — on the examples page.
Installation
Install the package and its GSAP peer with your package manager of choice.
npm install deltached gsappnpm add deltached gsapyarn add deltached gsapbun add deltached gsapUsing React, Vue, or Svelte? Add the official wrapper alongside —@deltached/react, @deltached/vue, or@deltached/svelte (for example,npm install @deltached/react deltached gsap). Each wraps this same controller; see Quick start.
Quick start
A source the panel grows out of, a target that morphs. The JavaScript and Angular tabs wire the vanilla controller against the markup below; React, Vue, and Svelte use their official wrapper, which creates and destroys the controller — and reveals the target — for you.
<!-- The source: any element the panel should grow out of. -->
<button id="trigger">Open panel</button>
<!-- The target: also the surface that morphs. Starts hidden. -->
<div id="panel" hidden>
<button data-close>Close</button>
<h2>Now you see me</h2>
<p>This box grew out of the button — same controller, in reverse, on close.</p>
</div>import { createDeltachedTransition } from "deltached";
const trigger = document.querySelector<HTMLElement>("#trigger")!;
const panel = document.querySelector<HTMLElement>("#panel")!;
const transition = createDeltachedTransition({
target: panel, // destination + the surface that morphs
source: trigger, // where it grows from
hooks: {
// Make the panel measurable before the read phase…
beforeEnter: () => (panel.hidden = false),
// …and hide it again once it has fully left.
afterLeave: () => (panel.hidden = true),
},
});
trigger.addEventListener("click", () => transition.enter());
panel
.querySelector("[data-close]")!
.addEventListener("click", () => transition.leave());import { useDeltached } from "@deltached/react";
export function MorphPanel() {
const { sourceRef, targetRef, enter, leave } = useDeltached();
return (
<>
<button ref={sourceRef} onClick={() => enter()}>
Open panel
</button>
<div ref={targetRef}>
<button onClick={() => leave()}>Close</button>
<h2>Now you see me</h2>
</div>
</>
);
}<script setup lang="ts">
import { useDeltached } from "@deltached/vue";
const { sourceRef, targetRef, enter, leave } = useDeltached();
</script>
<template>
<button :ref="sourceRef" @click="enter()">Open panel</button>
<div :ref="targetRef">
<button @click="leave()">Close</button>
<h2>Now you see me</h2>
</div>
</template>import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
ViewChild,
} from "@angular/core";
import { createDeltachedTransition, type DeltachedTransition } from "deltached";
@Component({
selector: "app-morph-panel",
standalone: true,
template: `
<button #trigger (click)="tx.enter()">Open panel</button>
<div #panel hidden>
<button (click)="tx.leave()">Close</button>
<h2>Now you see me</h2>
</div>
`,
})
export class MorphPanelComponent implements AfterViewInit, OnDestroy {
@ViewChild("trigger") trigger!: ElementRef<HTMLButtonElement>;
@ViewChild("panel") panel!: ElementRef<HTMLDivElement>;
tx!: DeltachedTransition;
ngAfterViewInit() {
const panel = this.panel.nativeElement;
this.tx = createDeltachedTransition({
target: panel,
source: this.trigger.nativeElement,
hooks: {
beforeEnter: () => (panel.hidden = false),
afterLeave: () => (panel.hidden = true),
},
});
}
ngOnDestroy() {
this.tx.destroy();
}
}<script lang="ts">
import { useDeltached } from "@deltached/svelte";
const d = useDeltached();
</script>
<button {@attach d.source} onclick={() => d.enter()}>Open panel</button>
<div {@attach d.target}>
<button onclick={() => d.leave()}>Close</button>
<h2>Now you see me</h2>
</div>Core concepts
The target is the surface
There's no extra "ghost" element. deltached takes the target out of flow (position: fixed), shapes it exactly like the source, and animates that one box to the target's natural frame. Because both endpoints live in the same property space — x, y, width, height — an interrupted transition simply retweens from wherever it is. Rapid open/close never tears the DOM.
Lifecycle & hooks
A transition moves through four phases, readable any time viatransition.phase:
idle entering open leaving idle
Hooks fire at the edges, so you can do the work the core leaves to you:
- beforeEnter — before any measurement. Make the target's container visible here.
- afterEnter — once the enter has fully settled (focus the panel).
- beforeLeave — right before the leave starts.
- afterLeave — after the target is reset, same synchronous frame, so nothing intermediate paints. Hide or unmount here.
Interruptibility
Calling leave() mid-enter (or enter() mid-leave) doesn't restart — it reverses from the current frame, and any persisted layers ride home with it. You can spam the trigger and it stays glued together. enter() resolves false when a newer call supersedes it, which is handy for awaiting a settled state.
Placement
Placement decides where the target comes to rest. With the default"center", deltached morphs into whatever frame your CSS produces and then steps out of the way. The origin family instead keeps the target's natural size but anchors it to the source, so it visibly grows out of the trigger — a dropdown, a popover, a menu.
"center"CSS-owned- The target rests wherever its own CSS lays it out (a flex- or grid-centered overlay, say). deltached morphs into that frame, then hands all positioning back to CSS on settle. The default.
"origin"grows from center- Keeps the target's natural size, but positions it over the source so it grows evenly out of the trigger's center.
"origin-top"grows ↑- Anchored to the source, expanding upward, centered across.
"origin-bottom"grows ↓- A dropdown: anchored to the source, expanding down, centered on the cross axis.
"origin-left"grows ←- Anchored to the source's trailing edge, expanding left.
"origin-right"grows →- Anchored to the source's leading edge, expanding right.
"origin-auto"flip-aware ↕- A smart dropdown: anchors to the source's top or bottom edge and grows toward whichever side has more room, flipping live as the viewport changes. The cross axis leans away from the nearer edge.
Set a default on the instance, or choose per call. Origin panels are clamped to stay placementMargin px clear of every viewport edge, and re-anchor live as the window resizes.
import { createDeltachedTransition } from "deltached";
const dropdown = createDeltachedTransition({
target: menu,
source: button,
placement: "origin-bottom", // grow straight down from the trigger
placementMargin: 12, // keep 12px clear of every viewport edge
});
// …or decide the direction per open — here, a flip-aware dropdown:
button.addEventListener("click", () =>
dropdown.enter({ from: button, placement: "origin-auto" }),
);Timings
The defaults are tuned for a soft dialog. Override any subset throughtimings — the rest fall back to the defaults below. Fades and the backdrop are expressed as fractions of the duration, so they scale with whatever speed you pick.
// A crisp popover — fast, no content blur, no backdrop dim:
createDeltachedTransition({
target: popover,
source: field,
placement: "origin-auto",
timings: {
enterDuration: 0.34,
leaveDuration: 0.24,
contentBlur: 0,
backdropOpacity: 0,
},
});
// A heavier dialog — slower, with a frosted crossfade and a dim backdrop:
createDeltachedTransition({
target: dialog,
source: trigger,
backdrop,
timings: { enterDuration: 0.6, contentBlur: 12, backdropOpacity: 0.2 },
});enterDurationnumberdefault0.6- Seconds for the enter morph.
leaveDurationnumberdefault0.6- Seconds for the leave morph.
enterEasestringdefault"deltached.enter"- GSAP ease for entering. A snappy take-off with a soft landing, registered for you.
leaveEasestringdefault"deltached.leave"- GSAP ease for leaving — a touch tighter, so exits feel faster.
contentFadeFractionnumberdefault0.3- Fraction of the duration spent fading the target's content in/out.
handoffFractionnumberdefault0.3- Final fraction of the leave where the source fades back in under the surface — what prevents it flashing before the morph ends.
backdropFadeFractionnumberdefault0.55- Fraction of the duration used for the backdrop fade.
backdropOpacitynumberdefault0.2- Backdrop opacity while open.
contentBlurnumberdefault12- Max blur (px) applied to content mid-morph.
0disables the filter entirely. reducedMotionDurationnumberdefault0.15- Plain-fade duration used when the user prefers reduced motion.
Persisted children
Sometimes a piece of content exists on both sides of the morph — a thumbnail that's also the lightbox's hero, a label that's also the panel's title. Mark those elements with the samedata-deltached-id on the source and the target, opt in withpersist, and they fly between layouts as their own layer (image, text, canvas, or a cloned surface) instead of crossfading with everything else.
<!-- source: a card -->
<button id="card">
<img data-deltached-id="cover" src="/thumb.jpg" alt="" />
<span data-deltached-id="title">Sunset over the bay</span>
</button>
<!-- target: the lightbox it opens into -->
<div id="lightbox" hidden>
<img data-deltached-id="cover" src="/full.jpg" alt="" />
<h2 data-deltached-id="title">Sunset over the bay</h2>
</div>createDeltachedTransition({
target: lightbox,
source: card,
// Matching ids fly as their own layer; everything else still crossfades.
// Enable with defaults — tune the handoff/overflow/adapters when you need to.
persist: {},
});Accessibility & motion
deltached respects prefers-reduced-motion automatically: it skips the morph and plays a short crossfade instead, keeping the exact same lifecycle and hooks (an origin panel still opens at the source). The roles your UI needs — role="dialog", focus management, scroll lock, Escape, restore-focus — are yours to add in the hooks, since only you know whether the surface is a dialog, a menu, or a popover.
While it hands a source off to the surface, deltached toggles that element's opacity and visibility and marks it withdata-deltached-morphing. Suppress your own transitions on that attribute so a trigger doesn't fade on its own each time it disappears and reappears around the morph:
/* Stand down while deltached drives the handoff. */
[data-deltached-morphing] {
transition: none;
}API reference
The entry point is createDeltachedTransition(config) (ornew DeltachedTransition(config) — identical). It returns a controller you keep for the element's lifetime.
DeltachedConfig
targetHTMLElementrequired- The destination — and the surface that actually morphs. deltached measures its natural (open) frame and animates that same box from the source's frame.
sourceHTMLElement | nulldefaultnull- Default origin element. Override per call with
enter({ from }); one of the two must resolve to a connected element orenter()warns and bails. backdropHTMLElement | nulldefaultnull- An element faded in and out in sync with the morph (a dimmed overlay). Its opacity is driven for you.
contentHTMLElement[] | (() => HTMLElement[])defaulttarget children- Elements crossfaded inside the target while it morphs. Resolved fresh on each transition; defaults to the target's direct children.
placementPlacementdefault"center"- Where the target settles.
"center"leaves it to your CSS; theoriginfamily anchors it to the source. See Placement. placementMarginnumberdefault16- Viewport gutter (px) kept on every edge when an origin-placed panel is clamped on screen. Ignored for
center. timingsPartial<DeltachedTimings>default{}- Per-instance overrides for durations, eases, blur and backdrop. See Timings.
hooksDeltachedHooksdefault{}- Lifecycle callbacks for everything the core doesn't own — visibility, scroll lock, focus, mounting.
persistPersistConfig | falsedefaultfalse- Opt in to shared-element continuity for marked descendants. Pass
{}for defaults; omit or passfalseto disable. See Persist.
Instance methods & getters
enter(options?)=> Promise<boolean>- Morphs the target in from the source. Resolves
truewhen it settles,falseif skipped or interrupted. A no-op while alreadyopenorentering.options:{ from?, placement? }. leave()=> Promise<boolean>- Morphs the target back onto the last source. The source only reappears during the final handoff window — never before the transition ends.
destroy()=> void- Kills any running animation, reverts every inline style the system touched, and detaches its listeners. Call it on unmount.
phaseTransitionPhase- Read-only. One of
"idle","entering","open","leaving". isOpen / isAnimating / isIdleboolean- Read-only convenience getters derived from
phase.
DeltachedTimings
enterDurationnumberdefault0.6- Seconds for the enter morph.
leaveDurationnumberdefault0.6- Seconds for the leave morph.
enterEasestringdefault"deltached.enter"- GSAP ease for entering. A snappy take-off with a soft landing, registered for you.
leaveEasestringdefault"deltached.leave"- GSAP ease for leaving — a touch tighter, so exits feel faster.
contentFadeFractionnumberdefault0.3- Fraction of the duration spent fading the target's content in/out.
handoffFractionnumberdefault0.3- Final fraction of the leave where the source fades back in under the surface — what prevents it flashing before the morph ends.
backdropFadeFractionnumberdefault0.55- Fraction of the duration used for the backdrop fade.
backdropOpacitynumberdefault0.2- Backdrop opacity while open.
contentBlurnumberdefault12- Max blur (px) applied to content mid-morph.
0disables the filter entirely. reducedMotionDurationnumberdefault0.15- Plain-fade duration used when the user prefers reduced motion.
PersistConfig
Every field is optional — persist: {} enables the feature with the defaults below. Pass false (or omit it) to turn it off.
attributestringdefault"data-deltached-id"- The attribute whose value is the match key. An element on the source and one on the target sharing the same value form a pair.
enabledbooleandefaulttruefalsebehaves exactly like omittingpersistaltogether.overflow"clip-to-surface" | "allow"default"clip-to-surface"- Clip the flying layers to the morphing surface's animated silhouette (so they read as its content), or let them overflow it.
handoffPersistHandoffConfigdefault{}- Tunes the central dual-snapshot crossfade:
enabled,at,window,easing. The strong change is hidden mid-travel, never as a late cut. adaptersPartial<Record<PersistKind, PersistAdapter>>defaultbuilt-ins- Override or extend the per-kind handlers —
text,image,canvas,surface,custom. Bring a custom adapter for exotic content. classify(el: HTMLElement) => PersistKinddefaultdefaultClassify- Override how an element's kind is decided during the read phase.
zIndexnumberdefault9999- z-index of the temporary overlay the layers fly inside.
debugbooleandefaultfalse- Dev diagnostics via
console.warn(duplicate ids, adapter fallbacks, unusable snapshots). Keep off in production.
Also exported
Constants and helpers for advanced use: DEFAULT_TIMINGS,ENTER_CURVE / LEAVE_CURVE andregisterEase, DEFAULT_PLACEMENT /DEFAULT_PLACEMENT_MARGIN, MORPHING_ATTRIBUTE,prefersReducedMotion, the persist primitives (builtinPersistAdapters, defaultClassify,DEFAULT_PERSIST_ATTRIBUTE), plus every type (Placement, EnterOptions,DeltachedHooks, PersistConfig…).