Stepper
Vue navigationMulti-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
Or with the named registry:
npx shadcn-vue@latest add @uipkge/stepper
Examples
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
} Dependencies
Used by
Files (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 4.2 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 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) // 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 :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', orientation === 'horizontal' ? 'h-9 w-full items-center justify-center' : 'w-9 flex-col items-center justify-start self-stretch', ) " > <!-- Connector segments (absolute, butt up at item boundaries) --> <span v-if="!isFirst" aria-hidden="true" :class=" cn( 'pointer-events-none absolute transition-colors duration-200', 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', leftSegmentCompleted ? 'bg-primary' : 'bg-border', ) " /> <span v-if="!isLast" aria-hidden="true" :class=" cn( 'pointer-events-none absolute transition-colors duration-200', 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', rightSegmentCompleted ? 'bg-primary' : 'bg-border', ) " /> <StepperIndicator :status="status" :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 :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 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> -
app/components/ui/stepper/StepperIndicator.vue 1.5 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" :class=" cn( stepperIndicatorVariants({ status, size }), 'ring-background relative z-10 ring-4 transition-colors 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" aria-hidden="true" /> <span v-else-if="index !== undefined" class="font-medium">{{ index }}</span> </slot> </button> </template> -
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 0.8 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" :class="cn('stepper-content motion-safe:animate-in motion-safe:fade-in-50 motion-safe:duration-200', 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.6 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 :class=" cn( stepperIndicatorVariants({ status: computedStatus, size: 'default', }), ) " > <slot name="icon"> <Check v-if="computedStatus === 'completed'" class="size-4" aria-hidden="true" /> <span v-else-if="index">{{ 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