Skip to content
UI Kit
Accent color

Funnel

Steps from first visit to the goal, with the share of the start and the conversion between every pair of steps.

One of 7 components in Charts

2 variants

Variants

Horizontal steps

Steps side by side, the flow between them as a light band, the rate on top. On phones it becomes rows.

Rows

One row per step with a bar against the start, the rate and the drop off between rows.

Details

What Funnel brings along, read from the registry and the source.

Category
Charts
Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/funnel.tsx
Brings along
utilskitcharticon
npm packages
None
Languages
English and German, 8 texts of its own
Studio
Adjust in code

Props

The FunnelProps interface from components/ui/funnel.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'labels'>.

  • stepsrequired
    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

Built-in texts

Funnel writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 8 texts
ofStartEnglish of startGerman vom Start
valueEnglish ValueGerman Wert
conversionEnglish From the step beforeGerman Vom Schritt davor
droppedEnglish Dropped offGerman Abgesprungen
shareEnglish Share of startGerman Anteil am Start
stepEnglish StepGerman Schritt
hintEnglish Use the arrow keys to step through the funnel.German Mit den Pfeiltasten durch den Trichter gehen.
chartEnglish funnelGerman Trichter

Accessibility

Read from the source of the component.

  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 194 lines
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>
  );
}

Frequent questions

How do I install Funnel?

With aiw add @ai-website/funnel. The command puts components/ui/funnel.tsx into your project, the code is yours from then on. It brings along chart and icon. It needs no further npm packages.

Which variants does Funnel have?

2 variants, Horizontal steps and Rows.

Can I translate the texts of Funnel?

Yes. Funnel carries 8 texts of its own in English and German, such as “of start”, “Value” and “From the step before”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Funnel?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Funnel with aiw_components and install it with aiw_add @ai-website/funnel. Name the variant in your prompt, for example variant="bars", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard