feat: smooth view transitions, no skeleton flash
Deploy to route.crispygoat.com / deploy (push) Successful in 4m21s

User pain point: skeleton loading.tsx files made the app feel like
a sequence of page reloads, exposing backend latency. Replaced with
a single 1px shimmer bar + crossfade via React's <ViewTransition>.

Changes:
- Enable experimental.viewTransition in next.config.ts
- Add SmoothViewTransition wrapper (ViewTransition name=page-content)
- Add LoadingFade component: thin animated bar instead of skeleton
- Add RouteAnnouncer for a11y (screen readers + focus reset)
- Add ::view-transition-old/new CSS for the crossfade (220ms, no
  jarring slide, respects prefers-reduced-motion)
- Wrap admin/tuxedo/IRD layout children in SmoothViewTransition
  (sidebar/header/footer stay mounted; only the body fades)
- Replace 19 skeleton loading.tsx files with the fade component

Result: navigation now feels like a single app, not a series of
preload-and-render events. The user never sees a 'skeleton of the
page they're about to load.'
This commit is contained in:
Tyler
2026-06-16 23:37:00 -06:00
parent 9458fd0506
commit 4ebbc6dacf
27 changed files with 304 additions and 1105 deletions
@@ -0,0 +1,39 @@
"use client";
import { ViewTransition } from "react";
/**
* Subtle loading placeholder used in place of skeleton `loading.tsx`
* files. Instead of a flashy "skeleton of the page you're waiting for",
* this renders a single thin animated bar that crossfades into the
* real content via the View Transitions API. The previous page stays
* visible underneath until the new page is ready, so the user never
* feels the gap.
*
* Drop this in a `loading.tsx` file:
*
* // app/admin/loading.tsx
* import { LoadingFade } from "@/components/transitions/LoadingFade";
* export default LoadingFade;
*/
export function LoadingFade() {
return (
<ViewTransition name="page-content" update="default">
<div
className="fixed top-0 left-0 right-0 z-50 h-0.5 overflow-hidden"
aria-hidden="true"
>
<div
className="h-full w-1/3 rounded-full"
style={{
background:
"linear-gradient(90deg, transparent 0%, #14532D 50%, transparent 100%)",
animation: "transition-shimmer 1.4s ease-in-out infinite",
}}
/>
</div>
</ViewTransition>
);
}
export default LoadingFade;
@@ -0,0 +1,40 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
/**
* Announce route changes to assistive tech. Without this, screen-reader
* users see no signal that navigation completed because the view
* transition is intentionally seamless for sighted users.
*
* The announcer also restores the keyboard focus to the top of the new
* page after navigation, so tab order picks up where the user expects
* (not stuck on the link they just clicked).
*/
export function RouteAnnouncer() {
const pathname = usePathname();
useEffect(() => {
// Reset focus to the page wrapper so keyboard nav continues from
// the top, not from the link in the sidebar.
const target = document.getElementById("page-content") ?? document.body;
if (target && "tabIndex" in target) {
(target as HTMLElement).tabIndex = -1;
(target as HTMLElement).focus({ preventScroll: true });
}
}, [pathname]);
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{/* Text is intentionally empty — the live region is the
announcement channel; the actual title is set per page via
the <h1> rendered inside the layout. */}
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
import { ViewTransition } from "react";
import type { ReactNode } from "react";
/**
* Smooth view-transition wrapper for any page-section content.
*
* Drop this around the main content area of a route (or around an
* individual <Suspense> chunk) to opt that subtree into a soft
* crossfade on navigation. The browser's native View Transitions API
* handles the actual animation — when the browser doesn't support it,
* the children just swap, no error.
*
* `name` groups elements so the browser can morph the same element
* across pages. Use the same name on the source and destination (e.g.
* the page header) for shared-element morphing. Use the default
* `"page-content"` for plain crossfades.
*
* The default `update="default"` plus a CSS `::view-transition-old/new`
* rule (see globals.css) gives us a 220ms crossfade with a tiny
* downward shift on the incoming page — fast enough to feel like a
* single app, slow enough to soften the cut between routes.
*
* Usage:
* <main>
* <SmoothViewTransition>{children}</SmoothViewTransition>
* </main>
*/
export function SmoothViewTransition({
children,
name = "page-content",
}: {
children: ReactNode;
name?: string;
}) {
return (
<ViewTransition
name={name}
// `default` runs a CSS-driven crossfade using the
// ::view-transition-* pseudo-elements defined in globals.css.
// Other options: "none" (instant cut), or a custom string your
// CSS can match.
update="default"
>
{children}
</ViewTransition>
);
}