Audience: Contributors
This guide explains how we handle page transitions in the RemitWise Next.js App Router frontend to ensure they feel native, tasteful, and accessible.
- Keep it fast: Route transitions should never delay the time-to-interactive.
- Respect user preferences: Always disable motion if the user prefers reduced motion (use
motion-reducevariants). - Use CSS over JS where possible: Next.js App Router supports CSS-based transition states via
template.tsxwithout needing heavy animation libraries.
When navigating between sibling routes (like /dashboard/transactions and /dashboard/goals), we use a subtle fade-in and slide-up animation.
Instead of wrapping every page in a custom motion wrapper, we use the Next.js template.tsx file for the route group.
Create a template.tsx file in the route segment:
// app/dashboard/template.tsx
import { ReactNode } from "react";
export default function DashboardTemplate({ children }: { children: ReactNode }) {
return (
<div className="animate-slide-in-bottom motion-reduce:animate-none motion-reduce:transition-none">
{children}
</div>
);
}template.tsxis unmounted and remounted on navigation between routes sharing the template.- We rely entirely on Tailwind's custom keyframes (
animate-slide-in-bottom) defined intailwind.config.js. - We use the
motion-reduce:variants (motion-reduce:animate-none) so the transition instantly snaps for users with accessibility preferences.
When applying filters or pushing shallow route changes, we use the native React useTransition hook to coordinate the UI state.
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
export function FilterButton({ filterId }: { filterId: string }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleFilter = () => {
startTransition(() => {
router.push(`?filter=${filterId}`, { scroll: false });
});
};
return (
<button
onClick={handleFilter}
disabled={isPending}
className={`
px-4 py-2 rounded-xl transition-all duration-200
${isPending ? "opacity-50" : "opacity-100"}
bg-white/5 hover:bg-white/10
`}
>
Apply Filter {isPending && "..."}
</button>
);
}This prevents the whole page layout from blocking and allows us to show localized feedback (like opacity-50) during the route state change.
Client-side route changes in the App Router do not automatically preserve scroll when the user presses Back or Forward. The global ScrollRestoration component (see components/ScrollRestoration.tsx) solves this by:
- Saving
{ x, y }scroll coordinates tosessionStorageas the user scrolls (debounced). - Resetting scroll to the top on push-style navigations (
<Link>,router.push()). - Restoring the saved position when a
popstateevent indicates a history navigation.
For filter-only or hash-only updates that should not move the viewport, combine router.push(..., { scroll: false }) with the one-shot escape hatch documented in HOOKS.md:
window.__rw_skip_scroll_restore = true;
router.push(`?filter=${filterId}`, { scroll: false });