Stepper

Navigates a sequence of steps with guarded transitions.

Step 2 of 0
Step 2 of 3
<script setup lang="ts">
  import { Stepper } from '@hina-ui/vue'

  const items = [
    { title: 'Step A', description: 'The first description' },
    { title: 'Step B', description: 'A longer description that wraps naturally across lines' },
    { title: 'Step C', description: 'The final description' },
  ]
</script>

<template>
  <Stepper :items="items" :default-value="2" />
</template>
vue

Usage

import { Stepper, type StepperItem } from '@hina-ui/vue'
ts

items defines the steps. v-model is the current step number, starting at 1. Without a model, the component manages its state and starts at defaultValue, or the first step.

Step 1 of 0
Step 1 of 3

Current step:1

<script setup lang="ts">
  import { ref } from 'vue'
  import { Stepper, Text, Stack } from '@hina-ui/vue'

  const step = ref(1)
  const items = [
    { title: 'Step A', description: 'The first description' },
    { title: 'Step B', description: 'A longer description that wraps naturally across lines' },
    { title: 'Step C', description: 'The final description' },
  ]
</script>

<template>
  <Stack class="w-full" gap="lg">
    <Stepper v-model="step" :items="items" />
    <Text size="sm" tone="muted">Current step:{{ step }}</Text>
  </Stack>
</template>
vue

Examples

Linear and free navigation

linear defaults to true: previous steps and the immediate next step are available, but later steps cannot be reached directly. Set it to false to reach any enabled step.

Linear navigation limits the allowed range; it does not validate data. Use beforeChange for validation. Assigning v-model externally updates state directly and does not run navigation restrictions or validation.

Step 1 of 0
Step 1 of 3
<script setup lang="ts">
  import { ref } from 'vue'
  import { Stepper, Stack, Switch } from '@hina-ui/vue'

  const linear = ref(true)
  const items = [
    { title: 'Step A', description: 'The first description' },
    { title: 'Step B', description: 'A longer description that wraps naturally across lines' },
    { title: 'Step C', description: 'The final description' },
  ]
</script>

<template>
  <Stack class="w-full" gap="lg">
    <Switch v-model="linear">Linear navigation</Switch>
    <Stepper :items="items" :linear="linear" />
  </Stack>
</template>
vue

Vertical

orientation="vertical" places text beside each marker, with connectors extending along descriptions. Horizontal steps place text below their markers and share the available width equally. Orientation also controls arrow-key navigation.

Step 2 of 0
Step 2 of 3
<script setup lang="ts">
  import { Stepper } from '@hina-ui/vue'

  const items = [
    { title: 'Step A', description: 'The first description' },
    { title: 'Step B', description: 'A longer description that wraps naturally across lines' },
    { title: 'Step C', description: 'The final description' },
  ]
</script>

<template>
  <Stepper :items="items" :default-value="2" orientation="vertical" class="max-w-md" />
</template>
vue

States

Steps before the current one appear completed. completed explicitly marks a step as complete, including the last step. error takes visual priority while the active step retains its current-position semantics.

An item’s disabled prevents entering it; root disabled prevents all user navigation. next() and prev() do not skip disabled steps: they return false when the adjacent step is disabled.

Step 2 of 0
Step 2 of 3
<script setup lang="ts">
  import { Stepper, Stack, Switch } from '@hina-ui/vue'
  import { ref } from 'vue'

  const disabled = ref(false)
  const items = [
    { title: 'Completed', completed: true },
    { title: 'Needs attention', description: 'This step has an error', error: true },
    { title: 'Disabled', disabled: true },
  ]
</script>

<template>
  <Stack class="w-full" gap="lg">
    <Switch v-model="disabled">Disable all steps</Switch>
    <Stepper :items="items" :default-value="2" :linear="false" :disabled="disabled" />
  </Stack>
</template>
vue

Content and navigation

The default slot receives the current step and navigation methods. Compose Card, Button or other components inside it. The content region only renders when this slot is provided.

The slot remains mounted; the caller controls switching, preserving or resetting its contents. The component instance exposes next(), prev() and goTo(step) with the same behavior as the slot methods.

Step 1 of 0

Step A

Content for step 1 .

Step 1 of 3
<script setup lang="ts">
  import { Stepper, Button, Card, Inline, Stack, Text } from '@hina-ui/vue'

  const items = [
    { title: 'Step A', description: 'The first description' },
    { title: 'Step B', description: 'A longer description that wraps naturally across lines' },
    { title: 'Step C', description: 'The final description' },
  ]
</script>

<template>
  <Stepper :items="items">
    <template #default="{ item, step, next, prev, canNext, canPrev }">
      <Card>
        <Stack gap="lg">
          <Text weight="medium">{{ item?.title }}</Text>
          <Text tone="muted">Content for step {{ step }} .</Text>
          <Inline justify="between">
            <Button variant="outline" tone="neutral" :disabled="!canPrev" @click="prev">
              Previous
            </Button>
            <Button :disabled="!canNext" @click="next">Next</Button>
          </Inline>
        </Stack>
      </Card>
    </template>
  </Stepper>
</template>
vue

Guarding navigation

beforeChange(nextStep, previousStep) runs for pointer activation, keyboard activation and navigation methods. Return false to block a change, or true / no value to allow it. Promises are supported.

The current step stays active and duplicate requests are blocked while waiting. Thrown errors and rejected promises preserve the step and emit error. External current-step, step-structure or disabled-state changes and unmounting invalidate outstanding validation.

This example uses FormField and Input. Clicking the second step and clicking Next both run the same guard.

Step 1 of 0
Step 1 of 3
<script setup lang="ts">
  import { computed, ref } from 'vue'
  import { Stepper, Button, Card, FormField, Input, Inline, Stack, Text } from '@hina-ui/vue'

  const name = ref('')
  const error = ref('')
  const items = computed(() => [
    { title: 'Step A', error: !!error.value },
    { title: 'Step B' },
    { title: 'Step C' },
  ])

  async function beforeChange(step: number, previousStep: number) {
    if (step <= previousStep || previousStep !== 1) return true
    await new Promise(resolve => setTimeout(resolve, 700))
    error.value = name.value.trim() ? '' : 'Enter a name'
    return !error.value
  }
</script>

<template>
  <Stepper :items="items" :before-change="beforeChange">
    <template #default="{ step, next, prev, pending, canNext, canPrev }">
      <Card>
        <Stack gap="lg">
          <FormField v-if="step === 1" label="Name" :error="error">
            <Input v-model="name" :disabled="pending" />
          </FormField>
          <Text v-else>Step {{ step }}</Text>
          <Inline justify="between">
            <Button variant="outline" tone="neutral" :disabled="!canPrev" @click="prev">
              Previous
            </Button>
            <Button :loading="pending" :disabled="!canNext" @click="next">Next</Button>
          </Inline>
        </Stack>
      </Card>
    </template>
  </Stepper>
</template>
vue

Custom indicators and text

#indicator, #title and #description receive the original item, zero-based index, one-based step, state, active, pending and disabled. Custom fields retain type inference.

This example replaces numbers with icons and adds Tag to titles. These slots are inside the step button and should contain non-interactive content. Place inputs, links and other buttons in the default content slot.

Step 1 of 0
Step 1 of 3
<script setup lang="ts">
  import { FileText, ListChecks, Send } from '@lucide/vue'
  import { Stepper, Tag, Inline, Text, type StepperItem } from '@hina-ui/vue'

  interface Item extends StepperItem {
    note: string
  }
  const icons = [FileText, ListChecks, Send]
  const items: Item[] = [
    { title: 'Step A', note: 'Required' },
    { title: 'Step B', note: 'Optional' },
    { title: 'Step C', note: 'Confirm' },
  ]
</script>

<template>
  <Stepper :items="items" :linear="false">
    <template #indicator="{ index }"><component :is="icons[index]" /></template>
    <template #title="{ item, active }">
      <Inline as="span" justify="center" gap="sm">
        <Text as="span" weight="medium">{{ item.title }}</Text>
        <Tag :tone="active ? 'accent' : 'neutral'">{{ item.note }}</Tag>
      </Inline>
    </template>
  </Stepper>
</template>
vue

Sizes

size adjusts marker and text sizes. Marker dimensions and spacing use Hina tokens and follow density settings.

sm

Step 2 of 0
Step 2 of 3

md

Step 2 of 0
Step 2 of 3

lg

Step 2 of 0
Step 2 of 3
<script setup lang="ts">
  import { Stepper, Stack, Text, type StepperSize } from '@hina-ui/vue'

  const sizes: StepperSize[] = ['sm', 'md', 'lg']
  const items = [
    { title: 'Step A', description: 'The first description' },
    { title: 'Step B', description: 'A longer description that wraps naturally across lines' },
    { title: 'Step C', description: 'The final description' },
  ]
</script>

<template>
  <Stack class="w-full" gap="lg">
    <Stack v-for="size in sizes" :key="size">
      <Text size="sm" tone="muted">{{ size }}</Text>
      <Stepper :items="items" :size="size" :default-value="2" />
    </Stack>
  </Stack>
</template>
vue

RTL

Direction follows an ancestor’s dir or the configuration provider, or an explicit dir="rtl". Horizontal placement, connectors and arrow navigation reverse together.

Step 1 of 0
Step 1 of 3
<script setup lang="ts">
  import { Stepper } from '@hina-ui/vue'

  const items = [
    { title: 'الخطوة الأولى', description: 'وصف الخطوة الأولى' },
    { title: 'الخطوة الثانية', description: 'وصف الخطوة الثانية' },
    { title: 'الخطوة الثالثة' },
  ]
</script>

<template>
  <Stepper :items="items" :linear="false" dir="rtl" />
</template>
vue

Behavior and accessibility

  • Steps form a named group containing a list. Set label to replace the default accessible name.
  • Each step is a button with a title and optional description. The current button has aria-current="step". Error and completion states also have text alternatives.
  • Tab moves between enabled steps and content. Left/right arrows move focus horizontally; up/down arrows move it vertically. Enter or Space activates the focused step. Focus movement does not change the current step.
  • Navigation does not steal focus from content or submit forms.
  • Progress announcements follow the UI locale. Hina motion tokens respect reduced-motion preferences.

API

Props

Prop
Type
Default
Description
items
T[]
Required
Steps, where T extends StepperItem
v-model
number
—
Current step, starting at 1
defaultValue
number
1
Initial uncontrolled step
orientation
'horizontal' | 'vertical'
'horizontal'
Layout and keyboard direction
size
'sm' | 'md' | 'lg'
'md'
Size
linear
boolean
true
Restrict navigation to sequential steps
disabled
boolean
false
Prevent all user navigation
beforeChange
StepperBeforeChange
—
Navigation guard
label
string
From locale
Accessible group name
dir
'ltr' | 'rtl'
Inherited
Reading direction
class
string
—
Root element classes

An empty list has a displayed step of 0. Invalid or out-of-range current values are bounded for display without writing back to the model. defaultValue only initializes state.

StepperItem

Field
Type
Description
title
string
Required title
description
string
Optional description
disabled
boolean
Prevent entering the step
completed
boolean
Explicit completion; false does not override automatic completion of previous steps
error
boolean
Error state, taking priority over completion

Step identity and numbers follow array position. index starts at 0 and step starts at 1.

Slots

Slot
Props
Description
default
StepperNavigation<T>
Current content and navigation
indicator
StepperSlotProps<T>
Number, check, error icon or loading indicator
title
StepperSlotProps<T>
Title
description
StepperSlotProps<T>
Description

StepperSlotProps<T> is { item, index, step, state, active, pending, disabled }. state is 'inactive' | 'active' | 'completed' | 'error'. pending identifies the step awaiting validation; disabled includes explicit disabling and linear range restrictions.

StepperNavigation<T> is { step, item, total, pending, canNext, canPrev, next, prev, goTo }. item is undefined for an empty list. Navigation returns Promise<boolean>: true when a model update is requested, or false when blocked, unchanged or stale.

Events

Event
Payload
Description
update:modelValue
step: number
Requests a current-step update
error
error: unknown
A navigation guard threw an error

Expose

Component refs expose step, pending, canNext, canPrev, next(), prev() and goTo(step). beforeChange has the signature (nextStep: number, previousStep: number) => boolean | void | Promise<boolean | void>.