Route transitions
How this site covers the screen while a section loads: which routes hold the scrim, what opens it, and where the timing lives.
The routes
Only the section routes hold it
The scrim covers when you move between sections, not between the pages inside one. That falls out of where the loader is: only the four section routes have it, so navigating within a section never enters a pending state that anything is waiting on.
export const Route = createFileRoute("/react-scrim")({
component: Layout,
loader: scrimTransitionLoader,
staleTime: Infinity,
gcTime: 0,
});Why staleTime and gcTime are there
They are not tuning. A match with a loader is cached for its gcTime, and a stale reload runs in the background without blocking the commit — so returning to a section you visited a minute ago would reuse the cached match, resolve instantly and swap the page with the scrim still open. gcTime: 0 forces a fresh match on every entry.
staleTime: Infinity is the other half: without it the section match, stale immediately, would revalidate on every navigation to a page inside it, running the hold again for nothing.
What opens it
A section change, not any navigation
The condition compares the section being navigated to with the one currently committed. location is already the destination when a navigation starts, while resolvedLocation only moves at the commit — so the two differ exactly for the duration of a section change, and never for a navigation inside one.
const isEnteringSection = useRouterState({
select: (state) => {
const target = navigationSectionFor(state.location.pathname);
return state.isLoading && !!target && target !== navigationSectionFor(state.resolvedLocation?.pathname ?? "");
},
});The same optimistic location gives the scrim its variant and its title, which is why the destination is already named on the layer before the page underneath has changed.
<Scrim until={appReady} open={isEnteringSection} variant={section?.prefix}>
<SplashContent />
</Scrim>The variants
Shared timing, per-section direction
Everything the four sections have in common lives in the base rule: which properties animate, how long, and the hold before the scrim leaves. Note the delay is only on the closed state, so it postpones the exit without slowing the entrance.
:root {
--scrim-duration: 650ms;
}
[data-scrim] {
opacity: 1;
translate: 0;
transition-property: opacity, translate, visibility;
transition-duration: var(--scrim-duration);
transition-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
}
[data-scrim]:not([data-scrim-open]) {
transition-delay: var(--scrim-duration);
}What differs is one declaration. Margo UI drops from the top, the others rise from the bottom — and because the incoming variant is applied for a frame before the scrim opens, arriving from Margo UI still enters from the bottom rather than inheriting the direction it just left.
[data-scrim-variant="/margo-ui"]:not([data-scrim-open]) {
translate: 0 -100%;
}
[data-scrim-variant="/react-graft"]:not([data-scrim-open]) {
translate: 0 100%;
}opacity: 1 and translate: 0 sit in the base for a reason: a variant that animates one property has to leave the other at a known value, or the next variant starts from whatever the last one happened to park there.
The timing
Read from the stylesheet, not stored twice
The loader holds the route for as long as the scrim takes to cover, and gets that number from CSS. The declaration on :root is the single source: change it and both the animation and the hold follow.
const scrimDuration = () => {
const value = getComputedStyle(document.documentElement).getPropertyValue("--scrim-duration").trim();
return Number.parseFloat(value) * (value.endsWith("ms") ? 1 : 1000);
};
export const scrimTransitionLoader = () => {
if (typeof document === "undefined") return;
return new Promise((resolve) => window.setTimeout(resolve, scrimDuration()));
};It is read from :root rather than from the scrim itself on purpose. By the time the loader runs, the element is already in the frame where the incoming variant is applied without animating — its computed transition is none, so it would report a duration of zero and the page would swap in the open.
The unit check is not decoration either: minifiers rewrite 650ms as 0.65s, so a build would otherwise hold the route for less than a millisecond.
The title
Waiting on fonts, once
The section name inside the scrim is hidden until until resolves, because it is set in a font that is still loading on the first paint. data-scrim-ready latches, so it reveals once and stays revealed through every later transition instead of flickering on each one.
.scrim-title {
opacity: 0;
transition: opacity 300ms ease-out;
}
[data-scrim-ready] .scrim-title {
opacity: 1;
}