From 4ebbc6dacf5f957e2c8952f3601583c73195ade0 Mon Sep 17 00:00:00 2001 From: Tyler Date: Tue, 16 Jun 2026 23:37:00 -0600 Subject: [PATCH] feat: smooth view transitions, no skeleton flash 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 . 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.' --- next.config.ts | 5 + src/app/admin/communications/loading.tsx | 7 +- src/app/admin/layout.tsx | 15 ++- src/app/admin/loading.tsx | 124 ++---------------- src/app/cart/loading.tsx | 78 +---------- src/app/checkout/loading.tsx | 67 +--------- src/app/contact/loading.tsx | 80 +---------- src/app/globals.css | 53 ++++++++ src/app/indian-river-direct/about/loading.tsx | 42 +----- .../indian-river-direct/contact/loading.tsx | 64 +-------- src/app/indian-river-direct/faq/loading.tsx | 43 +----- src/app/indian-river-direct/layout.tsx | 9 +- src/app/indian-river-direct/loading.tsx | 83 +----------- .../stops/[slug]/loading.tsx | 72 +--------- src/app/loading.tsx | 82 +----------- src/app/login/loading.tsx | 61 +-------- src/app/pricing/loading.tsx | 63 +-------- src/app/tuxedo/about/loading.tsx | 42 +----- src/app/tuxedo/contact/loading.tsx | 64 +-------- src/app/tuxedo/faq/loading.tsx | 43 +----- src/app/tuxedo/layout.tsx | 8 +- src/app/tuxedo/loading.tsx | 83 +----------- src/app/tuxedo/stops/[slug]/loading.tsx | 72 +--------- src/app/water/loading.tsx | 21 +-- src/components/transitions/LoadingFade.tsx | 39 ++++++ src/components/transitions/RouteAnnouncer.tsx | 40 ++++++ .../transitions/SmoothViewTransition.tsx | 49 +++++++ 27 files changed, 304 insertions(+), 1105 deletions(-) create mode 100644 src/components/transitions/LoadingFade.tsx create mode 100644 src/components/transitions/RouteAnnouncer.tsx create mode 100644 src/components/transitions/SmoothViewTransition.tsx diff --git a/next.config.ts b/next.config.ts index bfec4f5..83800d6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -112,6 +112,11 @@ const nextConfig: NextConfig = { experimental: { // Enable optimizePackageImports for better bundle size optimizePackageImports: ["lucide-react", "@radix-ui/react-icons", "framer-motion"], + // Enable React's and Next.js' automatic route + // transitions. Combined with the smooth-transition wrappers around + // page content (see src/components/transitions), navigation feels + // like a single continuous app rather than a sequence of page loads. + viewTransition: true, }, // Compiler options diff --git a/src/app/admin/communications/loading.tsx b/src/app/admin/communications/loading.tsx index 5d44232..11609c9 100644 --- a/src/app/admin/communications/loading.tsx +++ b/src/app/admin/communications/loading.tsx @@ -1,5 +1,6 @@ -import CommunicationsLoading from "@/components/admin/CommunicationsLoading"; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ; -} \ No newline at end of file + return ; +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 5bb585a..9b17171 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -8,6 +8,8 @@ import { getSession } from "@/lib/auth"; import "@/styles/admin-design-system.css"; import { ToastProvider } from "@/components/admin/Toast"; import { ToastContainer } from "@/components/admin/ToastContainer"; +import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition"; +import { RouteAnnouncer } from "@/components/transitions/RouteAnnouncer"; // Admin layout calls getAdminUser() which reads cookies(). Without this, // Next.js tries to prerender the entire /admin/* tree statically and the @@ -105,8 +107,17 @@ export default async function AdminLayout({ children }: { children: React.ReactN activeBrandId={activeBrandId} brands={brands} /> -
- {children} + {/* The main content area swaps on every navigation. Wrapping + it in opts the swap into a soft + crossfade via the View Transitions API — sidebar and + background stay mounted, only the body fades. */} +
+ {children} +
); diff --git a/src/app/admin/loading.tsx b/src/app/admin/loading.tsx index f2886ac..4f333ff 100644 --- a/src/app/admin/loading.tsx +++ b/src/app/admin/loading.tsx @@ -1,112 +1,14 @@ +import { LoadingFade } from "@/components/transitions/LoadingFade"; + +/** + * Admin shell loading state. + * + * Replaces the previous skeleton (header + 4 stat cards + 2 panels + + * 5-row table) with a 1px-thick animated bar at the top of the page. + * The AdminSidebar and parchment background stay mounted, so the + * transition is a subtle fade — the user never sees an "empty" version + * of the admin while the next page streams in. + */ export default function AdminLoading() { - return ( -
- {/* Header skeleton */} -
-
-
-
-
-
-
-
-
-
- - {/* Stats cards skeleton */} -
- {[1, 2, 3, 4].map((i) => ( -
-
-
-
-
-
-
-
-
- ))} -
- - {/* Quick Actions + Usage skeleton */} -
-
-
-
-
-
- {[1, 2, 3, 4].map((i) => ( -
- ))} -
-
-
-
-
-
-
-
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
- ))} -
-
-
- - {/* Recent orders skeleton */} -
-
-
-
-
-
- {[1, 2, 3, 4, 5].map((i) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
-
- - {/* Filter tabs skeleton */} -
- {[1, 2, 3, 4].map((i) => ( -
- ))} -
- - {/* Section cards skeleton */} -
- {[1, 2, 3, 4, 5, 6].map((i) => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
-
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/cart/loading.tsx b/src/app/cart/loading.tsx index 261b6bf..11609c9 100644 --- a/src/app/cart/loading.tsx +++ b/src/app/cart/loading.tsx @@ -1,76 +1,6 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Your Cart — Route Commerce", - description: "Review and manage your shopping cart. Select pickup stops, adjust quantities, and proceed to checkout.", - keywords: ["cart", "shopping cart", "produce order", "checkout", "pickup"], - robots: { - index: false, - follow: false, - }, -}; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ( -
- {/* Dark background matching cart page */} -
-
- - - {/* Minimal header skeleton */} -
- - {/* Content skeleton */} -
-
-
- {/* Title skeleton */} -
-
- - {/* Cart items skeleton */} -
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ))} -
-
- - {/* Sidebar skeleton */} - -
-
- - {/* Status for accessibility */} - Loading your cart... -
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/checkout/loading.tsx b/src/app/checkout/loading.tsx index c3f25c4..11609c9 100644 --- a/src/app/checkout/loading.tsx +++ b/src/app/checkout/loading.tsx @@ -1,65 +1,6 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Processing...", - description: "Loading checkout...", - robots: { - index: false, - follow: false, - }, -}; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ( -
-
- -
-
-
- {/* Title skeleton */} -
-
- - {/* Form skeleton */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
- - {/* Button skeleton */} -
-
-
- - {/* Sidebar skeleton */} - -
-
- - Loading checkout... -
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/contact/loading.tsx b/src/app/contact/loading.tsx index d40adae..11609c9 100644 --- a/src/app/contact/loading.tsx +++ b/src/app/contact/loading.tsx @@ -1,78 +1,6 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Loading contact...", - description: "Loading...", - robots: { - index: false, - follow: false, - }, -}; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ( -
- {/* Header skeleton */} -
-
-
-
-
-
-
-
-
- -
- {/* Page header skeleton */} -
-
-
-
-
- - {/* Contact cards skeleton */} -
- {[1, 2, 3].map((i) => ( -
-
-
-
-
- ))} -
- - {/* Form skeleton */} -
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - Loading contact page... -
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/globals.css b/src/app/globals.css index 50bb2b9..d177697 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -177,6 +177,59 @@ select:-webkit-autofill:focus { color: #999999; } +/* ─── View Transitions (route fades) ────────────────────────────────────── */ +/* + * Native browser View Transitions API, opt-in via the + * component and Next.js's + * experimental.viewTransition config. These rules define the actual + * crossfade / slide. Without them, transitions still work but use + * the browser default (instant cut). + * + * 220ms is the sweet spot for route changes: long enough to soften + * the cut, short enough to feel like a single app. The cubic-bezier + * keeps the start crisp and the finish gentle. + */ +::view-transition-old(page-content) { + animation: route-fade-out 180ms cubic-bezier(0.4, 0, 0.2, 1) both; +} +::view-transition-new(page-content) { + animation: route-fade-in 240ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes route-fade-out { + to { + opacity: 0; + transform: translateY(-4px); + } +} +@keyframes route-fade-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Top-of-page shimmer for the LoadingFade placeholder bar. */ +@keyframes transition-shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } +} + +/* Reduce motion: respect the user's OS-level preference. */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-old(page-content), + ::view-transition-new(page-content) { + animation: none !important; + } + @keyframes transition-shimmer { + 0%, 100% { transform: translateX(0); } + } +} + /* Light mode placeholder (used by storefront) */ .light ::placeholder, .light::-webkit-input-placeholder, diff --git a/src/app/indian-river-direct/about/loading.tsx b/src/app/indian-river-direct/about/loading.tsx index 2a95b55..11609c9 100644 --- a/src/app/indian-river-direct/about/loading.tsx +++ b/src/app/indian-river-direct/about/loading.tsx @@ -1,40 +1,6 @@ -"use client"; - -import { motion } from "framer-motion"; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ( -
-
- {/* Hero skeleton */} -
-
-
-
-
- - {/* Content sections skeleton */} -
- {[0, 1, 2].map((i) => ( - -
-
-
-
-
-
-
-
- - ))} -
-
-
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/indian-river-direct/contact/loading.tsx b/src/app/indian-river-direct/contact/loading.tsx index f1126f9..11609c9 100644 --- a/src/app/indian-river-direct/contact/loading.tsx +++ b/src/app/indian-river-direct/contact/loading.tsx @@ -1,62 +1,6 @@ -"use client"; - -import { motion } from "framer-motion"; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ( -
-
- {/* Header skeleton */} -
-
-
-
-
- - {/* Contact cards skeleton */} -
- {[0, 1, 2].map((i) => ( - -
-
-
- - ))} -
- - {/* Form skeleton */} -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/indian-river-direct/faq/loading.tsx b/src/app/indian-river-direct/faq/loading.tsx index 4f927ee..11609c9 100644 --- a/src/app/indian-river-direct/faq/loading.tsx +++ b/src/app/indian-river-direct/faq/loading.tsx @@ -1,41 +1,6 @@ -"use client"; - -import { motion } from "framer-motion"; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ( -
-
- {/* Header skeleton */} -
-
-
-
-
- - {/* Search skeleton */} -
-
-
- - {/* FAQ items skeleton */} -
- {[0, 1, 2, 3, 4].map((i) => ( - -
-
-
-
- - ))} -
-
-
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/indian-river-direct/layout.tsx b/src/app/indian-river-direct/layout.tsx index f8eb9ff..88e6db8 100644 --- a/src/app/indian-river-direct/layout.tsx +++ b/src/app/indian-river-direct/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import { SmoothViewTransition } from "@/components/transitions/SmoothViewTransition"; const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://routecommerce.com"; @@ -94,7 +95,7 @@ export default function IndianRiverLayout({ children }: { children: React.ReactN
{/* Glass morphism gradient background */}
- + {/* Decorative glass orbs */}
@@ -102,8 +103,10 @@ export default function IndianRiverLayout({ children }: { children: React.ReactN
- {/* Content wrapper */} -
{children}
+ {/* Content wrapper — crossfades on navigation. */} +
+ {children} +
); } \ No newline at end of file diff --git a/src/app/indian-river-direct/loading.tsx b/src/app/indian-river-direct/loading.tsx index a62baf4..6c5bb25 100644 --- a/src/app/indian-river-direct/loading.tsx +++ b/src/app/indian-river-direct/loading.tsx @@ -1,78 +1,7 @@ -"use client"; +import { LoadingFade } from "@/components/transitions/LoadingFade"; -import { motion } from "framer-motion"; - -export default function Loading() { - return ( -
-
- {/* Header skeleton with blue accent */} -
-
-
-
-
- - {/* Product cards skeleton */} -
- {[0, 1, 2].map((i) => ( - - {/* Image skeleton */} -
-
-
- {/* Content skeleton */} -
-
-
-
-
-
-
-
-
- - ))} -
- - {/* Stops section skeleton */} -
-
-
-
-
-
-
- {[0, 1, 2, 3, 4, 5].map((i) => ( - -
-
-
-
-
-
-
-
-
-
-
- - ))} -
-
-
-
- ); -} \ No newline at end of file +/** Indian River Direct storefront loading — light blue glass backdrop + * stays mounted in the layout. */ +export default function IndianRiverLoading() { + return ; +} diff --git a/src/app/indian-river-direct/stops/[slug]/loading.tsx b/src/app/indian-river-direct/stops/[slug]/loading.tsx index b46b2fd..11609c9 100644 --- a/src/app/indian-river-direct/stops/[slug]/loading.tsx +++ b/src/app/indian-river-direct/stops/[slug]/loading.tsx @@ -1,70 +1,6 @@ -"use client"; - -import { motion } from "framer-motion"; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ( -
-
- {/* Back navigation skeleton */} -
-
-
- - {/* Stop header skeleton */} -
-
-
-
-
-
- - {/* Stop info skeleton */} -
-
- {[0, 1, 2].map((i) => ( - -
-
-
-
-
- - ))} -
-
- - {/* Products section skeleton */} -
-
-
-
-
- {[0, 1, 2].map((i) => ( - -
-
-
-
-
-
- - ))} -
-
-
-
- ); -} \ No newline at end of file + return ; +} diff --git a/src/app/loading.tsx b/src/app/loading.tsx index 34238e4..fc0e0e1 100644 --- a/src/app/loading.tsx +++ b/src/app/loading.tsx @@ -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 ( -
-
- ); + // 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 ; } diff --git a/src/app/login/loading.tsx b/src/app/login/loading.tsx index 73488d3..11609c9 100644 --- a/src/app/login/loading.tsx +++ b/src/app/login/loading.tsx @@ -1,59 +1,6 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "Loading...", - description: "Loading...", - robots: { - index: false, - follow: false, - }, -}; +import { LoadingFade } from "@/components/transitions/LoadingFade"; +/** Subtle navigation indicator — see LoadingFade for rationale. */ export default function Loading() { - return ( -
- {/* Background orbs */} -