"use client"; import { ArrowDownRightIcon, ClockIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, useMemo, useRef, useState } from "react"; import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; export interface FunnelSegment { label: string; value: number; } export interface FunnelStep { id: string; label: string; value: number; breakdown?: FunnelSegment[]; medianTime?: string; } export type FunnelMode = "count" | "percent"; export interface FunnelProps { title?: string; caption?: string; steps?: FunnelStep[]; defaultMode?: FunnelMode; segmentColors?: string[]; locale?: string; className?: string; } const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const SEGMENT_COLORS = ["var(--color-violet-500)", "var(--color-sky-600)", "var(--color-amber-600)", "var(--color-rose-600)"]; const TIP_W = 216; const split = (value: number, shares: number[]) => { const raw = shares.map((s) => Math.round(value * s)); raw[0] += value - raw.reduce((a, b) => a + b, 0); return raw; }; const channels = (value: number, shares: number[]) => split(value, shares).map((v, i) => ({ label: ["Organic", "Paid", "Referral"][i], value: v })); const defaultSteps: FunnelStep[] = [ { id: "visit", label: "Visited pricing", value: 48210, breakdown: channels(48210, [0.52, 0.34, 0.14]) }, { id: "start", label: "Started signup", value: 18340, breakdown: channels(18340, [0.49, 0.33, 0.18]), medianTime: "3m" }, { id: "verify", label: "Verified email", value: 14905, breakdown: channels(14905, [0.5, 0.31, 0.19]), medianTime: "6m" }, { id: "workspace", label: "Created workspace", value: 8822, breakdown: channels(8822, [0.53, 0.26, 0.21]), medianTime: "1.4h" }, { id: "invite", label: "Invited a teammate", value: 4104, breakdown: channels(4104, [0.55, 0.21, 0.24]), medianTime: "1.8 days" }, { id: "paid", label: "Upgraded to paid", value: 2318, breakdown: channels(2318, [0.57, 0.17, 0.26]), medianTime: "6 days" }, ]; function RollingNumber({ text, className }: { text: string; className?: string }) { const reduce = useReducedMotion(); let d = 0; let o = 0; const glyphs = [...text] .reverse() .map((ch) => { const isDigit = ch >= "0" && ch <= "9"; return { ch, key: isDigit ? `d${d++}` : `s${o++}${ch}`, digit: isDigit ? Number(ch) : null }; }) .reverse(); return ( {text} {glyphs.map((g) => g.digit === null ? ( {g.ch} ) : ( 0 {DIGITS.map((n) => ( {n} ))} ), )} ); } export function Funnel({ title = "Signup funnel", caption = "Last 30 days", steps = defaultSteps, defaultMode = "count", segmentColors = SEGMENT_COLORS, locale = "en-US", className, }: FunnelProps) { const reduce = useReducedMotion(); const listRef = useRef(null); const rowRefs = useRef<(HTMLLIElement | null)[]>([]); const [mode, setMode] = useState(defaultMode); const [active, setActive] = useState(null); const [tip, setTip] = useState({ x: 0, y: 0, above: false }); const num = useMemo(() => new Intl.NumberFormat(locale), [locale]); const pct = (v: number, digits = 1) => `${(v * 100).toFixed(digits)}%`; const top = steps[0]?.value || 1; const drops = steps.map((s, i) => (i === 0 ? 0 : 1 - s.value / (steps[i - 1].value || 1))); const worst = drops.reduce((w, d, i) => (d > drops[w] ? i : w), 1); const overall = (steps.at(-1)?.value ?? 0) / top; function placeFor(i: number) { const row = rowRefs.current[i]; const list = listRef.current; if (!row || !list) return; const bar = (steps[i].value / top) * list.clientWidth; const below = row.offsetTop + row.offsetHeight / 2 < list.clientHeight * 0.6; setTip({ x: Math.max(0, Math.min(bar - 24, list.clientWidth - TIP_W)), y: below ? row.offsetTop + row.offsetHeight + 8 : row.offsetTop - 8, above: !below, }); } function show(i: number) { setActive(i); placeFor(i); } function onKeyDown(e: KeyboardEvent, i: number) { const next = e.key === "ArrowDown" ? i + 1 : e.key === "ArrowUp" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? steps.length - 1 : null; if (e.key === "Escape") setActive(null); if (next === null || next < 0 || next >= steps.length) return; e.preventDefault(); rowRefs.current[next]?.focus(); } const flipY = tip.above; const step = active === null ? null : steps[active]; if (steps.length === 0) { return (

{title}

No one has entered this funnel yet.

); } return (

{title}

end to end
{num.format(top)} entered ยท {caption}
setMode(v as FunnelMode)} value={mode}> Count %
    setActive(null)} ref={listRef} > {steps.map((s, i) => { const share = s.value / top; const prevShare = i === 0 ? share : steps[i - 1].value / top; const lost = i === 0 ? 0 : steps[i - 1].value - s.value; const dim = active !== null && active !== i; const value = mode === "count" ? num.format(s.value) : pct(share); return (
  1. setActive(null)} onFocus={() => show(i)} onKeyDown={(e) => onKeyDown(e, i)} onPointerEnter={() => show(i)} ref={(el) => { rowRefs.current[i] = el; }} tabIndex={0} >
    {i + 1} {s.label}
    {i > 0 ? ( {(prevShare - share) * 100 > 14 ? ( {mode === "count" ? num.format(lost) : pct(drops[i], 0)} ) : null} ) : null} {i > 0 && (prevShare - share) * 100 <= 14 ? ( {mode === "count" ? num.format(lost) : pct(drops[i], 0)} ) : null}
  2. ); })}
{step && active !== null ? (
{step.label} Step {active + 1}
{active ? "Converted" : "Entered"}
{active ? pct(1 - drops[active]) : num.format(step.value)}
{active ? "Dropped" : "Of total"}
{active ? num.format(steps[active - 1].value - step.value) : "100%"}
{step.breakdown?.length ? (
{step.breakdown.map((seg, k) => (
))}
{step.breakdown.map((seg, k) => (
{seg.label} {num.format(seg.value)} {pct(seg.value / (step.value || 1), 0)}
))}
) : null} {step.medianTime ? (
Median wait {step.medianTime}
) : null} ) : null}
{steps.length > 1 ? ( ) : null}
); }