View Transitions

animateView animates between two DOM states using the browser’s native View Transitions API — whole-page crossfades, shared-element morphs (a thumbnail flying into a hero), and enter/exit animations for appearing and disappearing elements, all driven by Motion’s spring-capable timing.

<script lang="ts">
    import { animateView } from '@humanspeak/svelte-motion'

    let selected = $state<Item | null>(null)

    const open = (item: Item) => {
        animateView(() => {
            selected = item
        }).add(`[data-thumb="${item.id}"]`, '[data-hero]')
    }
</script>
<script lang="ts">
    import { animateView } from '@humanspeak/svelte-motion'

    let selected = $state<Item | null>(null)

    const open = (item: Item) => {
        animateView(() => {
            selected = item
        }).add(`[data-thumb="${item.id}"]`, '[data-hero]')
    }
</script>
mode · live running

Click a tile — it morphs into the hero

How it works

animateView(update, options?) takes an update callback that performs the state change producing the new view. The browser snapshots the page before and after the update, then animates between the snapshots.

Svelte $state mutations made inside update are flushed to the DOM synchronously before the new snapshot is captured — plain assignment just works, no tick() ceremony:

animateView(() => (showDetail = true))
animateView(() => (showDetail = true))

The returned builder is thenableawait it for completion:

await animateView(() => (items = shuffle(items))).add('[data-card]')
console.log('transition finished')
await animateView(() => (items = shuffle(items))).add('[data-card]')
console.log('transition finished')

Opting the page in

A bare call with no chained subject swaps instantly by design — nothing is captured unless something opts in. Chain .layout() on the implicit root subject for a whole-page crossfade:

animateView(() => (theme = 'dark'), { duration: 0.5 }).layout()
animateView(() => (theme = 'dark'), { duration: 0.5 }).layout()

Shared-element morphs

.add(oldTarget, newTarget) pairs two different elements as one layer: the first resolves in the old snapshot, the second in the new one, and the browser morphs between them — the “now playing” pattern:

// Thumbnail grows into the detail hero…
animateView(() => (selected = album)).add(`[data-thumb="${album.id}"]`, '[data-hero]')

// …and morphs back on close.
animateView(() => (selected = null)).add('[data-hero]', `[data-thumb="${album.id}"]`)
// Thumbnail grows into the detail hero…
animateView(() => (selected = album)).add(`[data-thumb="${album.id}"]`, '[data-hero]')

// …and morphs back on close.
animateView(() => (selected = null)).add('[data-hero]', `[data-thumb="${album.id}"]`)

Targets can be CSS selectors or Elements. view-transition-names are generated, applied, and cleaned up automatically.

With a single argument, .add(target) registers every matched element — survivors morph to their new positions, and pure newcomers/leavers can animate via .enter() / .exit():

animateView(() => (filter = 'circles'))
    .add('[data-item]')
    .enter({ opacity: [0, 1], scale: [0.6, 1] })
    .exit({ opacity: [1, 0], scale: [1, 0.6] })
animateView(() => (filter = 'circles'))
    .add('[data-item]')
    .enter({ opacity: [0, 1], scale: [0.6, 1] })
    .exit({ opacity: [1, 0], scale: [1, 0.6] })

.new() and .old() are the ungated variants — they animate the new/old view of a layer whenever it exists, including survivors, for crossfades and slide-throughs.

Matching corner radii across morph endpoints

View-transition snapshots are paints of the live DOM, so an element’s corner rounding is baked into its snapshot as transparency. If the two ends of a shared-element morph have different proportional rounding, the outgoing snapshot’s corners ghost through during the crossfade.

Give both endpoints the same percentage radius so the silhouettes coincide at every scale:

.thumb,
.hero {
    border-radius: 12%; /* not 14px on one and 20px on the other */
}
.thumb,
.hero {
    border-radius: 12%; /* not 14px on one and 20px on the other */
}

For morphs whose aspect ratio changes, this is handled automatically — the layer is cropped with object-fit: cover and its clip radius animates from the old radius to the new. .crop(true) / .crop(false) overrides the automatic behavior.

Options

The second argument sets default transition options for every layer (a subject’s own .layout() / .enter() / … options win), plus interrupt:

animateView(update, {
    type: 'spring',
    visualDuration: 0.4,
    bounce: 0.2,
    interrupt: 'immediate' // skip an in-flight transition; 'wait' (default) queues
})
animateView(update, {
    type: 'spring',
    visualDuration: 0.4,
    bounce: 0.2,
    interrupt: 'immediate' // skip an in-flight transition; 'wait' (default) queues
})

Browser support

Browsers without document.startViewTransition still run the update — the view swaps instantly and the returned promise resolves, so no feature-gating is needed at call sites. As of 2026 the API is supported in Chromium and Safari 18+; Firefox support is in development.

Builder API

MethodDescription
.add(target, newTarget?)Register elements by selector/Element; a second target pairs two elements into one shared-element morph
.layout(options?)Customize the morph timing; on the root subject, opts the page into the crossfade
.enter(keyframes, options?)Animate a pure newcomer’s new view
.exit(keyframes, options?)Animate a pure leaver’s old view
.new(keyframes, options?)Animate the new view of any layer, including survivors
.old(keyframes, options?)Animate the old view of any layer, including survivors
.crop(enabled?)Force the clip + animated corner radii on or off
.class(name)Tag the layer with a view-transition-class for CSS targeting
.group(enabled?)Opt out of DOM-hierarchy layer nesting so the layer escapes an ancestor’s clip
await builderResolves when the transition completes

Related


Based on Motion’s animateView API.