Stepper
stepperuiMulti-step indicator — horizontal or vertical, with completed / current / upcoming states and optional descriptions per step. Use for onboarding wizards and checkout flows.
When to use which
Decision GuideTimeline showcases chronological events and milestones past-to-present. Stepper guides users through a linear, multi-step workflow requiring active user progression.
| Component | Role | Use when |
|---|---|---|
| Timeline Chronological feeds, delivery tracking logs, audit trails, and product roadmap milestones. | Activity & Milestone Stream | |
| Steppercurrent Multi-step wizards, checkout sequences, onboarding flows, and form completion tracking. | Workflow Progression |
- Activity logs, tracking events, & milestones → Timeline (view)
- Multi-step wizard or form progression → Stepper
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/stepper.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/stepper.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/stepper.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/stepper.jsonhttps://uipkge.dev/r/svelte/stepper.jsonInstalls to:src/lib/components/ui/stepper/Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
status | 'pending''active''completed''error' | pending | optional |
size | 'sm''default''lg' | default | optional |
steps | StepperStep[] | — | optional |
valueControlled active step (1-based). Bind with `bind:value`. | number | — | optional |
orientation | StepperOrientation | horizontal | optional |
stepsSnippetOverride the header strip (defaults to the steps list). | Snippet | — | optional |
children | Snippet<[{ activeStep: number; steps: StepperStep[] }]> | — | optional |
ref | HTMLDivElement | null | — | optional |
onValueChange | (value: number) => void | — | optional |
onStepClickFires with the 1-based step index on every step click that navigates (alongside `onValueChange`). | (stepIndex: number) => void | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
StepperContextValueinterface StepperContextValue {
orientation: () => StepperOrientation
size: () => StepperSize
activeStep: () => number
steps: () => StepperStep[]
goToStep: (stepIndex: number) => void
isClickable: (index: number) => boolean
getStatus: (index: number) => StepperStatus
}StepperStepinterface StepperStep {
id: string | number
title: string
description?: string
icon?: IconComponent
disabled?: boolean
error?: boolean
}npm dependencies
Used by
Files installed (12)
src/lib/components/ui/stepper/Stepper.svelte2.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { StepperOrientation, StepperSize } from './context' import type { StepperStep } from './types' // Omit children: ours takes render params, the base Snippet takes none. export interface StepperProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> { steps?: StepperStep[] /** Controlled active step (1-based). Bind with `bind:value`. */ value?: number orientation?: StepperOrientation size?: StepperSize /** Override the header strip (defaults to the steps list). */ stepsSnippet?: Snippet children?: Snippet<[{ activeStep: number; steps: StepperStep[] }]> ref?: HTMLDivElement | null onValueChange?: (value: number) => void /** Fires with the 1-based step index on every step click that navigates (alongside `onValueChange`). */ onStepClick?: (stepIndex: number) => void } </script> <script lang="ts"> import { cn } from '$lib/utils' import { setStepperContext, type StepperStatus } from './context' import StepperItem from './StepperItem.svelte' let { class: className, steps = [], value = $bindable(1), orientation = 'horizontal', size = 'default', stepsSnippet, children, ref = $bindable(null), onValueChange, onStepClick, ...restProps }: StepperProps = $props() function getStatus(index: number): StepperStatus { const step = steps[index] if (step?.error) return 'error' if (index + 1 === value) return 'active' if (index + 1 < value) return 'completed' return 'pending' } function isClickable(index: number): boolean { return index + 1 < value } export function goToStep(stepIndex: number) { if (stepIndex < 1 || stepIndex > steps.length) return const step = steps[stepIndex - 1] if (step?.disabled) return value = stepIndex onValueChange?.(stepIndex) onStepClick?.(stepIndex) } setStepperContext({ orientation: () => orientation, size: () => size, activeStep: () => value, steps: () => steps, goToStep, isClickable, getStatus, }) </script> <div bind:this={ref} class={cn('w-full', className)} role="tablist" aria-orientation={orientation} data-orientation={orientation} data-uipkge="" data-slot="stepper" {...restProps} > {#if stepsSnippet} {@render stepsSnippet()} {:else if steps.length > 0} <ol class={cn('flex', orientation === 'horizontal' ? 'flex-row items-start' : 'flex-col items-stretch')}> {#each steps as step, index (step.id)} <StepperItem {step} {index} /> {/each} </ol> {/if} {#if children} <div class="mt-6 flex-1"> {@render children({ activeStep: value, steps })} </div> {/if} </div>src/lib/components/ui/stepper/StepperItem.svelte5.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' import type { StepperStep } from './types' export interface StepperItemProps extends HTMLAttributes<HTMLLIElement> { step: StepperStep index: number ref?: HTMLLIElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getStepperContext, type StepperContextValue } from './context' import StepperIndicator from './StepperIndicator.svelte' let { class: className, step, index, ref = $bindable(null), ...restProps }: StepperItemProps = $props() const maybeCtx = getStepperContext() if (!maybeCtx) throw new Error('StepperItem must be used inside <Stepper>') const ctx: StepperContextValue = maybeCtx const status = $derived(ctx.getStatus(index)) const orientation = $derived(ctx.orientation()) const size = $derived(ctx.size()) const isFirst = $derived(index === 0) const isLast = $derived(index === ctx.steps().length - 1) const clickable = $derived(ctx.isClickable(index) && !step.disabled) // Indicator row must match stepperIndicatorVariants sizes (sm 7 / default 9 / lg 11). // Full class strings so Tailwind's scanner keeps them. const indicatorAxisClass = $derived.by(() => { if (orientation === 'horizontal') { return { sm: 'h-7 w-full items-center justify-center', default: 'h-9 w-full items-center justify-center', lg: 'h-11 w-full items-center justify-center' }[size] } return { sm: 'w-7 flex-col items-center justify-start self-stretch', default: 'w-9 flex-col items-center justify-start self-stretch', lg: 'w-11 flex-col items-center justify-start self-stretch' }[size] }) // A connector "segment" is the line drawn between this indicator and the // adjacent one. We split it into left/right halves so each item owns its // own piece — they butt up at item boundaries for pixel alignment. const leftSegmentCompleted = $derived(index < ctx.activeStep()) const rightSegmentCompleted = $derived(index < ctx.activeStep() - 1) function handleNavigate() { if (clickable) ctx.goToStep(index + 1) } </script> <li bind:this={ref} data-uipkge="" data-slot="stepper-item" class={cn( 'group/stepper-item relative min-w-0', orientation === 'horizontal' ? 'flex flex-1 flex-col items-center gap-2' : 'flex flex-row items-start gap-3 pb-6 last:pb-0', step.disabled && 'opacity-50', className, )} role="tab" aria-selected={status === 'active'} aria-disabled={step.disabled || undefined} data-status={status} {...restProps} > <div class={cn('relative flex shrink-0', indicatorAxisClass)}> {#if !isFirst} <span aria-hidden="true" data-slot="stepper-connector" data-orientation={orientation} data-edge={orientation === 'horizontal' ? 'left' : 'top'} class={cn( 'bg-border pointer-events-none absolute overflow-hidden', orientation === 'horizontal' ? 'top-1/2 right-1/2 left-0 h-px -translate-y-1/2' : 'top-0 bottom-1/2 left-1/2 w-px -translate-x-1/2', )} > <span data-slot="stepper-connector-fill" data-completed={leftSegmentCompleted ? 'true' : 'false'} data-orientation={orientation} class="bg-primary absolute inset-0"></span> </span> {/if} {#if !isLast} <span aria-hidden="true" data-slot="stepper-connector" data-orientation={orientation} data-edge={orientation === 'horizontal' ? 'right' : 'bottom'} class={cn( 'bg-border pointer-events-none absolute overflow-hidden', orientation === 'horizontal' ? 'top-1/2 right-0 left-1/2 h-px -translate-y-1/2' : 'top-1/2 bottom-0 left-1/2 w-px -translate-x-1/2', )} > <span data-slot="stepper-connector-fill" data-completed={rightSegmentCompleted ? 'true' : 'false'} data-orientation={orientation} class="bg-primary absolute inset-0"></span> </span> {/if} <StepperIndicator {status} {size} index={index + 1} icon={step.icon} {clickable} class="focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" onclick={handleNavigate} /> </div> <div data-slot="stepper-item-content" class={cn('min-w-0', orientation === 'horizontal' ? 'max-w-[12rem] text-center' : 'flex-1 pt-1.5')}> <button type="button" class={cn( 'text-foreground text-sm font-medium text-balance transition-colors duration-200 outline-none', clickable && 'hover:text-primary focus-visible:text-primary focus-visible:ring-ring cursor-pointer focus-visible:ring-2 focus-visible:outline-none', !clickable && 'cursor-default', status === 'pending' && 'text-muted-foreground', status === 'error' && 'text-destructive', )} disabled={!clickable} onclick={handleNavigate} > {step.title} </button> {#if step.description} <p class="text-muted-foreground mt-0.5 text-xs text-balance">{step.description}</p> {/if} </div> </li> <style> /* Connector progress: fill scales along the track with a soft ease. */ [data-slot='stepper-connector-fill'] { transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1); } [data-slot='stepper-connector-fill'][data-orientation='horizontal'] { transform-origin: left center; transform: scaleX(0); } [data-slot='stepper-connector-fill'][data-orientation='horizontal'][data-completed='true'] { transform: scaleX(1); } [data-slot='stepper-connector-fill'][data-orientation='vertical'] { transform-origin: center top; transform: scaleY(0); } [data-slot='stepper-connector-fill'][data-orientation='vertical'][data-completed='true'] { transform: scaleY(1); } @media (prefers-reduced-motion: reduce) { [data-slot='stepper-connector-fill'] { transition: none !important; } } </style>src/lib/components/ui/stepper/StepperIndicator.svelte4 kB
<script lang="ts" module> import type { HTMLButtonAttributes } from 'svelte/elements' import type { StepperSize, StepperStatus } from './context' import type { IconComponent } from './types' export interface StepperIndicatorProps extends HTMLButtonAttributes { status?: StepperStatus size?: StepperSize index?: number icon?: IconComponent clickable?: boolean ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { Check, X } from '@lucide/svelte' import { cn } from '$lib/utils' import { stepperIndicatorVariants } from './stepper.variants' let { class: className, status = 'pending', size = 'default', index, icon, clickable = false, children, ref = $bindable(null), onclick, ...restProps }: StepperIndicatorProps = $props() const FallbackIcon = $derived(icon ?? (status === 'completed' ? Check : status === 'error' ? X : null)) </script> <button bind:this={ref} type="button" data-uipkge="" data-slot="stepper-indicator" data-status={status} class={cn( stepperIndicatorVariants({ status, size }), 'ring-background relative z-10 ring-4 transition-[color,background-color,box-shadow,transform] duration-200 outline-none', clickable && 'focus-visible:ring-ring cursor-pointer focus-visible:ring-2 focus-visible:outline-none', !clickable && 'cursor-default', className, )} disabled={!clickable} aria-current={status === 'active' ? 'step' : undefined} {onclick} {...restProps} > {#if children} {@render children()} {:else if FallbackIcon} <FallbackIcon class="size-4" data-slot="stepper-indicator-icon" aria-hidden="true" /> {:else if index !== undefined} <span class="font-medium" data-slot="stepper-indicator-label">{index}</span> {/if} </button> <style> @keyframes stepper-indicator-pop { 0% { transform: scale(0.92); } 55% { transform: scale(1.06); } 100% { transform: scale(1); } } /* Same curve as pop; distinct name so status changes re-trigger. */ @keyframes stepper-indicator-fill { 0% { transform: scale(0.92); } 55% { transform: scale(1.06); } 100% { transform: scale(1); } } @keyframes stepper-indicator-error { 0% { transform: scale(0.92); } 55% { transform: scale(1.06); } 100% { transform: scale(1); } } @keyframes stepper-icon-in { from { opacity: 0; transform: scale(0.6); } to { opacity: 1; transform: scale(1); } } /* One-shot pop when a step becomes active / completed / error. Distinct animation names so active→completed restarts the pop. */ [data-slot='stepper-indicator'][data-status='active'] { animation: stepper-indicator-pop 280ms cubic-bezier(0.22, 1.4, 0.36, 1) both; } [data-slot='stepper-indicator'][data-status='completed'] { animation: stepper-indicator-fill 280ms cubic-bezier(0.22, 1.4, 0.36, 1) both; } [data-slot='stepper-indicator'][data-status='error'] { animation: stepper-indicator-error 280ms cubic-bezier(0.22, 1.4, 0.36, 1) both; } /* Check / error / custom icon settles in with the fill. The icon renders inside a child component, so its half of the selector is :global — the indicator half stays scoped. */ [data-slot='stepper-indicator'][data-status='completed'] :global([data-slot='stepper-indicator-icon']), [data-slot='stepper-indicator'][data-status='error'] :global([data-slot='stepper-indicator-icon']), [data-slot='stepper-indicator'][data-status='active'] :global([data-slot='stepper-indicator-icon']) { animation: stepper-icon-in 220ms cubic-bezier(0.22, 1.2, 0.36, 1) both; } @media (prefers-reduced-motion: reduce) { [data-slot='stepper-indicator'], [data-slot='stepper-indicator'] :global([data-slot='stepper-indicator-icon']), [data-slot='stepper-indicator'] [data-slot='stepper-indicator-label'] { animation: none !important; transition: none !important; } } </style>src/lib/components/ui/stepper/StepperHeader.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface StepperHeaderProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: StepperHeaderProps = $props() </script> <div bind:this={ref} class={cn('stepper-header flex items-center gap-0', className)} {...restProps}> {@render children?.()} </div>src/lib/components/ui/stepper/StepperContent.svelte0.8 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface StepperContentProps extends HTMLAttributes<HTMLDivElement> { step?: number activeStep?: number ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, step = 1, activeStep = 1, children, ref = $bindable(null), ...restProps }: StepperContentProps = $props() const isActive = $derived(step === activeStep) </script> <div bind:this={ref} hidden={!isActive || undefined} data-slot="stepper-content" class={cn( 'stepper-content motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-200 motion-safe:ease-out', className, )} role="tabpanel" aria-hidden={!isActive} {...restProps} > {@render children?.()} </div>src/lib/components/ui/stepper/StepperTitle.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface StepperTitleProps extends HTMLAttributes<HTMLSpanElement> { ref?: HTMLSpanElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: StepperTitleProps = $props() </script> <span bind:this={ref} class={cn('text-foreground text-sm font-medium', className)} {...restProps}> {@render children?.()} </span>src/lib/components/ui/stepper/StepperDescription.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface StepperDescriptionProps extends HTMLAttributes<HTMLSpanElement> { ref?: HTMLSpanElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: StepperDescriptionProps = $props() </script> <span bind:this={ref} class={cn('text-muted-foreground text-xs', className)} {...restProps}> {@render children?.()} </span>src/lib/components/ui/stepper/StepperStep.svelte2.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { StepperStatus } from './context' import type { IconComponent } from './types' export interface StepperStepProps extends HTMLAttributes<HTMLDivElement> { title: string description?: string icon?: IconComponent completed?: boolean active?: boolean error?: boolean disabled?: boolean status?: StepperStatus index?: number /** Render overrides for the indicator icon, title, and description. */ iconSnippet?: Snippet titleSnippet?: Snippet descriptionSnippet?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Check } from '@lucide/svelte' import { cn } from '$lib/utils' import { stepperIndicatorVariants } from './stepper.variants' let { class: className, title, description, icon, completed = false, active = false, error = false, disabled = false, status, index, iconSnippet, titleSnippet, descriptionSnippet, children, ref = $bindable(null), ...restProps }: StepperStepProps = $props() const computedStatus = $derived.by(() => { if (status) return status if (error) return 'error' if (active) return 'active' if (completed) return 'completed' return 'pending' }) const Icon = $derived(icon ?? (computedStatus === 'completed' ? Check : null)) </script> <div bind:this={ref} class={cn('stepper-step flex gap-3', className)} role="tab" aria-selected={active} aria-disabled={disabled} {...restProps}> <div data-slot="stepper-indicator" data-status={computedStatus} class={cn(stepperIndicatorVariants({ status: computedStatus, size: 'default' }))}> {#if iconSnippet} {@render iconSnippet()} {:else if Icon} <Icon class="size-4" data-slot="stepper-indicator-icon" aria-hidden="true" /> {:else if index} <span data-slot="stepper-indicator-label">{index}</span> {/if} </div> <div class="flex flex-col gap-0.5 pt-1"> {#if titleSnippet} {@render titleSnippet()} {:else} <span class="text-sm font-medium">{title}</span> {/if} {#if descriptionSnippet} {@render descriptionSnippet()} {:else if description} <span class="text-muted-foreground text-xs">{description}</span> {/if} {@render children?.()} </div> </div>src/lib/components/ui/stepper/context.ts1 kB
import { getContext, setContext } from 'svelte' import type { StepperStep } from './types' export type StepperOrientation = 'horizontal' | 'vertical' export type StepperStatus = 'active' | 'completed' | 'pending' | 'error' export type StepperSize = 'sm' | 'default' | 'lg' /** * Stepper state shared from <Stepper> to its items. Exposed as functions so * reads inside child templates/`$derived` track the root's runes state. */ export interface StepperContextValue { orientation: () => StepperOrientation size: () => StepperSize activeStep: () => number steps: () => StepperStep[] goToStep: (stepIndex: number) => void isClickable: (index: number) => boolean getStatus: (index: number) => StepperStatus } const KEY = Symbol.for('uipkge:stepper') export function setStepperContext(value: StepperContextValue): StepperContextValue { setContext(KEY, value) return value } export function getStepperContext(): StepperContextValue | null { return getContext<StepperContextValue | null>(KEY) ?? null }src/lib/components/ui/stepper/stepper.variants.ts1.1 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so `StepperIndicator.vue` / `StepperStep.vue` can import * them without creating a circular dependency back through the index. * Sibling pattern to `card/card.variants.ts`. */ export const stepperIndicatorVariants = cva('flex items-center justify-center rounded-full font-semibold shrink-0', { variants: { status: { pending: 'bg-muted text-muted-foreground', active: 'bg-primary text-primary-foreground shadow-sm', completed: 'bg-primary text-primary-foreground', error: 'bg-destructive text-destructive-foreground', }, size: { sm: 'size-7 text-xs [&>svg]:size-3.5', default: 'size-9 text-sm [&>svg]:size-4', lg: 'size-11 text-base [&>svg]:size-5', }, }, defaultVariants: { status: 'pending', size: 'default', }, }) export type StepperIndicatorVariants = VariantProps<typeof stepperIndicatorVariants>src/lib/components/ui/stepper/types.ts0.3 kB
import type { Component } from 'svelte' /** Loose icon-component type (mirrors Vue's `Component`): any component accepting a class. */ export type IconComponent = Component<Record<string, any>> export interface StepperStep { id: string | number title: string description?: string icon?: IconComponent disabled?: boolean error?: boolean }src/lib/components/ui/stepper/index.ts1 kB
export { default as Stepper, type StepperProps } from './Stepper.svelte' export { default as StepperHeader, type StepperHeaderProps } from './StepperHeader.svelte' export { default as StepperItem, type StepperItemProps } from './StepperItem.svelte' export { default as StepperIndicator, type StepperIndicatorProps } from './StepperIndicator.svelte' export { default as StepperContent, type StepperContentProps } from './StepperContent.svelte' export { default as StepperTitle, type StepperTitleProps } from './StepperTitle.svelte' export { default as StepperDescription, type StepperDescriptionProps } from './StepperDescription.svelte' export { default as StepperStep, type StepperStepProps } from './StepperStep.svelte' export type { StepperStep as StepperStepConfig, IconComponent } from './types' export type { StepperOrientation, StepperSize, StepperStatus } from './context' export { stepperIndicatorVariants, type StepperIndicatorVariants } from './stepper.variants'
Raw manifest:https://uipkge.dev/r/svelte/stepper.json