Zum Inhalt springen
UI Kit
Accent

Schrittanzeige

Die Schritte eines Ablaufs, waagerecht oder senkrecht, mit erledigt, aktuell, offen und Fehler in Worten.

Eine von 6 Components in Navigation

2 Variants

Variants

Waagerecht

Zahlen oder Icons auf einer Spur, die sich füllt. Telefone bekommen den Schrittnamen und einen geteilten Balken.

Senkrecht

Platz für einen Satz je Schritt, für Onboarding und Sendungsverfolgung. Ein Fehler bleibt sichtbar, bis er behoben ist.

Details

Was Schrittanzeige mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Navigation
Bereich
Elements
Variants
2
Version
1.0.1 · seit 1.0.0
Datei
components/ui/stepper.tsx
Bringt mit
utilskiticon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 7 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle StepperProps aus components/ui/stepper.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • stepsPflicht
    Step[]
  • currentPflicht
    number

    Index of the current step, 0-based. Steps before it count as done.

  • orientationoptional
    'horizontal' | 'vertical'
  • markersoptional
    'numbers' | 'icons'

    numbers or icons in the markers

  • compactOnMobileoptional
    boolean

    On phones a horizontal stepper becomes "Step 2 of 4" with a bar. Default true.

Eingebaute Texte

Diese Texte schreibt Schrittanzeige selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 7 Texte anzeigen
labelEnglisch ProgressDeutsch Fortschritt
stepEnglisch StepDeutsch Schritt
ofEnglisch ofDeutsch von
doneEnglisch completedDeutsch erledigt
currentEnglisch current stepDeutsch aktueller Schritt
errorEnglisch needs attentionDeutsch braucht Aufmerksamkeit
upcomingEnglisch not startedDeutsch noch offen

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-current und aria-label, damit Screenreader Zweck und Zustand ansagen.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 8 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 197 Zeilen
components/ui/stepper.tsx
'use client';

import { useRef } from 'react';
import { cn, round, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';

const TEXTS = {
  en: { label: 'Progress', step: 'Step', of: 'of', done: 'completed', current: 'current step', error: 'needs attention', upcoming: 'not started' },
  de: { label: 'Fortschritt', step: 'Schritt', of: 'von', done: 'erledigt', current: 'aktueller Schritt', error: 'braucht Aufmerksamkeit', upcoming: 'noch offen' },
};

/* The current step breathes a quiet ring, so the eye finds it */
const STYLES =
  '.kitfx-step-ping{animation:kitfx-step-ping 2.6s var(--ease-kit) infinite}@keyframes kitfx-step-ping{0%{opacity:.55;transform:scale(1)}70%,100%{opacity:0;transform:scale(1.6)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-step-ping{animation:none;opacity:0}}';

/** How long the track takes to fill, the markers light up along the way */
const FILL_MS = 900;

export interface Step {
  title: string;
  description?: React.ReactNode;
  icon?: IconName;
  /** Marks a step that failed or needs input */
  error?: boolean;
  href?: string;
}

export interface StepperProps {
  steps: Step[];
  /** Index of the current step, 0-based. Steps before it count as done. */
  current: number;
  orientation?: 'horizontal' | 'vertical';
  /** numbers or icons in the markers */
  markers?: 'numbers' | 'icons';
  /** On phones a horizontal stepper becomes "Step 2 of 4" with a bar. Default true. */
  compactOnMobile?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

type State = 'done' | 'current' | 'upcoming' | 'error';

function Marker({ state, index, icon, markers, lit, delay }: { state: State; index: number; icon?: IconName; markers: 'numbers' | 'icons'; lit: boolean; delay: number }) {
  // Before the track reaches it, every marker waits in the upcoming look
  const shown: State = lit ? state : state === 'error' ? 'error' : 'upcoming';
  return (
    <span
      aria-hidden
      className={cn(
        'relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[13px] font-semibold tabular-nums transition-[background-color,border-color,color,box-shadow,scale] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
        shown === 'done' && 'border border-transparent bg-primary text-primary-foreground',
        shown === 'current' && 'scale-105 border-[1.5px] border-primary bg-background text-foreground shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)]',
        shown === 'upcoming' && 'border border-border-strong bg-background text-subtle-foreground',
        shown === 'error' && 'border-[1.5px] border-destructive bg-background text-destructive shadow-[0_0_0_4px_color-mix(in_oklab,var(--destructive)_12%,transparent)]',
      )}
      style={{ transitionDelay: `${delay}ms` }}
    >
      {state === 'current' && lit && <span className="kitfx-step-ping pointer-events-none absolute inset-0 rounded-full border border-primary" style={{ animationDelay: `${delay + 400}ms` }} />}
      {state === 'done' ? (
        <span className="relative grid place-items-center">
          <span className={cn('transition-[opacity,scale] duration-300 motion-reduce:transition-none', lit ? 'scale-50 opacity-0' : 'opacity-100')} style={{ transitionDelay: `${delay}ms` }}>
            {index + 1}
          </span>
          <Icon name="check" size={15} strokeWidth={2.2} className={cn('absolute transition-[opacity,scale] duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', lit ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} style={{ transitionDelay: `${delay + 80}ms` }} />
        </span>
      ) : state === 'error' ? (
        <Icon name="alert-circle" size={16} />
      ) : markers === 'icons' && icon ? (
        <Icon name={icon} size={15} />
      ) : (
        index + 1
      )}
    </span>
  );
}

/**
 * The steps of a process: checkout, onboarding, application. Done, current, upcoming and error,
 * each said in words to screen readers, not only shown with color. When it scrolls into view the
 * track fills up and the markers light up one after another.
 *   <Stepper current={1} steps={[{ title: 'Cart' }, { title: 'Address' }, { title: 'Payment' }]} />
 */
export function Stepper({ steps, current, orientation = 'horizontal', markers = 'numbers', compactOnMobile = true, labels, className }: StepperProps) {
  const t = useTexts(TEXTS, labels);
  const state = (i: number, s: Step) => (s.error ? 'error' : i < current ? 'done' : i === current ? 'current' : 'upcoming') as State;
  const words = { done: t.done, current: t.current, upcoming: t.upcoming, error: t.error };
  const [ref, inView] = useInView<HTMLElement>('0px 0px -10% 0px');
  const waited = useRef(false);
  if (!inView) waited.current = true;
  // Only steps that were waiting below the fold play the fill, everything else stands finished
  const play = inView && waited.current;
  const gaps = Math.max(1, Math.min(current, steps.length - 1));
  const at = (i: number) => (play ? round((Math.min(i, current) / gaps) * FILL_MS) : 0);

  const title = (s: Step, st: State) => {
    const inner = (
      <>
        <span className={cn('block text-sm font-medium leading-5', st === 'upcoming' ? 'text-muted-foreground' : st === 'error' ? 'text-destructive' : 'text-foreground')}>{s.title}</span>
        <span className="sr-only">, {words[st]}</span>
      </>
    );
    return s.href && st === 'done' ? (
      <a href={s.href} className="rounded-sm hover:underline hover:underline-offset-4">
        {inner}
      </a>
    ) : (
      inner
    );
  };

  if (orientation === 'vertical') {
    const per = round(FILL_MS / Math.max(1, current));
    return (
      <nav ref={ref} aria-label={t.label} className={className}>
        <style href="kitfx-step" precedence="default">
          {STYLES}
        </style>
        <ol className="grid">
          {steps.map((s, i) => {
            const st = state(i, s);
            const last = i === steps.length - 1;
            return (
              <li key={s.title} aria-current={st === 'current' ? 'step' : undefined} className="relative flex gap-4 pb-8 last:pb-0">
                {!last && (
                  <span aria-hidden className="absolute start-4 top-9 bottom-1 w-px -translate-x-1/2 overflow-hidden rounded-full bg-border rtl:translate-x-1/2">
                    <span
                      className={cn('block h-full w-full origin-top bg-primary transition-transform ease-linear motion-reduce:transition-none', i < current && inView ? 'scale-y-100' : 'scale-y-0')}
                      style={{ transitionDuration: `${per}ms`, transitionDelay: `${play ? i * per : 0}ms` }}
                    />
                  </span>
                )}
                <Marker state={st} index={i} icon={s.icon} markers={markers} lit={inView} delay={play ? Math.min(i, current) * per : 0} />
                <div className={cn('min-w-0 pt-1.5 transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-x-2 opacity-0')} style={{ transitionDelay: `${play ? i * 70 : 0}ms` }}>
                  {title(s, st)}
                  {s.description && <div className="mt-1 text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.description}</div>}
                </div>
              </li>
            );
          })}
        </ol>
      </nav>
    );
  }

  const share = steps.length > 1 ? current / (steps.length - 1) : 1;
  return (
    <nav ref={ref} aria-label={t.label} className={cn('w-full', className)}>
      <style href="kitfx-step" precedence="default">
        {STYLES}
      </style>
      {compactOnMobile && (
        <div className="sm:hidden">
          <div className="flex items-baseline justify-between gap-3 text-sm">
            <span className="font-medium text-foreground">{steps[current]?.title}</span>
            <span className="tabular-nums text-muted-foreground">
              {t.step} {Math.min(current + 1, steps.length)} {t.of} {steps.length}
            </span>
          </div>
          <div className="mt-2.5 grid gap-1" style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }} aria-hidden>
            {steps.map((s, i) => (
              <span key={s.title} className="h-1 overflow-hidden rounded-full bg-track">
                {(s.error || i <= current) && (
                  <span
                    className={cn('block h-full w-full origin-left rounded-full transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', s.error ? 'bg-destructive' : 'bg-primary', inView ? 'scale-x-100' : 'scale-x-0')}
                    style={{ transitionDelay: `${play ? i * 140 : 0}ms` }}
                  />
                )}
              </span>
            ))}
          </div>
        </div>
      )}
      <ol data-clip-ok={compactOnMobile || undefined} className={cn('relative grid', compactOnMobile && 'max-sm:sr-only')} style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}>
        {/* One track behind the markers, from the first center to the last center */}
        <span aria-hidden className="absolute top-4 h-px -translate-y-1/2 bg-border" style={{ left: `calc(50% / ${steps.length})`, right: `calc(50% / ${steps.length})` }}>
          <span
            className="block h-full w-full origin-left bg-primary transition-transform ease-[cubic-bezier(0.45,0,0.25,1)] motion-reduce:transition-none rtl:origin-right"
            style={{ transform: `scaleX(${inView ? round(share, 4) : 0})`, transitionDuration: `${play ? FILL_MS : 500}ms` }}
          />
        </span>
        {steps.map((s, i) => {
          const st = state(i, s);
          return (
            <li key={s.title} aria-current={st === 'current' ? 'step' : undefined} className="flex min-w-0 flex-col items-center px-2 text-center">
              <Marker state={st} index={i} icon={s.icon} markers={markers} lit={inView} delay={at(i)} />
              <div className={cn('mt-3 min-w-0 transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-y-1.5 opacity-0')} style={{ transitionDelay: `${play ? at(i) : 0}ms` }}>
                {title(s, st)}
                {s.description && <div className="mt-0.5 text-pretty text-xs text-muted-foreground">{s.description}</div>}
              </div>
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

Häufige Fragen

Wie installiere ich Schrittanzeige?

Mit aiw add @ai-website/stepper. Der Befehl legt die Datei components/ui/stepper.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Schrittanzeige?

2 Varianten, Waagerecht und Senkrecht.

Kann ich die Texte von Schrittanzeige übersetzen?

Ja. Schrittanzeige bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „Fortschritt“, „Schritt“ und „von“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Schrittanzeige einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Schrittanzeige mit aiw_components und installieren sie mit aiw_add @ai-website/stepper.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert