Funnel
NewInstallation
npx shadcn@latest add https://uibrik.com/r/funnel.jsonInstall the packages
npm install @number-flow/react @tabler/icons-react motion react-use-measureAdd the cn helper
Imports
cnfrom@/lib/utils, whichshadcn initcreates. Add it by hand without shadcn.Copy the source into your project
components/uibrik/funnel/funnel.tsx"use client"; import { useMemo, type KeyboardEvent, type PointerEvent } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { Announce, ChartCard, DataTable, Headline, Tooltip, TooltipTitle, useChartCursor, useWidth, } from "../_charts/parts"; import { barPath, linear } from "../_charts/scale"; import { accentVars, BAR_RADIUS, DRAW_IN, EASE_OUT, seriesColor, tint, TRACK, } from "../_charts/theme"; import { formatCompact, formatShare, formatValue, type ValueFormat, } from "../_charts/format"; export type FunnelStep = { label: string; value: number }; const SAMPLE: FunnelStep[] = [ { label: "Visited site", value: 4012 }, { label: "Viewed pricing", value: 2004 }, { label: "Started trial", value: 1240 }, { label: "Added payment", value: 716 }, { label: "Subscribed", value: 482 }, ]; // ─── Geometry ───────────────────────────────────────────────────────────────── // Columns side by side when there's room, rows stacked when there isn't. const NARROW = 440; // Columns: headroom above the tallest bar for its count, the bars, then names below. const COL_TOP = 40; const COL_PLOT = 220; const COL_NAMES = 40; const COL_BAR_MAX = 64; // Rows: a name column, the bars, then the numbers. const ROW = 52; const ROW_BAR = 24; const ROW_LABEL = 92; const ROW_NUMBERS = 52; const ROW_GAP = 12; // Hover reach past a bar's edges, so the gap between bars stays its own target. const REACH = 8; const STAGGER = 0.06; type Box = { x: number; y: number; w: number; h: number }; type Layout = { vertical: boolean; height: number; bars: Box[]; tracks: Box[]; bands: string[]; /** Bar and gap anchors for the tooltip, alternating: bar 0, gap 0, bar 1… */ anchors: [number, number][]; /** Where each bar's hover reach starts and ends along the stepping axis. */ spans: [number, number][]; }; function layout(steps: FunnelStep[], width: number): Layout { const n = steps.length; const max = Math.max(1, ...steps.map((s) => s.value)); const first = steps[0]?.value ?? 0; const anchors: [number, number][] = []; if (width >= NARROW) { const slot = width / n; const w = Math.min(COL_BAR_MAX, slot * 0.56); const y = linear([0, max], [COL_PLOT, COL_TOP]); const bars = steps.map((s, i) => ({ x: slot * i + (slot - w) / 2, y: y(s.value), w, h: COL_PLOT - y(s.value), })); const tracks = bars.map((b) => ({ ...b, y: y(first), h: COL_PLOT - y(first), })); // Each band runs from the top of one bar to the top of the next, down to the baseline. const bands = bars.slice(0, -1).map((a, i) => { const b = bars[i + 1]; return `M${a.x + a.w},${a.y}L${b.x},${b.y}L${b.x},${COL_PLOT}L${a.x + a.w},${COL_PLOT}Z`; }); bars.forEach((b, i) => { anchors.push([b.x + b.w / 2, Math.max(b.y - 4, 48)]); const c = bars[i + 1]; if (c) anchors.push([ (b.x + b.w + c.x) / 2, Math.max((b.y + c.y) / 2 - 4, 48), ]); }); return { vertical: true, height: COL_PLOT + COL_NAMES, bars, tracks, bands, anchors, spans: bars.map((b) => [b.x - REACH, b.x + b.w + REACH]), }; } const x0 = ROW_LABEL + ROW_GAP; const span = Math.max(1, width - ROW_LABEL - ROW_NUMBERS - ROW_GAP * 2); const len = (v: number) => (v / max) * span; const bars = steps.map((s, i) => ({ x: x0, y: i * ROW + (ROW - ROW_BAR) / 2, w: len(s.value), h: ROW_BAR, })); const tracks = bars.map((b) => ({ ...b, w: len(first) })); // Each band runs from the bottom of one bar to the top of the next. const bands = bars.slice(0, -1).map((a, i) => { const b = bars[i + 1]; return `M${x0},${a.y + a.h}L${x0 + a.w},${a.y + a.h}L${x0 + b.w},${b.y}L${x0},${b.y}Z`; }); bars.forEach((b, i) => { anchors.push([x0 + b.w / 2, b.y - 4]); const c = bars[i + 1]; if (c) anchors.push([x0 + (b.w + c.w) / 4, (b.y + b.h + c.y) / 2 - 2]); }); return { vertical: false, height: n * ROW, bars, tracks, bands, anchors, spans: bars.map((b) => [b.y - 6, b.y + b.h + 6]), }; } /** Target under `pos` along the stepping axis: bars are even, the gaps between them odd. */ function hit(spans: [number, number][], pos: number) { for (let i = 0; i < spans.length; i++) { if (pos < spans[i][0]) return Math.max(0, 2 * i - 1); if (pos <= spans[i][1]) return 2 * i; } return Math.max(0, 2 * spans.length - 2); } export type FunnelProps = { /** Names the funnel. */ title?: string; /** One entry per step, in order. The first step is everyone who entered. */ steps?: FunnelStep[]; /** Intl.NumberFormat options for the counts. */ format?: ValueFormat; /** Bar colour. Empty uses the chart palette's first colour. */ accent?: string; /** Called when a step is clicked, or picked with Enter. */ onSelect?: (step: FunnelStep, index: number) => void; className?: string; }; export function Funnel({ title = "Trial conversion", steps = SAMPLE, format = {}, accent = "", onSelect, className, }: FunnelProps) { const reduce = useReducedMotion(); const [ref, width] = useWidth(); const n = steps.length; // Bars and the gaps between them take turns, so arrows walk the whole flow. const cursor = useChartCursor(Math.max(0, 2 * n - 1)); const { active } = cursor; const geo = useMemo( () => (width && n ? layout(steps, width) : null), [steps, width, n], ); const vertical = geo ? geo.vertical : true; const height = geo?.height ?? COL_PLOT + COL_NAMES; const first = steps[0]?.value ?? 0; const last = steps[n - 1]?.value ?? 0; const share = (v: number) => (first ? v / first : 0); const fmt = (v: number) => formatValue(v, format); const color = seriesColor(0); const bar = active !== null && active % 2 === 0 ? active / 2 : null; const gap = active !== null && active % 2 === 1 ? (active - 1) / 2 : null; const anchor = active !== null ? geo?.anchors[active] : undefined; const onPointerMove = (e: PointerEvent<HTMLDivElement>) => { if (!geo) return; const r = ( e.currentTarget.firstElementChild ?? e.currentTarget ).getBoundingClientRect(); cursor.point( hit(geo.spans, geo.vertical ? e.clientX - r.left : e.clientY - r.top), ); }; const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { if ((e.key === "Enter" || e.key === " ") && bar !== null) { e.preventDefault(); onSelect?.(steps[bar], bar); return; } cursor.bind.onKeyDown(e); }; const dropText = (i: number) => { const a = steps[i].value; const b = steps[i + 1].value; return { count: `−${fmt(Math.max(0, a - b))}`, share: formatShare(a ? Math.max(0, a - b) / a : 0), }; }; const stepText = (i: number) => { const s = steps[i]; const prev = steps[i - 1]?.value; return { count: fmt(s.value), ofFirst: `${formatShare(share(s.value))} of total`, ofPrev: prev ? `${formatShare(s.value / prev)} from previous step` : null, }; }; // Redraw from scratch when the data or the layout changes, not on resize. const drawKey = `${vertical ? "v" : "h"}-${steps.map((s) => s.value).join(",")}`; const grow = (i: number) => reduce ? { duration: 0 } : { duration: DRAW_IN, ease: EASE_OUT, delay: i * STAGGER }; const bandsIn = reduce ? 0 : n * STAGGER + DRAW_IN * 0.6; return ( <ChartCard title={title} value={ <Headline value={share(last)} format={{ style: "percent", maximumFractionDigits: 1 }} /> } meta={ <span className="text-[13px] text-muted-foreground tabular-nums"> {fmt(last)} of {fmt(first)} </span> } padded={false} className={cn("max-w-160", className)} style={accentVars(accent)} > <div role="group" aria-label={`${title}, ${n} steps. Arrow keys move between steps and the drop-off between them.`} className={cn( "touch-none rounded-2xl p-4 outline-none focus-visible:ring-2 focus-visible:ring-foreground/20", onSelect && "cursor-pointer", )} onPointerMove={onPointerMove} onPointerDown={onPointerMove} onClick={() => bar !== null && onSelect?.(steps[bar], bar)} {...cursor.bind} onKeyDown={onKeyDown} > {/* Measured on its own so the panel padding stays out of the geometry. */} <div ref={ref} className="relative" style={{ height }}> {geo && ( <svg key={drawKey} width={width} height={height} className="absolute inset-0 block overflow-visible" aria-hidden > {/* Everyone who entered, so the users lost read as empty space. */} {geo.tracks.map((t, i) => ( <path key={`t${i}`} d={barPath( t.x, t.y, t.w, t.h, BAR_RADIUS, vertical ? "bottom" : "left", )} fill={TRACK} /> ))} {geo.bands.map((d, i) => ( <motion.path key={`b${i}`} d={d} initial={{ opacity: 0 }} animate={{ opacity: bar !== null ? 0.5 : 1, fill: tint(color, gap === i ? 24 : 10), }} transition={{ opacity: { duration: 0.3, delay: bandsIn }, fill: { duration: 0.15 }, }} /> ))} {geo.bars.map((b, i) => ( <motion.path key={`r${i}`} d={barPath( b.x, b.y, b.w, b.h, BAR_RADIUS, vertical ? "bottom" : "left", )} fill={color} initial={ vertical ? { scaleY: reduce ? 1 : 0 } : { scaleX: reduce ? 1 : 0 } } animate={{ scaleX: 1, scaleY: 1, opacity: bar !== null && bar !== i ? 0.35 : 1, }} transition={{ scaleX: grow(i), scaleY: grow(i), opacity: { duration: 0.15 }, }} style={{ transformBox: "fill-box", transformOrigin: vertical ? "bottom" : "left", }} /> ))} {cursor.keyboard && bar !== null && ( <path d={(() => { const b = geo.bars[bar]; return vertical ? barPath( b.x - 3, b.y - 3, b.w + 6, b.h + 3, BAR_RADIUS + 3, ) : barPath( b.x, b.y - 3, b.w + 3, b.h + 6, BAR_RADIUS + 3, "left", ); })()} fill="none" className="stroke-foreground/40" strokeWidth={2} /> )} </svg> )} {/* Labels: count and share above each column with its name below, or name left and numbers right per row. */} {geo && steps.map((s, i) => { const b = geo.bars[i]; const dim = bar !== null && bar !== i; const numbers = ( <> <div className="text-[13px] font-medium leading-4 text-foreground tabular-nums"> {/* Full counts while they fit; compact only from six digits. */} {s.value < 100_000 ? formatValue(s.value, format) : formatCompact(s.value, format)} </div> <div className="text-xs leading-4 text-muted-foreground tabular-nums"> {formatShare(share(s.value))} </div> </> ); return vertical ? ( <div key={`${drawKey}-l${i}`} className={cn( "transition-opacity duration-150", dim && "opacity-50", )} > <motion.div className="pointer-events-none absolute pb-1.5 text-center" style={{ left: b.x + b.w / 2, top: b.y, x: "-50%", y: "-100%", }} initial={{ opacity: reduce ? 1 : 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3, delay: reduce ? 0 : i * STAGGER + DRAW_IN * 0.5, }} > {numbers} </motion.div> <div className="pointer-events-none absolute line-clamp-2 px-1 text-center text-xs leading-4 text-muted-foreground" style={{ left: (width / n) * i, width: width / n, top: COL_PLOT + 8, }} > {s.label} </div> </div> ) : ( <div key={`${drawKey}-l${i}`} className={cn( "pointer-events-none absolute inset-x-0 flex items-center transition-opacity duration-150", dim && "opacity-50", )} style={{ top: i * ROW, height: ROW }} > <div className="line-clamp-2 text-xs leading-4 text-muted-foreground" style={{ width: ROW_LABEL }} > {s.label} </div> <div className="ml-auto text-right" style={{ width: ROW_NUMBERS }} > {numbers} </div> </div> ); })} <Tooltip x={anchor?.[0] ?? 0} y={anchor?.[1] ?? 0} bounds={width} visible={!!anchor} > {bar !== null && ( <> <TooltipTitle>{steps[bar].label}</TooltipTitle> <div className="mt-0.5 tabular-nums"> {stepText(bar).count} <span className="opacity-60"> · {stepText(bar).ofFirst}</span> </div> {stepText(bar).ofPrev && ( <div className="opacity-60 tabular-nums"> {stepText(bar).ofPrev} </div> )} </> )} {gap !== null && ( <> <TooltipTitle> {steps[gap].label} → {steps[gap + 1].label} </TooltipTitle> <div className="mt-0.5 tabular-nums"> {dropText(gap).count}{" "} <span className="opacity-60">dropped</span> ·{" "} {dropText(gap).share} </div> </> )} </Tooltip> </div> </div> <Announce> {bar !== null ? [ steps[bar].label, stepText(bar).count, stepText(bar).ofFirst, stepText(bar).ofPrev, ] .filter(Boolean) .join(", ") : gap !== null ? `${steps[gap].label} to ${steps[gap + 1].label}: ${dropText(gap).count} dropped, ${dropText(gap).share}` : ""} </Announce> <DataTable caption={title} columns={["Step", "Count", "Share of total", "From previous step"]} rows={steps.map((s, i) => [ s.label, fmt(s.value), formatShare(share(s.value)), i ? formatShare(steps[i - 1].value ? s.value / steps[i - 1].value : 0) : "—", ])} /> </ChartCard> ); }
Adds the files to
components/uibrik/funnel and installs @number-flow/react, @tabler/icons-react, motion and react-use-measure. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/funnel.Props
Funnel- title
- Type string
- "Trial conversion"
- Names the funnel.
- accent
- Type string
- ""
- Bar colour. Empty uses the chart palette.
- steps
- Type { label: string; value: number }[]
- –
- One entry per step, in order. The first is everyone who entered.
- format
- Type Intl.NumberFormatOptions
- {}
- Formats every count, e.g. { style: "currency", currency: "USD" }.
- onSelect
- Type (step: { label: string; value: number }, index: number) => void
- –
- Called when a step is clicked, or picked with Enter.
- className
- Type string
- –
- Classes for the outer shell.