Skip to main content
fc0ca9db7b6cd46b0663
·4 min read

Building a Smooth Top Loader with Motion and TanStack Router

A breakdown of how I built a polished top loading indicator inspired by tanstack.com using React, Motion, and Tailwind CSS. This article covers the animation setup, glow effect, smooth transitions, accessibility details, and the small design decisions that make a loading state feel clean and modern.

react
tanstack router
motion
tailwindcss
typescript
4d5fd787ac74e0caa4f7

Sohan R. Emon

Developer, Learner, Tech Enthusiast

I noticed the top loader on tanstack.com — that subtle glowing indicator that slides in at the top during route transitions. It felt more polished than a full-screen spinner or a tiny inline loader. I wanted something similar for my own project.

The Design

A fixed-position loader at the top of the viewport. It should:

  • Animate in and out smoothly
  • Not block interaction with page content
  • Work seamlessly with TanStack Router's navigation state
  • Feel unobtrusive but clearly visible

The tanstack version combines a blurred glow background with a spinning icon. That's the direction I took.

The Component

tsx
import { cn } from "@sohanemon/utils";
import { Iconify } from "@sohanemon/utils/components";
import { motion } from "motion/react";
import type * as React from "react";

type TopLoaderProps = React.ComponentProps<typeof motion.div>;

export function TopLoader({ className, ...props }: TopLoaderProps) {
	return (
		<motion.div
			initial={{
				y: -80,
				opacity: 0,
				scale: 0.85,
			}}
			animate={{
				y: 18,
				opacity: 1,
				scale: 1,
			}}
			exit={{
				y: -80,
				opacity: 0,
				scale: 0.85,
			}}
			transition={{
				type: "spring",
				stiffness: 250,
				damping: 18,
			}}
			aria-hidden="true"
			className={cn(
				"pointer-events-none fixed top-0 left-0 z-99999999 h-96 w-full select-none",
				className,
			)}
			{...props}
		>
			<div className="absolute top-0 grid size-full -translate-y-1/2 place-content-center rounded-[50%] bg-border/50 blur-[70px]" />
			<Iconify
				className="mx-auto mt-2 size-14 animate-spin rounded-full bg-background/50 p-2.5"
				icon="lucide:loader-2"
			/>
		</motion.div>
	);
}

The container uses fixed positioning with a high z-index to stay above everything. The animation uses three states: initial for the starting position above the viewport, animate for the visible state, and exit for when navigation completes. The spring transition (stiffness 250, damping 18) gives it a natural bounce without feeling stiff.

The glow effect comes from a div with blur-[70px], a rounded shape, and reduced opacity sitting behind the spinner. I added pointer-events-none so clicks pass through to elements below, and aria-hidden="true" since it's purely decorative.

Integration with TanStack Router

TanStack Router exposes navigation state through useRouterState hook. You can use this to trigger the loader during route transitions.

tsx
// components/AppLoader.tsx
import { useNavigation } from "@tanstack/react-router";
import { AnimatePresence } from "framer-motion";
import { TopLoader } from "@/components/animated/top-loader";

export function AppLoader() {
	const isLoading = useRouterState({
		select: (s) => s.status === "pending",
	});

	return (
		<AnimatePresence>
			{isLoading && <TopLoader />}
		</AnimatePresence>
	);
}

Then place this component at the root of your app layout.

TanStack Router's useRouterState returns state which can be "idle" or "pending". The loader shows whenever the router is actively navigating.

Alternative: Using TanStack Query

If you're using TanStack Query for data fetching rather than relying solely on route loaders, you can hook into the query client's loading state:

tsx
// components/QueryLoader.tsx
import { useIsFetching, useIsMutating } from "@tanstack/react-query";
import { AnimatePresence } from "framer-motion";
import { TopLoader } from "@/components/animated/top-loader";

export function QueryLoader() {
	const isFetching = useIsFetching();
	const isMutating = useIsMutating();

	const isLoading = isFetching > 0 || isMutating > 0;

	return (
		<AnimatePresence>
			{isLoading && <TopLoader />}
		</AnimatePresence>
	);
}

This shows the loader whenever any query is fetching or any mutation is running. Useful when you have multiple async operations happening across the app.

Fine-Tuning

The blur and opacity values took some trial and error. I landed on blur-[70px] with bg-border/50 as a balance between visibility and subtlety. You might want to adjust the y offset (currently 18) depending on your header height or if you have a fixed navbar.

The component is intentionally generic — it doesn't know about your router or query client. That keeps it reusable. Just pass in the boolean controlling its visibility from whichever state management approach you use.

It's a small detail, but it ties the experience together during navigation.

Found this useful?