shadimage

Stepper

Animated multi-step form with sliding step content and round step indicators.

Stepper.svelte

<script lang="ts">
	import type { Snippet } from 'svelte';
	import { tick } from 'svelte';

	type Props = {
		steps: Snippet[];
		initialStep?: number;
		onStepChange?: (step: number) => void;
		onFinalStepCompleted?: () => void;
		stepCircleContainerClass?: string;
		stepContainerClass?: string;
		contentClass?: string;
		footerClass?: string;
		backButtonText?: string;
		nextButtonText?: string;
		disableStepIndicators?: boolean;
		accentColor?: string;
	};

	let {
		steps,
		initialStep = 1,
		onStepChange,
		onFinalStepCompleted,
		stepCircleContainerClass = '',
		stepContainerClass = '',
		contentClass = '',
		footerClass = '',
		backButtonText = 'Back',
		nextButtonText = 'Continue',
		disableStepIndicators = false,
		accentColor = '#FF8A4C'
	}: Props = $props();

	let currentStep = $state(initialStep);
	let direction = $state(0);
	let parentHeight = $state(0);
	let measureRef: HTMLDivElement | null = $state(null);

	const totalSteps = $derived(steps.length);
	const isCompleted = $derived(currentStep > totalSteps);
	const isLastStep = $derived(currentStep === totalSteps);

	function updateStep(n: number) {
		currentStep = n;
		if (n > totalSteps) onFinalStepCompleted?.();
		else onStepChange?.(n);
	}
	function back() {
		if (currentStep > 1) { direction = -1; updateStep(currentStep - 1); }
	}
	function next() {
		if (!isLastStep) { direction = 1; updateStep(currentStep + 1); }
	}
	function complete() {
		direction = 1;
		updateStep(totalSteps + 1);
	}
	function goTo(n: number) {
		if (disableStepIndicators) return;
		if (n === currentStep) return;
		direction = n > currentStep ? 1 : -1;
		updateStep(n);
	}

	$effect(() => {
		void currentStep;
		(async () => {
			await tick();
			if (measureRef) parentHeight = measureRef.offsetHeight;
		})();
	});
</script>

<div class="flex min-h-full flex-1 flex-col items-center justify-center p-4 sm:aspect-[4/3] md:aspect-[2/1]">
	<div class={`mx-auto w-full max-w-md rounded-4xl shadow-xl ${stepCircleContainerClass}`} style="border:1px solid #222;">
		<div class={`${stepContainerClass} flex w-full items-center p-8`}>
			{#each steps as _, i}
				{@const stepNumber = i + 1}
				{@const status = currentStep === stepNumber ? 'active' : currentStep < stepNumber ? 'inactive' : 'complete'}
				<div
					class={`relative outline-none focus:outline-none ${disableStepIndicators ? 'pointer-events-none opacity-50' : 'cursor-pointer'}`}
					role="button"
					tabindex="0"
					onclick={() => goTo(stepNumber)}
					onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && goTo(stepNumber)}
				>
					<div
						class="flex h-8 w-8 items-center justify-center rounded-full font-semibold transition-[background-color,color] duration-300"
						style={status === 'inactive'
							? 'background:#222;color:#a3a3a3;'
							: `background:${accentColor};color:${accentColor};`}
					>
						{#if status === 'complete'}
							<svg class="h-4 w-4 text-black" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
								<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
							</svg>
						{:else if status === 'active'}
							<div class="h-3 w-3 rounded-full bg-[#120F17]"></div>
						{:else}
							<span class="text-sm">{stepNumber}</span>
						{/if}
					</div>
				</div>
				{#if i < steps.length - 1}
					<div class="relative mx-2 h-0.5 flex-1 overflow-hidden rounded bg-neutral-600">
						<div
							class="absolute left-0 top-0 h-full transition-[width,background-color] duration-[400ms]"
							style={currentStep > stepNumber
								? `width:100%;background-color:${accentColor};`
								: 'width:0;background-color:transparent;'}
						></div>
					</div>
				{/if}
			{/each}
		</div>

		<div
			class={`space-y-2 px-8 ${contentClass}`}
			style="position:relative;overflow:hidden;height:{isCompleted ? 0 : parentHeight}px;transition:height 0.4s cubic-bezier(0.5,1,0.5,1);"
		>
			{#if !isCompleted}
				{#key currentStep}
					<div
						bind:this={measureRef}
						style="position:absolute;left:0;right:0;top:0;animation:stepper-enter 0.4s cubic-bezier(0.4,0,0.2,1) forwards;--enter-x:{direction >= 0 ? '-100%' : '100%'};"
					>
						<div class="px-8">
							{@render steps[currentStep - 1]?.()}
						</div>
					</div>
				{/key}
			{/if}
		</div>

		{#if !isCompleted}
			<div class={`px-8 pb-8 ${footerClass}`}>
				<div class={`mt-10 flex ${currentStep !== 1 ? 'justify-between' : 'justify-end'}`}>
					{#if currentStep !== 1}
						<button
							onclick={back}
							class={`duration-350 rounded px-2 py-1 transition ${currentStep === 1 ? 'pointer-events-none opacity-50 text-neutral-400' : 'text-neutral-400 hover:text-neutral-700'}`}
						>{backButtonText}</button>
					{/if}
					<button
						onclick={isLastStep ? complete : next}
						class="next-button duration-350 flex items-center justify-center rounded-full py-1.5 px-3.5 font-medium tracking-tight transition"
					>{isLastStep ? 'Complete' : nextButtonText}</button>
				</div>
			</div>
		{/if}
	</div>
</div>

<style>
	@keyframes stepper-enter {
		from { transform: translateX(var(--enter-x)); opacity: 0; }
		to { transform: translateX(0); opacity: 1; }
	}
	.next-button {
		background-color: #ff8a4c;
		color: #120f17;
	}
	.next-button:hover { background-color: #ff7a36; }
	.next-button:active { background-color: #e86a2a; }
</style>
Where it goes
onboarding

DependenciesBundle weight · medium

animation
motion