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
Related Components
This requires the following components to be installed:
Related Types
This requires the following types to be installed:
Related Theme
This requires the following theme to be installed:
Component
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>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
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
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:
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()
})
})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()
})
})