Zum Inhalt springen
UI Kit
Accent

Trichter

Schritte vom ersten Besuch bis zum Ziel, mit dem Anteil am Start und dem Übergang zwischen je zwei Schritten.

Eine von 7 Components in Charts

2 Variants

Variants

Schritte nebeneinander

Schritte nebeneinander, der Fluss dazwischen als helles Band, die Rate obenauf. Auf dem Telefon werden daraus Zeilen.

Zeilen

Eine Zeile je Schritt mit Balken gegen den Start, dazwischen Rate und Absprung.

Details

Was Trichter mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Charts
Bereich
Elements
Variants
2
Version
1.0.0 · seit 1.0.0
Datei
components/ui/funnel.tsx
Bringt mit
utilskitcharticon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 8 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle FunnelProps aus components/ui/funnel.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'labels'> an.

  • stepsPflicht
    FunnelStep[]

    The steps in order, the first is the start (100 %)

  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { maximumFractionDigits: 0 }

  • variantoptional
    'columns' | 'bars'

    columns: steps side by side with the rates between them. bars: one row per step. Columns fall back to bars below 560 px.

  • heightoptional
    number

    columns: height of the bars in px

Eingebaute Texte

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

Alle 8 Texte anzeigen
ofStartEnglisch of startDeutsch vom Start
valueEnglisch ValueDeutsch Wert
conversionEnglisch From the step beforeDeutsch Vom Schritt davor
droppedEnglisch Dropped offDeutsch Abgesprungen
shareEnglisch Share of startDeutsch Anteil am Start
stepEnglisch StepDeutsch Schritt
hintEnglisch Use the arrow keys to step through the funnel.Deutsch Mit den Pfeiltasten durch den Trichter gehen.
chartEnglisch funnelDeutsch Trichter

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

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

Datei anzeigen, 194 Zeilen
components/ui/funnel.tsx
'use client';

import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, ChartTooltip, CountUp, barPath, numberFormatter, useActive, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y } from '@/components/ui/chart';
import { Icon } from '@/components/ui/icon';

export interface FunnelStep {
  label: string;
  value: number;
}

const TEXTS = {
  en: {
    ofStart: 'of start',
    value: 'Value',
    conversion: 'From the step before',
    dropped: 'Dropped off',
    share: 'Share of start',
    step: 'Step',
    hint: 'Use the arrow keys to step through the funnel.',
    chart: 'funnel',
  },
  de: {
    ofStart: 'vom Start',
    value: 'Wert',
    conversion: 'Vom Schritt davor',
    dropped: 'Abgesprungen',
    share: 'Anteil am Start',
    step: 'Schritt',
    hint: 'Mit den Pfeiltasten durch den Trichter gehen.',
    chart: 'Trichter',
  },
};

export interface FunnelProps extends Omit<ChartA11y, 'labels'> {
  /** The steps in order, the first is the start (100 %) */
  steps: FunnelStep[];
  /** Value format, e.g. { maximumFractionDigits: 0 } */
  format?: Intl.NumberFormatOptions;
  /** columns: steps side by side with the rates between them. bars: one row per step. Columns fall back to bars below 560 px. */
  variant?: 'columns' | 'bars';
  /** columns: height of the bars in px */
  height?: number;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

/**
 * Steps from first touch to the goal, with the conversion between each pair.
 *   <Funnel label="Shop funnel" steps={[{ label: 'Visits', value: 21904 }, { label: 'Orders', value: 832 }]} />
 */
export function Funnel({ steps, format, variant = 'columns', height = 200, label, summary, labels, animate = true, className }: FunnelProps) {
  const intl = useIntlLocale();
  const t = useTexts(TEXTS, labels);
  const fmt = numberFormatter(intl, format);
  const pct = numberFormatter(intl, { style: 'percent', minimumFractionDigits: 1, maximumFractionDigits: 1 });
  const [wrapRef, width] = useWidth<HTMLDivElement>(720);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const n = steps.length;
  const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(n);
  const { shown, open, jump } = useShown(active);
  // The steps fill one after another, each connector follows its step
  const STEP = 150;
  const start = steps[0]?.value || 1;
  const share = (i: number) => steps[i].value / start;
  const conv = (i: number) => (i > 0 && steps[i - 1].value ? steps[i].value / steps[i - 1].value : 1);
  const columns = variant === 'columns' && width >= 560;

  const live =
    active !== null
      ? [steps[active].label, fmt(steps[active].value), `${pct(share(active))} ${t.ofStart}`, active > 0 ? `${t.conversion} ${pct(conv(active))}` : ''].filter(Boolean).join(', ')
      : '';
  const auto = steps.map((s, i) => `${s.label} ${fmt(s.value)}${i ? ` (${pct(conv(i))})` : ''}`).join(', ');
  const rows = (i: number) => [
    { key: 'v', label: t.value, value: fmt(steps[i].value) },
    { key: 's', label: t.share, value: pct(share(i)) },
    ...(i > 0
      ? [
          { key: 'c', label: t.conversion, value: pct(conv(i)) },
          { key: 'd', label: t.dropped, value: fmt(steps[i - 1].value - steps[i].value) },
        ]
      : []),
  ];

  const colW = width / Math.max(1, n);
  const barW = round(Math.min(colW * 0.46, 96));
  const plotTop = 8;
  const bx = (i: number) => round(colW * i + (colW - barW) / 2);
  const by = (i: number) => round(plotTop + (1 - share(i)) * (height - plotTop));

  return (
    <ChartRoot anim={anim} rootRef={rootRef} className={className}>
      <div ref={wrapRef}>
        <ChartSurface
          label={label}
          summary={summary ?? auto}
          labels={{ hint: t.hint, chart: t.chart }}
          live={live}
          table={<ChartTable caption={label} head={[t.step, label, t.share, t.conversion]} rows={steps.map((s, i) => [s.label, fmt(s.value), pct(share(i)), i ? pct(conv(i)) : pct(1)])} />}
          onKeyDown={onKeyDown}
          onFocus={onFocus}
          onBlur={onBlur}
          onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
        >
          {columns ? (
            <div>
              <div className="grid" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
                {steps.map((s, i) => (
                  <div key={s.label} className={cn('flex flex-col px-2 text-center transition-opacity duration-200 motion-reduce:transition-none', active !== null && active !== i && 'opacity-50')} onPointerEnter={() => setActive(i)}>
                    <p className="flex flex-1 items-end justify-center text-balance text-[12px] leading-snug text-muted-foreground">{s.label}</p>
                    <p className="mt-1 text-[19px] font-semibold tracking-[-0.01em] text-foreground">
                      <CountUp value={s.value} format={fmt} anim={anim} delay={i * STEP} duration={800} />
                    </p>
                    <p className="text-[12px] tabular-nums text-subtle-foreground">{pct(share(i))}</p>
                  </div>
                ))}
              </div>
              <div className="relative mt-5">
                <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet" aria-hidden className="block">
                  {steps.map((s, i) => (
                    <g key={s.label} onPointerEnter={() => setActive(i)} onPointerDown={() => setActive(i)} opacity={active !== null && active !== i ? 0.5 : 1} className="transition-opacity duration-200 motion-reduce:transition-none">
                      {/* hit area: the whole column */}
                      <rect x={round(colW * i)} y={0} width={round(colW)} height={height} fill="transparent" />
                      <path d={barPath(bx(i), plotTop, barW, height - plotTop, 6, 'up')} fill="var(--kitch-empty)" />
                      {i < n - 1 && (
                        <path
                          d={`M${bx(i) + barW + 2} ${by(i)} L${bx(i + 1) - 2} ${by(i + 1)} L${bx(i + 1) - 2} ${height} L${bx(i) + barW + 2} ${height} Z`}
                          fill="var(--primary)"
                          fillOpacity="0.09"
                          className={anim ? 'kitch-clip' : undefined}
                          style={anim ? ({ '--kitch-d': `${260 + i * STEP}ms` } as React.CSSProperties) : undefined}
                        />
                      )}
                      <path
                        d={barPath(bx(i), by(i), barW, height - by(i), 6, 'up')}
                        fill="var(--kitch-1)"
                        className={anim ? 'kitch-grow-y' : undefined}
                        style={anim ? ({ '--kitch-d': `${i * STEP}ms` } as React.CSSProperties) : undefined}
                      />
                    </g>
                  ))}
                </svg>
                {/* Conversion between two steps, on the connector */}
                {steps.slice(1).map((s, k) => {
                  const i = k + 1;
                  const x = round(colW * i);
                  return (
                    <span
                      key={s.label}
                      aria-hidden
                      className={cn('pointer-events-none absolute flex -translate-x-1/2 items-center gap-0.5 whitespace-nowrap rounded-full border border-border bg-card px-2 py-0.5 text-[11px] font-medium tabular-nums text-foreground shadow-[var(--shadow-sm)]', anim && 'kitch-pop')}
                      style={{ left: x, top: Math.max(0, Math.min(height - 24, round((by(i - 1) + by(i)) / 2) - 26)), ...(anim ? ({ '--kitch-d': `${520 + k * STEP}ms` } as React.CSSProperties) : {}) }}
                    >
                      <Icon name="arrow-right" size={11} strokeWidth={2} className="text-muted-foreground" />
                      {pct(conv(i))}
                    </span>
                  );
                })}
                {shown !== null && shown < n && <ChartTooltip open={open} jump={jump} x={round(bx(shown) + barW / 2)} y={plotTop} width={width} offset={barW / 2 + 10} title={steps[shown].label} rows={rows(shown)} />}
              </div>
            </div>
          ) : (
            <ol className="grid gap-1">
              {steps.map((s, i) => (
                <li key={s.label} onPointerEnter={() => setActive(i)} onPointerDown={() => setActive(i)} className={cn('transition-opacity duration-200 motion-reduce:transition-none', active !== null && active !== i && 'opacity-50')}>
                  {i > 0 && (
                    <p className="flex items-center gap-1.5 py-1.5 ps-1 text-[12px] tabular-nums text-muted-foreground">
                      <Icon name="arrow-down" size={12} strokeWidth={2} />
                      <span className="font-medium text-foreground">{pct(conv(i))}</span>
                      <span>· {t.dropped} {fmt(steps[i - 1].value - s.value)}</span>
                    </p>
                  )}
                  <div className="flex items-baseline justify-between gap-4">
                    <span className="truncate text-[13px] text-foreground">{s.label}</span>
                    <span className="shrink-0 text-[13px] tabular-nums">
                      <CountUp value={s.value} format={fmt} anim={anim} delay={i * STEP} duration={800} align="end" className="font-semibold text-foreground" />
                      <span className="ms-2 inline-block w-14 text-right text-subtle-foreground">{pct(share(i))}</span>
                    </span>
                  </div>
                  <div className="mt-2 h-2.5 overflow-hidden rounded-full" style={{ background: 'var(--kitch-empty)' }}>
                    <div
                      className={cn('h-full rounded-full', anim && 'kitch-grow-x')}
                      style={{ width: `${round(Math.max(share(i) * 100, 0.6), 2)}%`, background: 'var(--kitch-1)', ...(anim ? ({ '--kitch-d': `${i * STEP}ms`, transformBox: 'border-box' } as React.CSSProperties) : {}) }}
                    />
                  </div>
                </li>
              ))}
            </ol>
          )}
        </ChartSurface>
      </div>
    </ChartRoot>
  );
}

Häufige Fragen

Wie installiere ich Trichter?

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

Welche Varianten hat Trichter?

2 Varianten, Schritte nebeneinander und Zeilen.

Kann ich die Texte von Trichter übersetzen?

Ja. Trichter bringt 8 eigene Texte auf Deutsch und Englisch mit, etwa „vom Start“, „Wert“ und „Vom Schritt davor“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Trichter einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Trichter mit aiw_components und installieren sie mit aiw_add @ai-website/funnel. Nenn im Prompt die Variante, etwa variant="bars", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert