Skip to content

Stepper

A set of steps that are used to indicate progress through a multi-step process.

Demo

Address

Add your address here

Shipping

Set your preferred shipping method

Checkout

Confirm your order

Step 0 of 0

This requires the following components to be installed:

This requires the following types to be installed:

This requires the following theme to be installed:

Component

Stepper.vue
vue
<script lang="ts">
import type { DynamicSlots } from '@/ui/utils/utils'
import type { StepperRootEmits, StepperRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Icon from '@/ui/components/Icon.vue'
import theme from '@/ui/theme/stepper'
import { reactivePick } from '@vueuse/shared'
import { StepperItem as RStepperItem, StepperDescription, StepperIndicator, StepperRoot, StepperSeparator, StepperTitle, StepperTrigger, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'

const stepper = tv(theme)

type StepperVariants = VariantProps<typeof stepper>

export interface StepperItem {
  slot?: string
  value?: string | number
  title?: string
  description?: string
  icon?: string
  content?: string
  disabled?: boolean
  [key: string]: any
}

export interface StepperProps<T extends StepperItem = StepperItem> extends Pick<StepperRootProps, 'linear'> {
  as?: any
  items: T[]
  size?: StepperVariants['size']
  color?: StepperVariants['color']
  orientation?: StepperVariants['orientation']
  defaultValue?: string | number
  disabled?: boolean
  ui?: Partial<typeof stepper.slots>
  class?: any
}

export type StepperEmits<T extends StepperItem = StepperItem> = Omit<StepperRootEmits, 'update:modelValue'> & {
  next: [payload: T]
  prev: [payload: T]
}

type SlotProps<T extends StepperItem> = (props: { item: T }) => any

export type StepperSlots<T extends StepperItem = StepperItem> = {
  indicator: SlotProps<T>
  title: SlotProps<T>
  description: SlotProps<T>
  content: SlotProps<T>
} & DynamicSlots<T>
</script>

<script setup lang="ts" generic="T extends StepperItem">
const props = withDefaults(defineProps<StepperProps<T>>(), {
  orientation: 'horizontal',
  linear: true,
})
const emits = defineEmits<StepperEmits<T>>()
const slots = defineSlots<StepperSlots<T>>()

const modelValue = defineModel<string | number>()

const rootProps = useForwardProps(reactivePick(props, 'as', 'orientation', 'linear'))

const ui = computed(() => stepper({
  orientation: props.orientation,
  size: props.size,
  color: props.color,
}))

const currentStepIndex = computed({
  get() {
    const value = modelValue.value ?? props.defaultValue

    return ((typeof value === 'string')
      ? props.items.findIndex(item => item.value === value)
      : value) ?? 0
  },
  set(value: number) {
    modelValue.value = props.items?.[value]?.value ?? value
  },
})

const currentStep = computed(() => props.items?.[currentStepIndex.value])
const hasNext = computed(() => currentStepIndex.value < props.items?.length - 1)
const hasPrev = computed(() => currentStepIndex.value > 0)

defineExpose({
  next() {
    if (hasNext.value) {
      currentStepIndex.value += 1
      emits('next', currentStep.value as T)
    }
  },
  prev() {
    if (hasPrev.value) {
      currentStepIndex.value -= 1
      emits('prev', currentStep.value as T)
    }
  },
  hasNext,
  hasPrev,
})
</script>

<template>
  <StepperRoot v-bind="rootProps" v-model="currentStepIndex" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <div :class="ui.header({ class: props.ui?.header })">
      <RStepperItem
        v-for="(item, count) in items"
        :key="item.value ?? count"
        :step="count"
        :disabled="item.disabled || props.disabled"
        :class="ui.item({ class: props.ui?.item })"
      >
        <div :class="ui.container({ class: props.ui?.container })">
          <StepperTrigger :class="ui.trigger({ class: props.ui?.trigger })">
            <StepperIndicator :class="ui.indicator({ class: props.ui?.indicator })">
              <slot name="indicator" :item="item">
                <Icon v-if="item.icon" :name="item.icon" :class="ui.icon({ class: props.ui?.indicator })" />
                <template v-else>
                  {{ count + 1 }}
                </template>
              </slot>
            </StepperIndicator>
          </StepperTrigger>

          <StepperSeparator
            v-if="count < items.length - 1"
            :class="ui.separator({ class: props.ui?.separator })"
          />
        </div>

        <div :class="ui.wrapper({ class: props.ui?.wrapper })">
          <StepperTitle :class="ui.title({ class: props.ui?.title })">
            <slot name="title" :item="item">
              {{ item.title }}
            </slot>
          </StepperTitle>
          <StepperDescription :class="ui.description({ class: props.ui?.description })">
            <slot name="description" :item="item">
              {{ item.description }}
            </slot>
          </StepperDescription>
        </div>
      </RStepperItem>
    </div>

    <div v-if="currentStep?.content || !!slots.content || currentStep?.slot" :class="ui.content({ class: props.ui?.content })">
      <slot
        :name="((currentStep?.slot || 'content') as keyof StepperSlots<T>)"
        :item="(currentStep as Extract<T, { slot: string }>)"
      >
        {{ currentStep?.content }}
      </slot>
    </div>
  </StepperRoot>
</template>
Stepper.vue
vue
<script lang="ts">
import type { DynamicSlots } from '@/UI/Utils/utils'
import type { StepperRootEmits, StepperRootProps } from 'reka-ui'
import type { VariantProps } from 'tailwind-variants'
import Icon from '@/UI/Components/Icon.vue'
import theme from '@/UI/Theme/stepper'
import { reactivePick } from '@vueuse/shared'
import { StepperItem as RStepperItem, StepperDescription, StepperIndicator, StepperRoot, StepperSeparator, StepperTitle, StepperTrigger, useForwardProps } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'

const stepper = tv(theme)

type StepperVariants = VariantProps<typeof stepper>

export interface StepperItem {
  slot?: string
  value?: string | number
  title?: string
  description?: string
  icon?: string
  content?: string
  disabled?: boolean
  [key: string]: any
}

export interface StepperProps<T extends StepperItem = StepperItem> extends Pick<StepperRootProps, 'linear'> {
  as?: any
  items: T[]
  size?: StepperVariants['size']
  color?: StepperVariants['color']
  orientation?: StepperVariants['orientation']
  defaultValue?: string | number
  disabled?: boolean
  ui?: Partial<typeof stepper.slots>
  class?: any
}

export type StepperEmits<T extends StepperItem = StepperItem> = Omit<StepperRootEmits, 'update:modelValue'> & {
  next: [payload: T]
  prev: [payload: T]
}

type SlotProps<T extends StepperItem> = (props: { item: T }) => any

export type StepperSlots<T extends StepperItem = StepperItem> = {
  indicator: SlotProps<T>
  title: SlotProps<T>
  description: SlotProps<T>
  content: SlotProps<T>
} & DynamicSlots<T>
</script>

<script setup lang="ts" generic="T extends StepperItem">
const props = withDefaults(defineProps<StepperProps<T>>(), {
  orientation: 'horizontal',
  linear: true,
})
const emits = defineEmits<StepperEmits<T>>()
const slots = defineSlots<StepperSlots<T>>()

const modelValue = defineModel<string | number>()

const rootProps = useForwardProps(reactivePick(props, 'as', 'orientation', 'linear'))

const ui = computed(() => stepper({
  orientation: props.orientation,
  size: props.size,
  color: props.color,
}))

const currentStepIndex = computed({
  get() {
    const value = modelValue.value ?? props.defaultValue

    return ((typeof value === 'string')
      ? props.items.findIndex(item => item.value === value)
      : value) ?? 0
  },
  set(value: number) {
    modelValue.value = props.items?.[value]?.value ?? value
  },
})

const currentStep = computed(() => props.items?.[currentStepIndex.value])
const hasNext = computed(() => currentStepIndex.value < props.items?.length - 1)
const hasPrev = computed(() => currentStepIndex.value > 0)

defineExpose({
  next() {
    if (hasNext.value) {
      currentStepIndex.value += 1
      emits('next', currentStep.value as T)
    }
  },
  prev() {
    if (hasPrev.value) {
      currentStepIndex.value -= 1
      emits('prev', currentStep.value as T)
    }
  },
  hasNext,
  hasPrev,
})
</script>

<template>
  <StepperRoot v-bind="rootProps" v-model="currentStepIndex" :class="ui.root({ class: [props.class, props.ui?.root] })">
    <div :class="ui.header({ class: props.ui?.header })">
      <RStepperItem
        v-for="(item, count) in items"
        :key="item.value ?? count"
        :step="count"
        :disabled="item.disabled || props.disabled"
        :class="ui.item({ class: props.ui?.item })"
      >
        <div :class="ui.container({ class: props.ui?.container })">
          <StepperTrigger :class="ui.trigger({ class: props.ui?.trigger })">
            <StepperIndicator :class="ui.indicator({ class: props.ui?.indicator })">
              <slot name="indicator" :item="item">
                <Icon v-if="item.icon" :name="item.icon" :class="ui.icon({ class: props.ui?.indicator })" />
                <template v-else>
                  {{ count + 1 }}
                </template>
              </slot>
            </StepperIndicator>
          </StepperTrigger>

          <StepperSeparator
            v-if="count < items.length - 1"
            :class="ui.separator({ class: props.ui?.separator })"
          />
        </div>

        <div :class="ui.wrapper({ class: props.ui?.wrapper })">
          <StepperTitle :class="ui.title({ class: props.ui?.title })">
            <slot name="title" :item="item">
              {{ item.title }}
            </slot>
          </StepperTitle>
          <StepperDescription :class="ui.description({ class: props.ui?.description })">
            <slot name="description" :item="item">
              {{ item.description }}
            </slot>
          </StepperDescription>
        </div>
      </RStepperItem>
    </div>

    <div v-if="currentStep?.content || !!slots.content || currentStep?.slot" :class="ui.content({ class: props.ui?.content })">
      <slot
        :name="((currentStep?.slot || 'content') as keyof StepperSlots<T>)"
        :item="(currentStep as Extract<T, { slot: string }>)"
      >
        {{ currentStep?.content }}
      </slot>
    </div>
  </StepperRoot>
</template>

Theme

stepper.ts
ts
export default {
  slots: {
    root: '',
    header: '',
    item: '',
    container: '',
    trigger: '',
    indicator: '',
    icon: '',
    separator: '',
    wrapper: '',
    title: '',
    description: '',
    content: '',
  },

  variants: {
    orientation: {
      horizontal: {
        root: '',
        container: '',
        separator: '',
        wrapper: '',
      },
      vertical: {
        header: '',
        item: '',
        separator: '',
      },
    },

    size: {
      xs: {
        trigger: '',
        icon: '',
        title: '',
        description: '',
        wrapper: '',
      },
      sm: {
        trigger: '',
        icon: '',
        title: '',
        description: '',
        wrapper: '',
      },
      md: {
        trigger: '',
        icon: '',
        title: '',
        description: '',
        wrapper: '',
      },
      lg: {
        trigger: '',
        icon: '',
        title: '',
        description: '',
        wrapper: '',
      },
      xl: {
        trigger: '',
        icon: '',
        title: '',
        description: '',
        wrapper: '',
      },
    },

    color: {
      primary: {
        trigger: '',
        separator: '',
      },
      secondary: {
        trigger: '',
        separator: '',
      },
      success: {
        trigger: '',
        separator: '',
      },
      info: {
        trigger: '',
        separator: '',
      },
      warning: {
        trigger: '',
        separator: '',
      },
      error: {
        trigger: '',
        separator: '',
      },
      neutral: {
        trigger: '',
        separator: '',
      },
    },
  },

  compoundVariants: [],

  defaultVariants: {
    size: 'md',
    color: 'primary',
  } as const,
}
View Nuxt UI theme
stepper.ts
ts
export default {
  slots: {
    root: 'flex gap-4',
    header: 'flex',
    item: 'group text-center relative w-full',
    container: 'relative',
    trigger: 'rounded-full font-medium text-center align-middle flex items-center justify-center font-semibold group-data-[state=completed]:text-inverted group-data-[state=active]:text-inverted text-muted bg-elevated focus-visible:outline-2 focus-visible:outline-offset-2',
    indicator: 'flex items-center justify-center size-full',
    icon: 'shrink-0',
    separator: 'absolute rounded-full group-data-[disabled]:opacity-75 bg-accented',
    wrapper: '',
    title: 'font-medium text-default',
    description: 'text-muted text-wrap',
    content: 'size-full',
  },

  variants: {
    orientation: {
      horizontal: {
        root: 'flex-col',
        container: 'flex justify-center',
        separator: 'top-[calc(50%-2px)] h-0.5',
        wrapper: 'mt-1',
      },
      vertical: {
        header: 'flex-col gap-4',
        item: 'flex text-start',
        separator: 'start-[calc(50%-1px)] -bottom-[10px] w-0.5',
      },
    },

    size: {
      xs: {
        trigger: 'size-6 text-xs',
        icon: 'size-3',
        title: 'text-xs',
        description: 'text-xs',
        wrapper: 'mt-1.5',
      },
      sm: {
        trigger: 'size-8 text-sm',
        icon: 'size-4',
        title: 'text-xs',
        description: 'text-xs',
        wrapper: 'mt-2',
      },
      md: {
        trigger: 'size-10 text-base',
        icon: 'size-5',
        title: 'text-sm',
        description: 'text-sm',
        wrapper: 'mt-2.5',
      },
      lg: {
        trigger: 'size-12 text-lg',
        icon: 'size-6',
        title: 'text-base',
        description: 'text-base',
        wrapper: 'mt-3',
      },
      xl: {
        trigger: 'size-14 text-xl',
        icon: 'size-7',
        title: 'text-lg',
        description: 'text-lg',
        wrapper: 'mt-3.5',
      },
    },

    color: {
      primary: {
        trigger: 'group-data-[state=completed]:bg-primary group-data-[state=active]:bg-primary focus-visible:outline-primary',
        separator: 'group-data-[state=completed]:bg-primary',
      },
      secondary: {
        trigger: 'group-data-[state=completed]:bg-secondary group-data-[state=active]:bg-secondary focus-visible:outline-secondary',
        separator: 'group-data-[state=completed]:bg-secondary',
      },
      success: {
        trigger: 'group-data-[state=completed]:bg-success group-data-[state=active]:bg-success focus-visible:outline-success',
        separator: 'group-data-[state=completed]:bg-success',
      },
      info: {
        trigger: 'group-data-[state=completed]:bg-info group-data-[state=active]:bg-info focus-visible:outline-info',
        separator: 'group-data-[state=completed]:bg-info',
      },
      warning: {
        trigger: 'group-data-[state=completed]:bg-warning group-data-[state=active]:bg-warning focus-visible:outline-warning',
        separator: 'group-data-[state=completed]:bg-warning',
      },
      error: {
        trigger: 'group-data-[state=completed]:bg-error group-data-[state=active]:bg-error focus-visible:outline-error',
        separator: 'group-data-[state=completed]:bg-error',
      },
      neutral: {
        trigger: 'group-data-[state=completed]:bg-inverted group-data-[state=active]:bg-inverted focus-visible:outline-inverted',
        separator: 'group-data-[state=completed]:bg-inverted',
      },
    },
  },

  compoundVariants: [
    {
      orientation: 'horizontal',
      size: 'xs',
      class: { separator: 'start-[calc(50%+16px)] end-[calc(-50%+16px)]' },
    } as const,
    {
      orientation: 'horizontal',
      size: 'sm',
      class: { separator: 'start-[calc(50%+20px)] end-[calc(-50%+20px)]' },
    } as const,
    {
      orientation: 'horizontal',
      size: 'md',
      class: { separator: 'start-[calc(50%+28px)] end-[calc(-50%+28px)]' },
    } as const,
    {
      orientation: 'horizontal',
      size: 'lg',
      class: { separator: 'start-[calc(50%+32px)] end-[calc(-50%+32px)]' },
    } as const,
    {
      orientation: 'horizontal',
      size: 'xl',
      class: { separator: 'start-[calc(50%+36px)] end-[calc(-50%+36px)]' },
    } as const,
    {
      orientation: 'vertical',
      size: 'xs',
      class: { separator: 'top-[30px]', item: 'gap-1.5' },
    } as const,
    {
      orientation: 'vertical',
      size: 'sm',
      class: { separator: 'top-[38px]', item: 'gap-2' },
    } as const,
    {
      orientation: 'vertical',
      size: 'md',
      class: { separator: 'top-[46px]', item: 'gap-2.5' },
    } as const,
    {
      orientation: 'vertical',
      size: 'lg',
      class: { separator: 'top-[54px]', item: 'gap-3' },
    } as const,
    {
      orientation: 'vertical',
      size: 'xl',
      class: { separator: 'top-[62px]', item: 'gap-3.5' },
    } as const,
  ],

  defaultVariants: {
    size: 'md',
    color: 'primary',
  } as const,
}

Test

To test this component, you can use the following test file:

Stepper.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Stepper from '@/ui/components/Stepper.vue'
import theme from '@/ui/theme/stepper'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('stepper', () => {
  const sizes = Object.keys(theme.variants.size) as any

  const items = [
    {
      title: 'Address',
      description: 'Add your address here',
      icon: 'i-lucide-house',
    },
    {
      title: 'Shipping',
      description: 'Set your preferred shipping method',
      icon: 'i-lucide-truck',
    },
    {
      slot: 'custom',
      title: 'Checkout',
      description: 'Confirm your order',
    },
  ]

  const props = { items }

  it.each<[string, RenderOptions<typeof Stepper>]>([
    // Props
    ['with items', { props }],
    ['with defaultValue', { props: { ...props, defaultValue: 1 } }],
    ['with modelValue', { props: { ...props, modelValue: 1 } }],
    ['with neutral color', { props: { ...props, color: 'neutral' } }],
    ...sizes.map((size: string) => [`with size ${size} horizontal`, { props: { ...props, size } }]),
    ...sizes.map((size: string) => [`with size ${size} vertical`, { props: { ...props, size, orientation: 'vertical' } }]),
    ['without linear', { props: { ...props, linear: false } }],
    ['with as', { props: { ...props, as: 'section' } }],
    ['with class', { props: { ...props, class: 'gap-8' } }],
    ['with ui', { props: { ...props, ui: { title: 'font-bold' } } }],
    // Slots
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with indicator slot', { props, slots: { indicator: () => 'Indicator slot' } }],
    ['with title slot', { props, slots: { title: () => 'Title slot' } }],
    ['with description slot', { props, slots: { description: () => 'Description slot' } }],
    ['with content slot', { props, slots: { content: () => 'Content slot' } }],
    ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
  ])('renders %s correctly', async (name, options) => {
    render(Stepper, { attrs: { 'data-testid': 'steper' }, ...options })

    expect(screen.getByTestId('steper')).toMatchSnapshot()
  })
})
Stepper.test.ts
ts
import type { RenderOptions } from '@testing-library/vue'
import Stepper from '@/UI/Components/Stepper.vue'
import theme from '@/UI/Theme/stepper'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'

describe('stepper', () => {
  const sizes = Object.keys(theme.variants.size) as any

  const items = [
    {
      title: 'Address',
      description: 'Add your address here',
      icon: 'i-lucide-house',
    },
    {
      title: 'Shipping',
      description: 'Set your preferred shipping method',
      icon: 'i-lucide-truck',
    },
    {
      slot: 'custom',
      title: 'Checkout',
      description: 'Confirm your order',
    },
  ]

  const props = { items }

  it.each<[string, RenderOptions<typeof Stepper>]>([
    // Props
    ['with items', { props }],
    ['with defaultValue', { props: { ...props, defaultValue: 1 } }],
    ['with modelValue', { props: { ...props, modelValue: 1 } }],
    ['with neutral color', { props: { ...props, color: 'neutral' } }],
    ...sizes.map((size: string) => [`with size ${size} horizontal`, { props: { ...props, size } }]),
    ...sizes.map((size: string) => [`with size ${size} vertical`, { props: { ...props, size, orientation: 'vertical' } }]),
    ['without linear', { props: { ...props, linear: false } }],
    ['with as', { props: { ...props, as: 'section' } }],
    ['with class', { props: { ...props, class: 'gap-8' } }],
    ['with ui', { props: { ...props, ui: { title: 'font-bold' } } }],
    // Slots
    ['with default slot', { props, slots: { default: () => 'Default slot' } }],
    ['with indicator slot', { props, slots: { indicator: () => 'Indicator slot' } }],
    ['with title slot', { props, slots: { title: () => 'Title slot' } }],
    ['with description slot', { props, slots: { description: () => 'Description slot' } }],
    ['with content slot', { props, slots: { content: () => 'Content slot' } }],
    ['with custom slot', { props, slots: { custom: () => 'Custom slot' } }],
  ])('renders %s correctly', async (name, options) => {
    render(Stepper, { attrs: { 'data-testid': 'steper' }, ...options })

    expect(screen.getByTestId('steper')).toMatchSnapshot()
  })
})

Contributors

barbapapazes

Changelog

c32f9 - fix: use ui.content instead of ui.description on 4/23/2025
368e3 - feat: stepper (#163) on 3/26/2025