Stepper UI Elements
Multi-stap indicator horizontaal of verticaal. Completed/active/upcoming states. Klikbaar optioneel.
src/components/ui/Stepper.astro
---
/**
* Stepper
* Multi-stap indicator — horizontaal of verticaal.
* Stap: completed, active, upcoming. Klikbaar optioneel.
*/
interface Step {
label: string;
description?: string;
icon?: string;
}
interface Props {
steps: Step[];
current?: number; /** 0-indexed */
orientation?: 'horizontal' | 'vertical';
clickable?: boolean;
variant?: 'default' | 'numbered' | 'icon';
size?: 'sm' | 'md' | 'lg';
}
const {
steps = [],
current = 0,
orientation = 'horizontal',
clickable = false,
variant = 'numbered',
size = 'md',
} = Astro.props;
---
<div
class:list={['stepper', `stepper--${orientation}`, `stepper--${size}`]}
data-component="stepper"
data-current={current}
role="list"
aria-label="Stappen"
>
{steps.map((step, i) => {
const isCompleted = i < current;
const isActive = i === current;
return (
<div
class:list={[
'stepper__step',
{ 'stepper__step--completed': isCompleted },
{ 'stepper__step--active': isActive },
{ 'stepper__step--clickable': clickable },
]}
role="listitem"
aria-current={isActive ? 'step' : undefined}
data-step={i}
>
<!-- Node -->
<div class="stepper__node">
{isCompleted ? (
<svg class="stepper__check" width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2.5 7l3 3 5.5-5.5" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
) : step.icon && variant === 'icon' ? (
<span set:html={step.icon} />
) : (
<span class="stepper__num">{i + 1}</span>
)}
</div>
<!-- Connector line -->
{i < steps.length - 1 && (
<div class="stepper__line">
<div class="stepper__line-fill"></div>
</div>
)}
<!-- Text -->
<div class="stepper__text">
<span class="stepper__label">{step.label}</span>
{step.description && <span class="stepper__desc">{step.description}</span>}
</div>
</div>
);
})}
</div>
{clickable && (
<script>
document.querySelectorAll<HTMLElement>('[data-component="stepper"]').forEach(el => {
el.querySelectorAll<HTMLElement>('.stepper__step--clickable').forEach(step => {
step.addEventListener('click', () => {
const idx = Number(step.dataset.step);
el.querySelectorAll('.stepper__step').forEach((s, i) => {
s.classList.toggle('stepper__step--completed', i < idx);
s.classList.toggle('stepper__step--active', i === idx);
s.setAttribute('aria-current', i === idx ? 'step' : '');
});
el.dataset.current = String(idx);
});
});
});
</script>
)}
<style>
/* === HORIZONTAL === */
.stepper--horizontal {
display: flex;
align-items: flex-start;
}
.stepper--horizontal .stepper__step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
text-align: center;
}
.stepper--horizontal .stepper__node {
position: relative;
z-index: 1;
}
.stepper--horizontal .stepper__line {
position: absolute;
top: 0;
left: calc(50% + 20px);
right: calc(-50% + 20px);
height: 2px;
background: rgba(0,0,0,0.1);
overflow: hidden;
top: 20px;
transform: translateY(-50%);
}
.stepper--horizontal .stepper__text {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
/* === VERTICAL === */
.stepper--vertical {
display: flex;
flex-direction: column;
gap: 0;
}
.stepper--vertical .stepper__step {
display: flex;
align-items: flex-start;
gap: 1rem;
position: relative;
padding-bottom: 2rem;
}
.stepper--vertical .stepper__step:last-child { padding-bottom: 0; }
.stepper--vertical .stepper__line {
position: absolute;
left: 19px;
top: 40px;
bottom: 0;
width: 2px;
background: rgba(0,0,0,0.1);
overflow: hidden;
}
.stepper--vertical .stepper__text {
padding-top: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
/* === NODE === */
.stepper__node {
width: 40px;
height: 40px;
border-radius: 50%;
border: 2px solid rgba(0,0,0,0.15);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--color-bg, #fff);
color: var(--color-muted, #6b7280);
transition: border-color 0.25s, background 0.25s, color 0.25s;
}
.stepper--sm .stepper__node { width: 32px; height: 32px; }
.stepper--lg .stepper__node { width: 48px; height: 48px; }
.stepper__step--active .stepper__node {
border-color: var(--color-accent, #6366f1);
background: var(--color-accent, #6366f1);
color: #fff;
}
.stepper__step--completed .stepper__node {
border-color: var(--color-accent, #6366f1);
background: var(--color-accent, #6366f1);
color: #fff;
}
/* === LINE FILL === */
.stepper__line-fill {
height: 100%;
width: 0%;
background: var(--color-accent, #6366f1);
transition: width 0.4s, height 0.4s;
}
/* Horizontal fill */
.stepper--horizontal .stepper__step--completed .stepper__line-fill { width: 100%; }
/* Vertical fill */
.stepper--vertical .stepper__line-fill { width: 100%; height: 0%; }
.stepper--vertical .stepper__step--completed .stepper__line-fill { height: 100%; }
/* === NUM === */
.stepper__num {
font-size: 0.8125rem;
font-weight: 700;
font-feature-settings: "tnum";
}
/* === TEXT === */
.stepper__label {
font-size: 0.875rem;
font-weight: 600;
color: rgba(0,0,0,0.4);
transition: color 0.2s;
}
.stepper--sm .stepper__label { font-size: 0.8125rem; }
.stepper--lg .stepper__label { font-size: 1rem; }
.stepper__step--active .stepper__label,
.stepper__step--completed .stepper__label { color: var(--color-primary, #0a0a0a); }
.stepper__desc {
font-size: 0.8125rem;
color: var(--color-muted, #6b7280);
line-height: 1.4;
}
/* Clickable */
.stepper__step--clickable { cursor: pointer; }
.stepper__step--clickable:hover .stepper__node { border-color: var(--color-accent, #6366f1); }
@media (max-width: 600px) {
.stepper--horizontal { flex-direction: column; gap: 0; }
.stepper--horizontal .stepper__step { flex-direction: row; text-align: left; align-items: center; flex: none; padding-bottom: 1.5rem; position: relative; }
.stepper--horizontal .stepper__line {
position: absolute;
left: 19px;
top: 40px;
right: auto;
bottom: 0;
width: 2px;
height: auto;
transform: none;
}
.stepper--horizontal .stepper__text { margin-top: 0; }
.stepper--horizontal .stepper__step--completed .stepper__line-fill { width: 100%; height: 100%; }
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
steps * | { label: string; description?: string; icon?: string }[] | — | Stap definitie array |
current | number | 0 | Huidige stap index (0-indexed) |
orientation | 'horizontal' | 'vertical' | 'horizontal' | Lay-out richting |
clickable | boolean | false | Stappen klikbaar maken |
variant | 'default' | 'numbered' | 'icon' | 'numbered' | Node variant |
size | 'sm' | 'md' | 'lg' | 'md' | Grootte |
* = verplicht