Components

Navigation Progress

A lifecycle-aware progress bar for SvelteKit navigation with top and bottom placement.

Preview

The documentation layout is tracking navigation

Run a same-page navigation to see the site-level bar use the real SvelteKit navigation lifecycle without leaving these docs.

More examples

Bottom placement

Place the progress indicator along the bottom edge of the viewport or a contained application shell.

Bottom placement

Contained mode makes placement easy to preview inside panels and application shells.

Controlled and customized

Control progress directly and customize its thickness and CSS color for non-navigation work.

64%

Installation

Install directly from the hosted registry with the shadcn-svelte CLI.

Terminal
npx shadcn-svelte@latest add https://ui.akcodeworks.com/r/navigation-progress.json

Usage

  • Mount NavigationProgress once in src/routes/+layout.svelte so it remains active across page changes.
  • Navigation and top-level streamed promises returned from load are tracked automatically.
  • Use waitFor for remote queries or other async work. For SSE, resolve it after the first meaningful event—the maxWaitTime safety limit prevents a stalled stream from holding the bar forever.

API reference

PropTypeDefaultDescription
positiontop | bottomtopEdge where the progress indicator is displayed.
maxProgressnumber (1–99)90Highest progress value reached while navigation is still pending.
initialProgressnumber8Progress value shown when navigation begins.
trickleIntervalnumber (milliseconds)250Delay between automatic progress increments in milliseconds.
transitionDurationnumber (milliseconds)200Duration of progress and opacity transitions in milliseconds.
completionDelaynumber (milliseconds)180Delay before hiding the completed bar in milliseconds.
minDisplayTimenumber (milliseconds)0Minimum time the bar remains visible, including fast navigations.
waitForPromiseLike | () => PromiseLike—Additional promise to await before completing, such as a remote query or the first SSE event.
maxWaitTimenumber (milliseconds)30000Safety timeout for streamed or explicit async work. Set to 0 to disable it.
heightnumber (pixels)3Thickness of the progress bar in pixels.
colorCSS colorshadcn primaryOverrides the primary CSS color.
zIndexnumber9999CSS stacking level of the progress bar.
trackNavigationbooleantrueStarts and completes from SvelteKit's client-side navigation lifecycle.
trackPageDatabooleantrueWaits for top-level promises streamed through the destination's page.data.
containedbooleanfalsePositions the bar inside its nearest positioned ancestor instead of the viewport.
valuenumber (0–100)—The current controlled value.
ariaLabelstringNavigation progressAccessible label announced for the progress indicator.
onStart() => void—Called when tracked navigation begins.
onComplete() => void—Called when the progress bar reaches completion.
classstring—Tailwind CSS classes applied to the component root.
indicatorClassstring—Tailwind CSS classes applied to the indicator.

Source

These files are included in the registry item and copied into the consuming project.

navigation-progress.svelte
<script module lang="ts">	import type { HTMLAttributes } from 'svelte/elements';	export type NavigationProgressPosition = 'top' | 'bottom';	export type NavigationProgressWaitFor =		PromiseLike<unknown> | (() => PromiseLike<unknown> | undefined | void);	export type NavigationProgressProps = Omit<HTMLAttributes<HTMLDivElement>, 'color'> & {		position?: NavigationProgressPosition;		maxProgress?: number;		initialProgress?: number;		trickleInterval?: number;		transitionDuration?: number;		completionDelay?: number;		minDisplayTime?: number;		waitFor?: NavigationProgressWaitFor;		maxWaitTime?: number;		height?: number;		color?: string;		zIndex?: number;		trackNavigation?: boolean;		trackPageData?: boolean;		contained?: boolean;		value?: number;		ariaLabel?: string;		onStart?: () => void;		onComplete?: () => void;		indicatorClass?: string;		class?: string;	};</script><script lang="ts">	import { onNavigate } from '$app/navigation';	import { page } from '$app/state';	import { onDestroy } from 'svelte';	import { cn } from '$lib/utils.js';	let {		position = 'top',		maxProgress = 90,		initialProgress = 8,		trickleInterval = 250,		transitionDuration = 200,		completionDelay = 180,		minDisplayTime = 0,		waitFor,		maxWaitTime = 30_000,		height = 3,		color,		zIndex = 9999,		trackNavigation = true,		trackPageData = true,		contained = false,		value,		ariaLabel = 'Navigation progress',		onStart,		onComplete,		indicatorClass,		class: className,		...restProps	}: NavigationProgressProps = $props();	let progress = $state(0);	let visible = $state(false);	let trickleTimer: ReturnType<typeof setInterval> | undefined;	let completionTimer: ReturnType<typeof setTimeout> | undefined;	let resetTimer: ReturnType<typeof setTimeout> | undefined;	let minimumTimer: ReturnType<typeof setTimeout> | undefined;	let startedAt = 0;	let navigationId = 0;	const ceiling = $derived(clamp(maxProgress, Math.max(1, initialProgress), 99));	const displayProgress = $derived(value === undefined ? progress : clamp(value, 0, 100));	const displayVisible = $derived(value === undefined ? visible : value > 0);	function clamp(input: number, minimum: number, maximum: number) {		return Math.min(maximum, Math.max(minimum, input));	}	function clearTrickleTimer() {		if (trickleTimer === undefined) return;		clearInterval(trickleTimer);		trickleTimer = undefined;	}	function clearCompletionTimer() {		if (completionTimer === undefined) return;		clearTimeout(completionTimer);		completionTimer = undefined;	}	function clearResetTimer() {		if (resetTimer === undefined) return;		clearTimeout(resetTimer);		resetTimer = undefined;	}	function clearMinimumTimer() {		if (minimumTimer === undefined) return;		clearTimeout(minimumTimer);		minimumTimer = undefined;	}	function advance() {		const remaining = ceiling - progress;		if (remaining <= 0.25) {			progress = ceiling;			clearTrickleTimer();			return;		}		progress = Math.min(ceiling, progress + Math.max(0.25, remaining * 0.08));	}	function start() {		clearCompletionTimer();		clearResetTimer();		clearMinimumTimer();		clearTrickleTimer();		startedAt = performance.now();		progress = clamp(initialProgress, 1, ceiling);		visible = true;		onStart?.();		trickleTimer = setInterval(advance, Math.max(50, trickleInterval));	}	function finish() {		clearTrickleTimer();		progress = 100;		onComplete?.();		completionTimer = setTimeout(			() => {				visible = false;				completionTimer = undefined;				resetTimer = setTimeout(					() => {						progress = 0;						resetTimer = undefined;					},					Math.max(0, transitionDuration)				);			},			Math.max(0, completionDelay)		);	}	function complete() {		const remaining = Math.max(0, minDisplayTime - (performance.now() - startedAt));		if (remaining === 0) {			finish();			return;		}		minimumTimer = setTimeout(() => {			minimumTimer = undefined;			finish();		}, remaining);	}	async function waitForPageWork() {		const pending: PromiseLike<unknown>[] = [];		if (trackPageData) {			for (const value of Object.values(page.data)) {				if (isPromiseLike(value)) pending.push(value);			}		}		try {			const additionalWork = typeof waitFor === 'function' ? waitFor() : waitFor;			if (isPromiseLike(additionalWork) && !pending.includes(additionalWork)) {				pending.push(additionalWork);			}		} catch {			// A readiness callback should not prevent navigation from completing.		}		if (pending.length === 0) return;		let timeout: ReturnType<typeof setTimeout> | undefined;		const safetyLimit = Math.max(0, maxWaitTime);		const pageWork = Promise.allSettled(pending.map((work) => Promise.resolve(work)));		if (safetyLimit === 0) {			await pageWork;			return;		}		await Promise.race([			pageWork,			new Promise<void>((resolve) => {				timeout = setTimeout(resolve, safetyLimit);			})		]);		if (timeout !== undefined) clearTimeout(timeout);	}	function isPromiseLike(value: unknown): value is PromiseLike<unknown> {		if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return false;		return typeof (value as { then?: unknown }).then === 'function';	}	async function completeAfterPageWork(id: number) {		await waitForPageWork();		if (id === navigationId) complete();	}	onNavigate(() => {		if (!trackNavigation || value !== undefined) return;		const id = ++navigationId;		start();		return () => {			void completeAfterPageWork(id);		};	});	onDestroy(() => {		navigationId += 1;		clearTrickleTimer();		clearCompletionTimer();		clearResetTimer();		clearMinimumTimer();	});</script><div	{...restProps}	data-slot="navigation-progress"	data-position={position}	data-state={displayVisible ? 'loading' : 'idle'}	role="progressbar"	aria-label={ariaLabel}	aria-hidden={!displayVisible}	aria-valuemin="0"	aria-valuemax="100"	aria-valuenow={Math.round(displayProgress)}	class={cn(		'pointer-events-none inset-x-0 overflow-hidden transition-opacity motion-reduce:transition-none',		contained ? 'absolute' : 'fixed',		position === 'top' ? 'top-0' : 'bottom-0',		displayVisible ? 'opacity-100' : 'opacity-0',		className	)}	style:height={`${Math.max(1, height)}px`}	style:z-index={zIndex}	style:transition-duration={`${Math.max(0, transitionDuration)}ms`}>	<div		data-slot="navigation-progress-indicator"		class={cn(			'h-full w-full origin-left bg-primary shadow-sm transition-transform ease-out motion-reduce:transition-none',			indicatorClass		)}		style:background-color={color}		style:transform={`scaleX(${displayProgress / 100})`}		style:transition-duration={`${Math.max(0, transitionDuration)}ms`}	></div></div>
index.ts
export { default as NavigationProgress } from './navigation-progress.svelte';export type {	NavigationProgressPosition,	NavigationProgressProps,	NavigationProgressWaitFor} from './navigation-progress.svelte';