Stepper
Numbered progress through a sequence: done, current and still to come.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add stepperpnpm dlx ermineui add stepperyarn dlx ermineui add stepperbunx ermineui add stepperThat copies 2 files into src/components/ui/stepper/. Files you already have are kept.
Use
import { Stepper } from '@/components/ui/stepper'<Stepper v-model="step" :steps="['Cart', 'Shipping', 'Payment']" />
<MultiSelect v-model="regions" name="regions" placeholder="Search regions…" :max="3">
<MultiSelectItem value="fra1">Frankfurt</MultiSelectItem>
<MultiSelectItem value="lhr1">London</MultiSelectItem>
</MultiSelect>
<NumberInput v-model="replicas" :min="1" :max="10" />
<FileUpload v-model="files" multiple accept=".csv,image/*" :max-size="2 * 1024 * 1024" :progress="progressByName" />In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
Stepper.vue | src/components/ui/stepper/Stepper.vue |
index.ts | src/components/ui/stepper/index.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
Stepper.vue 207 lines
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
/** The steps in order: a name, or a name with a line of description. */
steps: ReadonlyArray<string | { label: string; description?: string }>
orientation?: 'horizontal' | 'vertical'
/** Let the reader go back by pressing a step that is already done. */
clickable?: boolean
/** Accessible name of the list. */
label?: string
}>(),
{ orientation: 'horizontal', clickable: true, label: 'Progress' },
)
/** The current step, starting at 1. One past the last step shows every step as done. */
const step = defineModel<number>({ default: 1 })
const items = computed(() =>
props.steps.map((item, index) => {
const number = index + 1
const state = number < step.value ? 'complete' : number === step.value ? 'current' : 'upcoming'
return { number, state, label: typeof item === 'string' ? item : item.label, description: typeof item === 'string' ? undefined : item.description }
}),
)
</script>
<template>
<ol class="er-stepper" :aria-label="label" :data-orientation="orientation">
<li v-for="item in items" :key="item.number" class="er-stepper-step" :data-state="item.state" :aria-current="item.state === 'current' ? 'step' : undefined">
<component
:is="clickable && item.state === 'complete' ? 'button' : 'div'"
class="er-stepper-item"
:type="clickable && item.state === 'complete' ? 'button' : undefined"
@click="clickable && item.state === 'complete' && (step = item.number)"
>
<span class="er-stepper-marker" aria-hidden="true">
<svg v-if="item.state === 'complete'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
<template v-else>{{ item.number }}</template>
</span>
<span class="er-stepper-text">
<span class="er-stepper-label">
<span v-if="item.state === 'complete'" class="er-stepper-sr">Completed: </span>{{ item.label }}
</span>
<span v-if="item.description" class="er-stepper-description">{{ item.description }}</span>
</span>
</component>
</li>
</ol>
</template>
<style>
@layer components {
.er-stepper {
display: flex;
gap: 0.5rem;
width: 100%;
margin: 0;
padding: 0;
font-size: 0.875rem;
list-style: none;
}
.er-stepper[data-orientation='vertical'] {
flex-direction: column;
gap: 0;
}
.er-stepper-step {
display: flex;
flex: 1;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.er-stepper-step:last-child {
flex: none;
}
/* The connector to the next step: filled once this step is done. */
.er-stepper-step:not(:last-child)::after {
content: '';
flex: 1;
min-width: 1rem;
height: 2px;
border-radius: 1px;
background: var(--er-border);
}
.er-stepper-step[data-state='complete']::after {
background: var(--er-primary);
}
.er-stepper[data-orientation='vertical'] .er-stepper-step {
flex: none;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
.er-stepper[data-orientation='vertical'] .er-stepper-step:not(:last-child)::after {
flex: none;
width: 2px;
min-width: 0;
height: 1.25rem;
margin: 0 0 0.25rem 0.8125rem;
}
.er-stepper-item {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
padding: 0;
border: 0;
border-radius: var(--er-radius-sm);
background: transparent;
color: var(--er-muted-foreground);
font: inherit;
text-align: left;
}
button.er-stepper-item {
cursor: pointer;
}
button.er-stepper-item:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-stepper-marker {
display: grid;
flex-shrink: 0;
place-items: center;
width: 1.75rem;
height: 1.75rem;
border: 1px solid var(--er-input);
border-radius: 50%;
font-size: 0.8125rem;
font-weight: var(--er-weight-medium);
font-variant-numeric: tabular-nums;
transition:
background-color var(--er-duration),
border-color var(--er-duration);
}
.er-stepper-marker svg {
width: 0.875rem;
height: 0.875rem;
}
.er-stepper-step[data-state='complete'] .er-stepper-marker {
border-color: var(--er-primary);
background: var(--er-primary);
color: var(--er-primary-foreground);
}
.er-stepper-step[data-state='current'] .er-stepper-marker {
border-color: var(--er-primary);
color: var(--er-foreground);
box-shadow: 0 0 0 3px color-mix(in oklab, var(--er-primary) 15%, transparent);
}
.er-stepper-step:not([data-state='upcoming']) .er-stepper-label {
color: var(--er-foreground);
}
.er-stepper-text {
display: grid;
min-width: 0;
}
.er-stepper-label {
font-weight: var(--er-weight-medium);
white-space: nowrap;
}
.er-stepper-description {
font-size: 0.8125rem;
}
.er-stepper-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
/* On a narrow screen only the current step keeps its name; the rest shrink to their numbers. */
@media (max-width: 40rem) {
.er-stepper[data-orientation='horizontal'] .er-stepper-step:not([data-state='current']) .er-stepper-text {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
}
}
</style>index.ts 3 lines
/** Stepper — numbered progress through a sequence: done, current and still to come. */
export { default as Stepper } from './Stepper.vue'