Stepper
stepper ui Multi-step indicator — horizontal or vertical, with completed / current / upcoming states and optional descriptions per step. Use for onboarding wizards and checkout flows.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/stepper.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/stepper.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/stepper.json $ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/stepper.json Named registry:
npx shadcn-vue@latest add @uipkge/stepper Installs to: app/components/ui/stepper/ Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
status | 'pending''active''completed''error' | pending | optional |
steps | StepperStep[] | () => [], modelValue: 1, orientation: 'horizontal', size:… | optional |
modelValue | number | — | optional |
orientation | StepperOrientation | — | optional |
size | StepperSize | — | optional |
class | HTMLAttributes['class'] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
StepperContext interface StepperContext {
orientation: Ref<StepperOrientation>
size: Ref<StepperSize>
activeStep: Ref<number>
steps: Ref<StepperStep[]>
goToStep: (stepIndex: number) => void
isClickable: (index: number) => boolean
getStatus: (index: number) => StepperStatus
} StepperStep interface StepperStep {
id: string | number
title: string
description?: string
icon?: Component
disabled?: boolean
error?: boolean
} npm dependencies
Used by
Files installed (12)
-
app/components/ui/stepper/Stepper.vue 2.1 kB
<script setup lang="ts"> import { computed, provide, toRef } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { STEPPER_CONTEXT, type StepperOrientation, type StepperSize, type StepperStatus } from './context' import type { StepperStep } from './types' interface Props { steps?: StepperStep[] modelValue?: number orientation?: StepperOrientation size?: StepperSize class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { steps: () => [], modelValue: 1, orientation: 'horizontal', size: 'default', }) const emit = defineEmits<{ 'update:modelValue': [value: number] }>() const activeStep = computed(() => props.modelValue) const stepsRef = computed(() => props.steps) function getStatus(index: number): StepperStatus { const step = props.steps[index] if (step?.error) return 'error' if (index + 1 === activeStep.value) return 'active' if (index + 1 < activeStep.value) return 'completed' return 'pending' } function isClickable(index: number): boolean { return index + 1 < activeStep.value } function goToStep(stepIndex: number) { if (stepIndex < 1 || stepIndex > props.steps.length) return const step = props.steps[stepIndex - 1] if (step?.disabled) return emit('update:modelValue', stepIndex) } provide(STEPPER_CONTEXT, { orientation: toRef(props, 'orientation'), size: toRef(props, 'size'), activeStep, steps: stepsRef, goToStep, isClickable, getStatus, }) defineExpose({ goToStep }) </script> <template> <div :class="cn('w-full', props.class)" role="tablist" :aria-orientation="orientation" :data-orientation="orientation" > <!-- Header strip with steps --> <slot name="steps"> <ol v-if="steps.length > 0" :class="cn('flex', orientation === 'horizontal' ? 'flex-row items-start' : 'flex-col items-stretch')" > <StepperItem v-for="(step, index) in steps" :key="step.id" :step="step" :index="index" /> </ol> </slot> <!-- Content area --> <div v-if="$slots.default" class="mt-6 flex-1"> <slot :active-step="activeStep" :steps="steps" /> </div> </div> </template> -
app/components/ui/stepper/StepperItem.vue 6.3 kB
<script setup lang="ts"> import { computed, inject } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' import { STEPPER_CONTEXT } from './context' import type { StepperStep } from './types' interface Props { step: StepperStep index: number class?: HTMLAttributes['class'] } const props = defineProps<Props>() const _maybeCtx = inject(STEPPER_CONTEXT) if (!_maybeCtx) throw new Error('StepperItem must be used inside <Stepper>') const ctx: NonNullable<typeof _maybeCtx> = _maybeCtx const status = computed(() => ctx.getStatus(props.index)) const orientation = computed(() => ctx.orientation.value) const size = computed(() => ctx.size.value) const isFirst = computed(() => props.index === 0) const isLast = computed(() => props.index === ctx.steps.value.length - 1) const clickable = computed(() => ctx.isClickable(props.index) && !props.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 = computed(() => { if (orientation.value === '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', } as const )[size.value] } 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', } as const )[size.value] }) // 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 = computed(() => props.index < ctx.activeStep.value) const rightSegmentCompleted = computed(() => props.index < ctx.activeStep.value - 1) function handleNavigate() { if (clickable.value) ctx.goToStep(props.index + 1) } </script> <template> <li 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', props.class, ) " role="tab" :aria-selected="status === 'active'" :aria-disabled="step.disabled || undefined" :data-status="status" > <!-- Indicator row: contains the indicator + connector segments --> <div :class="cn('relative flex shrink-0', indicatorAxisClass)"> <!-- Connector segments (absolute, butt up at item boundaries). Track is always border; fill scales in when the segment completes. --> <span v-if="!isFirst" aria-hidden="true" data-slot="stepper-connector" :data-orientation="orientation" :data-edge="orientation === 'horizontal' ? 'left' : 'top'" :class=" cn( 'pointer-events-none absolute overflow-hidden bg-border', 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 v-if="!isLast" aria-hidden="true" data-slot="stepper-connector" :data-orientation="orientation" :data-edge="orientation === 'horizontal' ? 'right' : 'bottom'" :class=" cn( 'pointer-events-none absolute overflow-hidden bg-border', 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> <StepperIndicator :status="status" :size="size" :index="index + 1" :icon="step.icon" :clickable="clickable" class="focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" @click="handleNavigate" /> </div> <!-- Title + description --> <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" @click="handleNavigate" > {{ step.title }} </button> <p v-if="step.description" class="text-muted-foreground mt-0.5 text-xs text-balance"> {{ step.description }} </p> </div> </li> </template> <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> -
app/components/ui/stepper/StepperIndicator.vue 3.6 kB
<script setup lang="ts"> import { computed } from 'vue' import type { Component, HTMLAttributes } from 'vue' import { Check, X } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { stepperIndicatorVariants } from './stepper.variants' import type { StepperSize, StepperStatus } from './context' interface Props { status?: StepperStatus size?: StepperSize index?: number icon?: Component clickable?: boolean class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { status: 'pending', size: 'default', clickable: false, }) const emit = defineEmits<{ click: [] }>() const fallbackIcon = computed(() => { if (props.icon) return props.icon if (props.status === 'completed') return Check if (props.status === 'error') return X return null }) </script> <template> <button type="button" 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', props.class, ) " :disabled="!clickable" :aria-current="status === 'active' ? 'step' : undefined" @click="emit('click')" > <slot> <component :is="fallbackIcon" v-if="fallbackIcon" class="size-4" data-slot="stepper-indicator-icon" aria-hidden="true" /> <span v-else-if="index !== undefined" class="font-medium" data-slot="stepper-indicator-label">{{ index }}</span> </slot> </button> </template> <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. */ [data-slot='stepper-indicator'][data-status='completed'] [data-slot='stepper-indicator-icon'], [data-slot='stepper-indicator'][data-status='error'] [data-slot='stepper-indicator-icon'], [data-slot='stepper-indicator'][data-status='active'] [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'] [data-slot='stepper-indicator-icon'], [data-slot='stepper-indicator'] [data-slot='stepper-indicator-label'] { animation: none !important; transition: none !important; } } </style> -
app/components/ui/stepper/StepperHeader.vue 0.6 kB
<!-- StepperHeader Component Container for the stepper header row. Used to wrap StepperItems. @example <StepperHeader> <StepperItem>...</StepperItem> <StepperSeparator /> <StepperItem>...</StepperItem> </StepperHeader> --> <script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <div :class="cn('stepper-header flex items-center gap-0', props.class)"> <slot /> </div> </template> -
app/components/ui/stepper/StepperContent.vue 1 kB
<!-- StepperContent Component Wrapper for step content with transition animations. @example <StepperContent :step="currentStep"> <div class="step-content"> Step content here </div> </StepperContent> --> <script setup lang="ts"> import { computed } from 'vue' import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { step?: number activeStep?: number class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { step: 1, activeStep: 1, }) const isActive = computed(() => props.step === props.activeStep) </script> <template> <div v-show="isActive" 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', props.class, ) " role="tabpanel" :aria-hidden="!isActive" > <slot /> </div> </template> -
app/components/ui/stepper/StepperTitle.vue 0.4 kB
<!-- StepperTitle Component Title text for a stepper item. @example <StepperTitle>Account Setup</StepperTitle> --> <script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <span :class="cn('text-foreground text-sm font-medium', props.class)"> <slot /> </span> </template> -
app/components/ui/stepper/StepperDescription.vue 0.5 kB
<!-- StepperDescription Component Description text for a stepper item. @example <StepperDescription>Create your account to get started</StepperDescription> --> <script setup lang="ts"> import type { HTMLAttributes } from 'vue' import { cn } from '@/lib/utils' interface Props { class?: HTMLAttributes['class'] } const props = defineProps<Props>() </script> <template> <span :class="cn('text-muted-foreground text-xs', props.class)"> <slot /> </span> </template> -
app/components/ui/stepper/StepperStep.vue 2.8 kB
<!-- StepperStep Component A single step within a Stepper, used with step slots. Provides title, description, completion state, and active state. @example <Stepper v-model="currentStep" orientation="vertical"> <StepperStep title="Account" description="Create your account" :completed="currentStep > 1" :active="currentStep === 1" :error="hasAccountError" > <AccountForm @next="currentStep = 2" /> </StepperStep> <StepperStep title="Profile" description="Set up your profile" :completed="currentStep > 2" :active="currentStep === 2" > <ProfileForm @next="currentStep = 3" /> </StepperStep> <StepperStep title="Confirm" description="Review and confirm" :completed="currentStep > 3" :active="currentStep === 3" > <Confirmation @submit="handleSubmit" /> </StepperStep> </Stepper> --> <script setup lang="ts"> import { computed } from 'vue' import type { Component, HTMLAttributes } from 'vue' import { Check } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { stepperIndicatorVariants } from './stepper.variants' interface Props { title: string description?: string icon?: Component completed?: boolean active?: boolean error?: boolean disabled?: boolean status?: 'active' | 'completed' | 'pending' | 'error' index?: number class?: HTMLAttributes['class'] } const props = withDefaults(defineProps<Props>(), { completed: false, active: false, error: false, disabled: false, index: undefined, }) const computedStatus = computed(() => { if (props.status) return props.status if (props.error) return 'error' if (props.active) return 'active' if (props.completed) return 'completed' return 'pending' }) </script> <template> <div :class="cn('stepper-step flex gap-3', props.class)" role="tab" :aria-selected="active" :aria-disabled="disabled"> <!-- Indicator --> <div data-slot="stepper-indicator" :data-status="computedStatus" :class=" cn( stepperIndicatorVariants({ status: computedStatus, size: 'default', }), ) " > <slot name="icon"> <Check v-if="computedStatus === 'completed'" class="size-4" data-slot="stepper-indicator-icon" aria-hidden="true" /> <span v-else-if="index" data-slot="stepper-indicator-label">{{ index }}</span> </slot> </div> <!-- Content --> <div class="flex flex-col gap-0.5 pt-1"> <slot name="title"> <span class="text-sm font-medium">{{ title }}</span> </slot> <slot name="description"> <span v-if="description" class="text-muted-foreground text-xs"> {{ description }} </span> </slot> </div> </div> </template> -
app/components/ui/stepper/context.ts 0.6 kB
import type { InjectionKey, Ref } from 'vue' import type { StepperStep } from './types' export type StepperOrientation = 'horizontal' | 'vertical' export type StepperStatus = 'active' | 'completed' | 'pending' | 'error' export type StepperSize = 'sm' | 'default' | 'lg' export interface StepperContext { orientation: Ref<StepperOrientation> size: Ref<StepperSize> activeStep: Ref<number> steps: Ref<StepperStep[]> goToStep: (stepIndex: number) => void isClickable: (index: number) => boolean getStatus: (index: number) => StepperStatus } export const STEPPER_CONTEXT: InjectionKey<StepperContext> = Symbol('StepperContext') -
app/components/ui/stepper/stepper.variants.ts 1.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> -
app/components/ui/stepper/types.ts 0.2 kB
import type { Component } from 'vue' export interface StepperStep { id: string | number title: string description?: string icon?: Component disabled?: boolean error?: boolean } -
app/components/ui/stepper/index.ts 0.9 kB
export { default as Stepper } from './Stepper.vue' export { default as StepperHeader } from './StepperHeader.vue' export { default as StepperItem } from './StepperItem.vue' export { default as StepperIndicator } from './StepperIndicator.vue' export { default as StepperContent } from './StepperContent.vue' export { default as StepperTitle } from './StepperTitle.vue' export { default as StepperDescription } from './StepperDescription.vue' export { default as StepperStep } from './StepperStep.vue' export type { StepperStep as StepperStepConfig } from './types' export type { StepperOrientation, StepperSize, StepperStatus } from './context' // Re-export variant API from the sibling file (kept separate to avoid the // StepperIndicator.vue <-> index.ts circular import that broke dev SSR). export { stepperIndicatorVariants, type StepperIndicatorVariants } from './stepper.variants'
Raw manifest: https://uipkge.dev/r/vue/stepper.json