
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
stepperuiMulti-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 ->Timeline 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 |
$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.jsonnpx shadcn-vue@latest add @uipkge/stepperInstalls to:app/components/ui/stepper/| 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 | 'sm''default''lg' | default | optional |
class | HTMLAttributes['class'] | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
StepperContextinterface 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
}StepperStepinterface StepperStep {
id: string | number
title: string
description?: string
icon?: Component
disabled?: boolean
error?: boolean
}<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>
<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(
'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
v-if="!isLast"
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>
<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>
<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>
<!--
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>
<!--
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>
<!--
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>
<!--
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>
<!--
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>
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')
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>
import type { Component } from 'vue'
export interface StepperStep {
id: string | number
title: string
description?: string
icon?: Component
disabled?: boolean
error?: boolean
}
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