feat: smooth view transitions, no skeleton flash
Deploy to route.crispygoat.com / deploy (push) Successful in 4m21s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user