<!-- Source: https://motion.svelte.page/examples/view -->

# View Transitions

> Shared-element morphs and filtered galleries with animateView — the browser View Transitions API driven by Motion timing.

**Source:** [https://motion.svelte.page/examples/view](https://motion.svelte.page/examples/view)

**Markdown mirror:** [https://motion.svelte.page/examples/view.md](https://motion.svelte.page/examples/view.md)

---

This mirror preserves the prose, implementation notes, and runnable Svelte source behind the live example page.

## FIG-001: shared-element morphs.

The "now playing" pattern: click a tile and it morphs into the detail hero — two different elements paired into one view-transition layer with .add(thumb, hero). Closing morphs it back.

**Metadata:** tag: `MORPH` | pattern: `add-pair`

### Notes

- `animateView(update)` snapshots the page, runs the update — Svelte `$state` mutations are flushed synchronously, so plain assignment works — then animates between snapshots.
- `.add(oldTarget, newTarget)` pairs two different elements into one layer: the first resolves in the old snapshot, the second in the new one, and the browser morphs between them. Names are generated and cleaned up automatically.
- Both endpoints share the same `border-radius` (a hard 0): snapshots bake rounding in as transparency, so identical radii keep the silhouettes coincident at every scale — no corner ghosting mid-morph.

### Source

#### SharedElement.svelte

Source file: [src/lib/examples/view/demos/SharedElement.svelte](https://github.com/humanspeak/svelte-motion/blob/main/docs/src/lib/examples/view/demos/SharedElement.svelte)

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

    // The "now playing" pattern: a thumbnail and a detail hero are two
    // DIFFERENT elements paired into one view-transition layer with
    // .add(oldTarget, newTarget), so the browser morphs between them.

    type Album = { id: string; color: string; title: string }

    const albums: Album[] = [
        { id: 'coral', color: '#f87171', title: 'Coral Dreams' },
        { id: 'amber', color: '#fbbf24', title: 'Amber Waves' },
        { id: 'jade', color: '#4ade80', title: 'Jade Motion' },
        { id: 'sky', color: '#60a5fa', title: 'Sky Static' }
    ]

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

    const open = (album: Album) => {
        animateView(() => {
            selected = album
        }).add(`[data-thumb="${album.id}"]`, '[data-hero]')
    }

    const close = () => {
        const album = selected
        if (!album) return
        animateView(() => {
            selected = null
        }).add('[data-hero]', `[data-thumb="${album.id}"]`)
    }
</script>

<!-- dk-strip: docs-kit positioning shell — stripped from the published code. -->
<div class="dk-demo-shell">
    <div class="strip">
        <div class="strip-head">
            <span class="micro">// shared element</span>
            <span class="micro readout">
                {selected ? `detail · ${selected.id}` : 'grid · 04 albums'}
            </span>
        </div>

        <!-- Fixed-height stage (not content-driven): the grid and detail
             states differ in natural height, and letting the stage flex
             would jerk the footer as the view swaps. -->
        <div class="stage">
            {#if selected}
                <div class="detail">
                    <div data-hero class="hero" style={`background:${selected.color}`}></div>
                    <p class="title">{selected.title}</p>
                    <motion.button
                        onclick={close}
                        whileHover={{ scale: 1.04 }}
                        whileTap={{ scale: 0.96 }}
                        transition={{ type: 'spring', stiffness: 500, damping: 30 }}
                        style={styleString(() => ({
                            fontFamily: 'var(--brut-mono, monospace)',
                            fontSize: '0.6875rem',
                            textTransform: 'uppercase',
                            letterSpacing: '0.08em',
                            border: '1px solid var(--brut-ink, #0a0a0a)',
                            backgroundColor: 'var(--brut-bg, #f8fcfb)',
                            color: 'var(--brut-ink, #0a0a0a)',
                            padding: '0.5rem 0.875rem',
                            cursor: 'pointer'
                        }))}
                    >
                        ← back
                    </motion.button>
                </div>
            {:else}
                <div class="grid">
                    {#each albums as album (album.id)}
                        <button
                            data-thumb={album.id}
                            class="thumb"
                            style={`background:${album.color}`}
                            aria-label={`Open ${album.title}`}
                            onclick={() => open(album)}
                        ></button>
                    {/each}
                </div>
            {/if}
        </div>

        <div class="strip-foot">
            <span class="micro">pattern: add-pair</span>
            <span class="micro">morph: thumb ↔ hero</span>
        </div>
    </div>
</div>

<style>
    .dk-demo-shell {
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 1.5rem;
        min-height: 400px;
    }

    .strip {
        display: flex;
        flex-direction: column;
        gap: 0.75rem;
    }

    .micro {
        font-family: var(--brut-mono, monospace);
        font-size: 0.6875rem;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: var(--brut-ink-3, #9a9a9a);
    }

    .strip-head,
    .strip-foot {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 1rem;
        border-bottom: 1px dashed var(--brut-rule-2, #bbc4c0);
        padding-bottom: 0.5rem;
    }

    .strip-foot {
        border-bottom: none;
        border-top: 1px dashed var(--brut-rule-2, #bbc4c0);
        padding-top: 0.75rem;
        padding-bottom: 0;
    }

    .readout {
        color: var(--brut-accent, #247768);
    }

    .stage {
        height: 300px;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    /* Matching radii on both morph endpoints — snapshots bake corner
       rounding in as transparency, so identical radii keep the two
       silhouettes coincident at every scale. A hard 0/0 satisfies that
       (no corner ghosting) and reads as brutalist. */
    .thumb,
    .hero {
        box-sizing: border-box;
        border: 1px solid var(--brut-ink, #0a0a0a);
        border-radius: 0;
    }

    .grid {
        display: grid;
        grid-template-columns: repeat(4, 72px);
        gap: 12px;
    }

    .thumb {
        width: 72px;
        height: 72px;
        cursor: pointer;
    }

    .detail {
        display: flex;
        flex-direction: column;
        align-items: center;
        gap: 12px;
    }

    .hero {
        width: 200px;
        height: 200px;
    }

    .title {
        margin: 0;
        font-family: var(--brut-mono, monospace);
        font-size: 0.75rem;
        font-weight: 700;
        letter-spacing: 0.04em;
        color: var(--brut-ink, #0a0a0a);
    }
</style>
```

## FIG-002: filtering with view layers.

Filtering the grid inside animateView: surviving shapes glide to their new slots while entering and leaving shapes scale-fade through .enter() and .exit().

**Metadata:** tag: `ENTER/EXIT` | pattern: `enter-exit`

### Notes

- `.add('[data-view-item]')` registers every matched element — items present in both snapshots morph to their new grid slots automatically.
- `.enter()` animates pure newcomers and `.exit()` pure leavers; survivors get neither, they just morph. `.new()`/`.old()` are the ungated variants for crossfades on surviving layers.
- Rapid filter clicks queue behind the in-flight transition (`interrupt: 'wait' , the default) — pass interrupt: 'immediate'` to skip ahead instead.

### Source

#### FilterGallery.svelte

Source file: [src/lib/examples/view/demos/FilterGallery.svelte](https://github.com/humanspeak/svelte-motion/blob/main/docs/src/lib/examples/view/demos/FilterGallery.svelte)

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

    // Filtering inside animateView: survivors morph to their new grid
    // slots, pure newcomers scale-fade in via .enter(), pure leavers
    // out via .exit().

    type Item = { id: number; kind: 'circle' | 'square' }

    const all: Item[] = Array.from({ length: 12 }, (_, index) => ({
        id: index,
        kind: index % 2 === 0 ? 'circle' : 'square'
    }))

    let filter = $state<'all' | 'circle' | 'square'>('all')
    const visible = $derived(filter === 'all' ? all : all.filter((item) => item.kind === filter))

    const setFilter = (next: typeof filter) => {
        if (next === filter) return
        animateView(() => {
            filter = next
        })
            .add('[data-view-item]')
            .enter({ opacity: [0, 1], scale: [0.6, 1] })
            .exit({ opacity: [1, 0], scale: [1, 0.6] })
    }

    // Brut filter chip: active state swaps the border + fill to accent.
    const filterButtonStyle = (active: boolean) =>
        styleString(() => ({
            fontFamily: 'var(--brut-mono, monospace)',
            fontSize: '0.6875rem',
            textTransform: 'uppercase',
            letterSpacing: '0.08em',
            border: `1px solid ${active ? 'var(--brut-accent, #247768)' : 'var(--brut-rule-2, #bbc4c0)'}`,
            backgroundColor: active
                ? 'var(--brut-accent-soft, rgba(36, 119, 104, 0.1))'
                : 'transparent',
            color: active ? 'var(--brut-accent, #247768)' : 'var(--brut-ink-2, #525252)',
            padding: '0.4rem 0.8rem',
            cursor: 'pointer'
        }))
</script>

<!-- dk-strip: docs-kit positioning shell — stripped from the published code. -->
<div class="dk-demo-shell">
    <div class="strip">
        <div class="strip-head">
            <span class="micro">// filter gallery</span>
            <span class="micro readout">
                {filter} · {String(visible.length).padStart(2, '0')} / 12
            </span>
        </div>

        <div class="filters">
            {#each ['all', 'circle', 'square'] as const as kind (kind)}
                <motion.button
                    aria-pressed={filter === kind}
                    onclick={() => setFilter(kind)}
                    whileHover={{ scale: 1.04 }}
                    whileTap={{ scale: 0.96 }}
                    transition={{ type: 'spring', stiffness: 500, damping: 30 }}
                    style={filterButtonStyle(filter === kind)}
                >
                    {kind}
                </motion.button>
            {/each}
        </div>

        <div class="grid">
            {#each visible as item (item.id)}
                <div class={`item ${item.kind}`} data-view-item aria-hidden="true"></div>
            {/each}
        </div>

        <div class="strip-foot">
            <span class="micro">pattern: enter-exit</span>
            <span class="micro">layer: [data-view-item]</span>
        </div>
    </div>
</div>

<style>
    .dk-demo-shell {
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 2rem;
        min-height: 320px;
    }

    .strip {
        display: flex;
        flex-direction: column;
        gap: 0.75rem;
    }

    .micro {
        font-family: var(--brut-mono, monospace);
        font-size: 0.6875rem;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: var(--brut-ink-3, #9a9a9a);
    }

    .strip-head,
    .strip-foot {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 1rem;
        border-bottom: 1px dashed var(--brut-rule-2, #bbc4c0);
        padding-bottom: 0.5rem;
    }

    .strip-foot {
        border-bottom: none;
        border-top: 1px dashed var(--brut-rule-2, #bbc4c0);
        padding-top: 0.75rem;
        padding-bottom: 0;
    }

    .readout {
        color: var(--brut-accent, #247768);
    }

    .filters {
        display: flex;
        gap: 8px;
    }

    /* Fixed grid height for the 2-row "all" state: filtering down to
       one row must not shrink the stack, or the filter buttons above
       jump as the layout recenters. 40px items keep the 6-column row
       inside narrow mobile viewports. */
    .grid {
        display: grid;
        grid-template-columns: repeat(6, 40px);
        grid-auto-rows: 40px;
        gap: 8px;
        height: 88px;
        align-content: start;
    }

    .item {
        box-sizing: border-box;
        width: 40px;
        height: 40px;
        border: 1px solid var(--brut-ink, #0a0a0a);
    }

    /* Circles stay intrinsically round; squares are hard-cornered. Two
       silhouettes map onto the accent (circle) / ink (square) hues. */
    .item.circle {
        border-radius: 50%;
        background: var(--brut-accent, #247768);
    }

    .item.square {
        border-radius: 0;
        background: var(--brut-ink, #0a0a0a);
    }
</style>
```
