Transitions
Transitions demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.
App.tsx
import './App.css';
import '@expressive/react';
import { Link, Route, Router } from '@expressive/router';
import type { ReactNode } from 'react';
class Museum extends Router {
static global = false;
deferred = true;
toggle() {
this.deferred = !this.deferred;
}
protected navigate(work: () => void) {
if (this.deferred) return super.navigate(work);
work();
return Promise.resolve();
}
}
const unlock = async () => {
await new Promise((resolve) => setTimeout(resolve, 700));
};
export default () => (
<Museum>
<Route as={Frame}>
<Route as={Foyer} />
<Route to="paintings" label="Paintings" meta={{ tone: 'rose' }} as={Wing} redirect={unlock} fallback={<Opening />} />
<Route to="sculpture" label="Sculpture" meta={{ tone: 'gold' }} as={Wing} redirect={unlock} fallback={<Opening />} />
<Route to="archives" label="Archives" meta={{ tone: 'teal' }} as={Wing} redirect={unlock} fallback={<Opening />} />
</Route>
</Museum>
);
const Frame = (props: { children?: ReactNode }) => (
<div className="container">
<h1>Transitions</h1>
<p>
Every navigation commits through <code>Router.navigate</code>, which
applies it with <code>pending</code>. That marks the updates non-urgent, so
moving to a page that is not ready yet <em>holds the current screen</em>
until the next one is - no fallback flash between pages already up. The
promise it returns settles once the new page is on screen, which is what
drives <code>navigating</code> and the bar above.
</p>
<Address />
<Progress />
<nav className="nav">
<Link to="/">Foyer</Link>
<Link to="/paintings">Paintings</Link>
<Link to="/sculpture">Sculpture</Link>
<Link to="/archives">Archives</Link>
</nav>
<div className="view">{props.children}</div>
<Deferral />
<small>
Each wing takes 700ms to unlock (an async entry guard). With deferral, the
bar runs while the current wing stays put, then address and page swap
together - no flash. Untick the box to commit urgently instead: the address
jumps at once and the wing flashes its fallback while it unlocks. A cold
load falls back either way - there is no previous screen to hold.
</small>
</div>
);
const Address = () => {
const { path } = Museum.get();
return <code className="address">museum.example{path}</code>;
};
const Progress = () => (
<div className="bar" data-busy={Museum.get().navigating || undefined} />
);
const Deferral = () => {
const { deferred, toggle } = Museum.get();
return (
<label className="mode">
<input type="checkbox" checked={deferred} onChange={toggle} />
hold the screen while the next page loads (default)
</label>
);
};
const Foyer = () => (
<section className="room">
<h2>Foyer</h2>
<p>Pick a wing. Each one is behind a slow door.</p>
</section>
);
const Wing = () => {
const { label, meta } = Route.get();
return (
<section className={`room ${meta!.tone}`}>
<h2>{label}</h2>
<p>The {label!.toLowerCase()} wing, fully loaded.</p>
</section>
);
};
const Opening = () => <p className="gate">unlocking…</p>;
App.css
.address {
display: block;
width: 100%;
padding: var(--s2) var(--s3);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--muted);
}
.nav {
display: flex;
gap: var(--s3);
width: 100%;
padding-bottom: var(--s3);
border-bottom: 1px solid var(--accent-soft);
}
.nav a {
cursor: pointer;
}
.view {
display: flex;
flex-direction: column;
width: 100%;
min-height: 7rem;
}
.room {
margin: 0;
padding: var(--s3) var(--s4);
width: 100%;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
}
.room h2 {
margin: 0 0 var(--s2);
}
.room p {
margin: 0;
}
.room.rose {
background: oklch(0.62 0.18 20 / 0.1);
border-color: oklch(0.62 0.18 20 / 0.4);
}
.room.gold {
background: oklch(0.7 0.14 85 / 0.1);
border-color: oklch(0.7 0.14 85 / 0.4);
}
.room.teal {
background: oklch(0.65 0.12 190 / 0.1);
border-color: oklch(0.65 0.12 190 / 0.4);
}
.gate {
margin: 0;
padding: var(--s3) var(--s4);
width: 100%;
background: var(--surface);
border: 1px dashed var(--border);
border-radius: var(--r);
color: var(--muted);
}
.mode {
display: flex;
align-items: center;
gap: var(--s2);
width: 100%;
}
.bar {
width: 100%;
height: 3px;
border-radius: 999px;
background: var(--border);
overflow: hidden;
position: relative;
}
.bar[data-busy]::after {
content: '';
position: absolute;
inset: 0;
width: 40%;
border-radius: inherit;
background: var(--accent);
animation: slide 0.9s ease-in-out infinite;
}
@keyframes slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}