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
+5 -77
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { LoadingFade } from "@/components/transitions/LoadingFade";
export const metadata: Metadata = {
title: "Loading — Route Commerce",
@@ -10,81 +11,8 @@ export const metadata: Metadata = {
};
export default function Loading() {
return (
<main
className="atelier-canvas relative min-h-screen flex items-center justify-center overflow-hidden"
role="status"
aria-live="polite"
aria-label="Loading content"
>
<div className="atelier-grain" aria-hidden="true" />
{/* Subtle ambient glow */}
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div
className="absolute -top-32 -right-32 w-[28rem] h-[28rem] rounded-full opacity-25"
style={{ background: "radial-gradient(circle at 30% 30%, rgba(34, 78, 47, 0.18) 0%, transparent 70%)", filter: "blur(48px)" }}
/>
<div
className="absolute -bottom-48 -left-48 w-[36rem] h-[36rem] rounded-full opacity-20"
style={{ background: "radial-gradient(circle at 70% 70%, rgba(202, 138, 4, 0.16) 0%, transparent 70%)", filter: "blur(60px)" }}
/>
</div>
<div className="relative z-10 flex flex-col items-center gap-7 atelier-enter">
<div className="atelier-section-num">Loading</div>
{/* Animated editorial mark */}
<div className="relative">
<div
className="w-16 h-16 rounded-2xl flex items-center justify-center"
style={{
background: "linear-gradient(135deg, #14532D 0%, #1F6B3E 50%, #14532D 100%)",
boxShadow: "0 10px 28px -6px rgba(20, 83, 45, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12)",
animation: "atelier-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
}}
aria-hidden="true"
>
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 2L4.5 13.5H11.5L10.5 22L19 10.5H12L13 2Z" />
</svg>
</div>
</div>
<div className="text-center">
<p
className="atelier-title text-2xl"
>
Preparing your <span className="atelier-italic text-[#786B53]">harvest</span>
</p>
<p className="text-sm text-stone-500 mt-1.5">Just a moment</p>
</div>
{/* Progress dots */}
<div className="flex items-center gap-1.5" aria-hidden="true">
{[0, 1, 2].map((i) => (
<div
key={i}
className="w-1.5 h-1.5 rounded-full bg-[#14532D]"
style={{
animation: "atelier-dot 1.4s ease-in-out infinite",
animationDelay: `${i * 0.16}s`,
}}
/>
))}
</div>
</div>
<style>{`
@keyframes atelier-pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.05); opacity: 0.85; }
}
@keyframes atelier-dot {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
`}</style>
</main>
);
// No skeleton. The previous page stays visible while the next one
// streams in, and the View Transition API crossfades them. This
// thin top bar is the only signal that navigation is in flight.
return <LoadingFade />;
}