Pattern

Page transitions

The tile is the page: a tile grows into its page, the page shrinks back into its tile, and Back and Forward replay the same moves.

01

Watch it

Index to Unresolved, Back, Forward, then “← Index”. Recorded from the live code.
The same move at a quarter of the speed.
A Thoughts row grows into the article’s band; Back shrinks it into the row.
02

The four moves

In
A tile is clicked: the whole new page grows out of the tile’s box (translate and scale from --vt-x, --vt-y, --vt-s) while the home fades. 360ms on the system ease.
Out
The Index link or Back to the home from any page under a tile: the page scrolls to its top, shrinks into its tile, and melts into it (opacity holds to 70%). Second-level pages shrink into their parent’s tile: articles into Thoughts; Hirsla Support, Privacy, Legal, What’s New, and Links into Hirsla. The home opens with that tile in view.
Row
A Thoughts row is clicked: the row grows into the article’s band and the title travels into the headline. The old page fades out over 160ms; the new page appears over 200ms after it.
Row out
Back or “All Thoughts” from an article: the band shrinks into its row; the headline travels back into the list.
Everything else
A 200ms crossfade.
03

Rules

Only from what is visible
In and Row start only from a tile or row that is on screen; otherwise the page crossfades. Out and Row out scroll the page being left to its top first, and bring the target tile or row into view on arrival.
Back and Forward too
Traversals replay the same moves. A reload never animates.
No names in the markup
Never add view-transition-name to the HTML: every page ends on the Index, so static names would pair up across pages. vt.js sets names only for the row move and clears them afterwards.
Tiles list
The six tile slugs live in vt.js (TILES) and in sync-index.sh. A new tile without both falls back to a crossfade.
Reduced motion and old browsers
A plain page load. Nothing depends on the move. Without JavaScript the 200ms crossfade remains (it is pure CSS).
Load order
vt.js loads in the head (not deferred): pagereveal fires before the first frame.
04

How it works

Cross-document View Transitions. The page being left records the plan on pageswap (which move, and the tile’s box) in sessionStorage; the new page reads it on pagereveal, sets a class on <html> (vt-in, vt-out, vt-row-in, vt-row-out) and the stylesheet runs the keyframes. If storage is blocked, it crossfades.

From site/assets/js/vt.js, lines 60 to 95.

    addEventListener('pageswap', (e) => {
        store.clear();
        if (!e.viewTransition) return;
        const nav = e.activation;
        if (!nav) return; // no Navigation API: keep the plain crossfade
        if (nav.navigationType === 'reload') return e.viewTransition.skipTransition();
        // push (a link), traverse (Back and Forward) and replace all get the same moves.
        const from = location.pathname;
        const to = pathOf(nav.entry.url);
        const isTilePage = to.split('/').length === 3 && tileSlug(to);
        const f = isTilePage && tileFrame(tileSlug(to));
        const r = row(to);
        let plan = null;

        if (visible(f)) {
            const b = f.getBoundingClientRect();
            plan = { kind: 'in', x: b.left, y: b.top + f.clientTop, s: f.clientWidth / innerWidth };
        } else if (visible(r)) {
            name(r, 'vt-row');
            name(r.querySelector('.t-title'), 'vt-title');
            plan = { kind: 'row' };
        } else if (to === '/' && tileSlug(from)) {
            // The tile shows the top of the page, so shrink the top of the page into it,
            // not whatever part of it the reader had scrolled to.
            if (scrollY > 0) scrollTo({ top: 0, behavior: 'instant' });
            plan = { kind: 'out', slug: tileSlug(from) };
        } else if (to === '/thoughts/' && isArticle(from)) {
            // Start from the band the reader saw on arrival, not from off screen.
            if (scrollY > 0) scrollTo({ top: 0, behavior: 'instant' });
            name(document.querySelector('.band'), 'vt-row');
            name(document.querySelector('h1'), 'vt-title');
            plan = { kind: 'row-out', path: from };
        }
        if (plan) store.set(plan);
    });

From site/assets/js/vt.js, lines 96 to 140.

    const clearNames = () =>
        document.querySelectorAll('.band, h1, .t-row, .t-title').forEach((el) => (el.style.viewTransitionName = ''));

    addEventListener('pagereveal', (e) => {
        clearNames(); // a page restored by Back may still carry names from when it was left
        const plan = store.get();
        store.clear();
        if (!e.viewTransition || !plan || !plan.kind) return;
        let cls = '';

        if (plan.kind === 'in') {
            root.style.setProperty('--vt-x', plan.x + 'px');
            root.style.setProperty('--vt-y', plan.y + 'px');
            root.style.setProperty('--vt-s', plan.s);
            cls = 'vt-in';
        } else if (plan.kind === 'out') {
            const f = tileFrame(plan.slug);
            if (!f) return;
            const r = f.getBoundingClientRect();
            if (r.top < 0 || r.bottom > innerHeight) f.scrollIntoView({ block: 'center' });
            setBox(f);
            cls = 'vt-out';
        } else if (plan.kind === 'row') {
            name(document.querySelector('.band'), 'vt-row');
            name(document.querySelector('h1'), 'vt-title');
            cls = 'vt-row-in';
        } else if (plan.kind === 'row-out') {
            const r = row(plan.path);
            if (!r) return;
            const b = r.getBoundingClientRect();
            if (b.top < 0 || b.bottom > innerHeight) r.scrollIntoView({ block: 'center' });
            name(r, 'vt-row');
            name(r.querySelector('.t-title'), 'vt-title');
            cls = 'vt-row-out';
        }

        if (!cls) return;
        root.classList.add(cls);
        e.viewTransition.finished.finally(() => {
            root.classList.remove(cls);
            VARS.forEach((p) => root.style.removeProperty(p));
            clearNames();
        });
    });
})();
05

Testing a change

Test every change with links, Back, and Forward, at phone and desktop width, with reduced motion on and off. Serve the site locally (sh scripts/serve.sh in the website repo): transitions need http, not a file opened from disk.