v1.0

FunnelChart

Preview

Basic

Loading…

Preview

Code

ts
import FunnelChart from '@/components/data/FunnelChart';

src/components/data/FunnelChart.tsx

AI prompt

text
Build a conversion funnel chart component in React + TypeScript + Tailwind CSS (plain divs, no chart library): ordered stages, each a subset of the one before, and where people drop out.

## Look
- Frame: a panel card (`p-5`) with a header holding the title as a 10px uppercase section title plus an ⓘ hint tooltip, and at the right a two-icon chart / table toggle (lucide `BarChart3` / `Table2`).
- An `<ol>` with `space-y-2`. Each stage row is a grid `[7.5rem_minmax(0,1fr)]` with `gap-3`: the stage label right-aligned and truncating (text-xs slate-600 / dark slate-300), then the bar followed by its annotation.
- Bar: 24px tall, square on the left, `rounded-r-[4px]`, `px-2`. Width = value ÷ FIRST stage × 100%, never under 1.5%, so the shrinking reads as loss.
- Colour: one hue stepped along an ordinal ramp, because the stages are ordered, not unrelated groups. Light, first → last: #104281, #1c5cab, #2a78d6, #5598e7, #86b6ef. Dark: #cde2fb, #9ec5f4, #6da7ec, #3987e5, #256abf. Spread the ramp over however many stages there are (stage i takes step `round(i / (n − 1) × 4)`; a single stage takes the middle step).
- Value label: inside the bar when it is wider than ~34% — 11px semibold tabular-nums, white or slate-900 chosen by the fill's relative luminance (> 0.4 → dark ink), since the ramp runs the other way in dark mode. A shorter bar carries its value outside, right after the bar, in semibold slate-800 / dark slate-100 — never clipped.
- After the bar, from the second stage on: "42.6% of previous" in 11px tabular-nums slate-500.
- Summary line under the list (`mt-3`, 11px slate-500): "**2.5%** of visited pricing reach paid." — the percentage semibold slate-800 / dark slate-100, the first and last labels lower-cased.

## Behaviour
- Percentages to one decimal ("—" when dividing by 0). Each row's `title` is "Paid: 1.2K (2.5% of Visited pricing)".
- Table view columns: Stage, Count, From previous, From top (the first row's "From previous" is "—").
- A first stage of 0 → the 240px "No data for the selected period." placeholder. Default format: compact number.

## API
`title`, `hint?`, `stages: { label: string; value: number }[]`, `format?: (v: number) => string`, `className?`.

## Demo
"Trial onboarding" in a `max-w-2xl` wrapper: Visited pricing 48,200 → Started trial 9,640 → Invited a teammate 4,110 → Connected data 2,380 → Paid 1,190.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import ChartCard from './ChartCard';
import { compactNumber, useChartPalette } from './chartTheme';

export type FunnelStage = { label: string; value: number };

/** Relative luminance of a `#rrggbb` fill, to choose ink that clears it. */
function luminance(hex: string): number {
  const [r, g, b] = [1, 3, 5].map((i) => {
    const c = parseInt(hex.slice(i, i + 2), 16) / 255;
    return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
  });
  return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}

/**
 * A sequence of stages each a subset of the one before — visits, sign-ups,
 * activations — and where people drop out between them.
 *
 * Each stage is a bar scaled to the FIRST stage, so the shrinking reads as
 * loss. The stages are ORDERED, so they take one hue stepped light to dark
 * (an ordinal ramp, validated so even the palest step clears the surface),
 * not categorical colours that would imply unrelated groups. Every bar is
 * labelled with its value, its conversion from the stage before and from
 * the top — which is the actual question a funnel answers. A bar too short
 * for its label carries it outside, never clipped.
 */
export default function FunnelChart({
  title,
  hint,
  stages,
  format = compactNumber,
  className,
}: {
  title: string;
  hint?: string;
  stages: FunnelStage[];
  format?: (v: number) => string;
  className?: string;
}) {
  const p = useChartPalette();
  const top = stages[0]?.value ?? 0;
  const pct = (v: number, of: number) => (of ? `${((v / of) * 100).toFixed(1)}%` : '—');
  // Spread the ramp across however many stages there are.
  const colorOf = (i: number) => p.ordinal[stages.length <= 1 ? 2 : Math.round((i / (stages.length - 1)) * (p.ordinal.length - 1))];

  return (
    <ChartCard
      title={title}
      hint={hint}
      className={className}
      empty={top === 0}
      table={{
        columns: [
          { key: 'label', label: 'Stage' },
          { key: 'value', label: 'Count', align: 'right', format: (v) => format(Number(v)) },
          { key: 'step', label: 'From previous', align: 'right' },
          { key: 'overall', label: 'From top', align: 'right' },
        ],
        rows: stages.map((s, i) => ({ ...s, step: i === 0 ? '—' : pct(s.value, stages[i - 1].value), overall: pct(s.value, top) })),
      }}
    >
      <ol className="space-y-2">
        {stages.map((s, i) => {
          const width = top ? Math.max(1.5, (s.value / top) * 100) : 0;
          const inside = width > 34;
          return (
            <li key={s.label} className="grid grid-cols-[7.5rem_minmax(0,1fr)] items-center gap-3" title={`${s.label}: ${format(s.value)} (${pct(s.value, top)} of ${stages[0].label})`}>
              <span className="truncate text-right text-xs text-slate-600 dark:text-slate-300">{s.label}</span>
              <div className="flex items-center gap-2">
                <div
                  className="flex h-6 items-center rounded-r-[4px] px-2"
                  style={{ width: `${width}%`, background: colorOf(i) }}
                >
                  {inside && (
                    // A label inside a fill picks white or ink by the fill's
                    // own luminance — the ramp runs the other way in dark mode.
                    <span className={`text-[11px] font-semibold tabular-nums ${luminance(colorOf(i)) > 0.4 ? 'text-slate-900' : 'text-white'}`}>
                      {format(s.value)}
                    </span>
                  )}
                </div>
                <span className="shrink-0 text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
                  {!inside && <span className="mr-1.5 font-semibold text-slate-800 dark:text-slate-100">{format(s.value)}</span>}
                  {i > 0 && <>{pct(s.value, stages[i - 1].value)} of previous</>}
                </span>
              </div>
            </li>
          );
        })}
      </ol>
      {stages.length > 1 && (
        <p className="mt-3 text-[11px] text-slate-500 dark:text-slate-400">
          <span className="font-semibold text-slate-800 dark:text-slate-100">{pct(stages[stages.length - 1].value, top)}</span> of{' '}
          {stages[0].label.toLowerCase()} reach {stages[stages.length - 1].label.toLowerCase()}.
        </p>
      )}
    </ChartCard>
  );
}

Props

PropTypeDefaultDescription
title*string—
stages*FunnelStage[]—
hintstring—
format(v: number) => stringcompactNumber
classNamestring—