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.
Watch it
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.
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-nameto the HTML: every page ends on the Index, so static names would pair up across pages.vt.jssets names only for the row move and clears them afterwards. - Tiles list
- The six tile slugs live in
vt.js(TILES) and insync-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.jsloads in the head (not deferred):pagerevealfires before the first frame.
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();
});
});
})();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.