Zoeken...  ⌘K GitHub

Stepper UI Elements

Multi-stap indicator horizontaal of verticaal. Completed/active/upcoming states. Klikbaar optioneel.

/stepper
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