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:

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 gsap

Using 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.

index.html
<!-- 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>
morph.ts
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());

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:

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.

dropdown.ts
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.

presets.ts
// 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. 0 disables 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.

gallery.html
<!-- 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>
lightbox.ts
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:

trigger.css
/* 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 or enter() 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; the origin family 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 pass false to disable. See Persist.

Instance methods & getters

enter(options?)=> Promise<boolean>
Morphs the target in from the source. Resolves true when it settles, false if skipped or interrupted. A no-op while already open or entering. 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. 0 disables 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.
enabledbooleandefaulttrue
false behaves exactly like omitting persist altogether.
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…).