{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "experiments-1",
  "title": "Experiments 1: A/B test results",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A/B test results with the statistics done: each variant's rate with its 95% confidence interval on one shared axis, lift, chance to beat the control, and a plain verdict: a winner, no clear difference, or more traffic needed.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/experiments-1/experiments-1.tsx",
      "content": "// Ballmac UI: Experiments 1. https://ui.ballmac.com/blocks/experiments-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleCheck, FlaskConical, Hourglass, Minus, Rocket, TrendingDown, TrendingUp } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Experiments1Variant = { id: string; name: string; visitors: number; conversions: number; /** The version everyone sees today. */ control?: boolean }\n\ntype Experiments1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    title?: string\n    /** What was changed, in a sentence. */\n    hypothesis?: string\n    /** What counts as a win, such as \"Started a trial\". */\n    metric?: string\n    /** \"Running since 4 October\" or \"Ended 16 October\", already written for people. */\n    status?: string\n    variants?: Experiments1Variant[]\n    /** How sure to be before calling a winner, from 0.8 to 0.99. */\n    confidence?: number\n    /** Called when someone chooses to ship a variant. */\n    onShip?: (variant: Experiments1Variant) => void\n  }\n\nconst sample: Experiments1Variant[] = [\n  { id: \"a\", name: \"Current form\", visitors: 6420, conversions: 385, control: true },\n  { id: \"b\", name: \"Shorter form\", visitors: 6388, conversions: 471 },\n  { id: \"c\", name: \"Social proof above the fold\", visitors: 6451, conversions: 412 },\n]\n\n// Normal distribution helpers: Abramowitz and Stegun 7.1.26 for the error function, so there are no dependencies and no randomness.\nfunction erf(x: number) {\n  const s = x < 0 ? -1 : 1\n  const t = 1 / (1 + 0.3275911 * Math.abs(x))\n  const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x)\n  return s * y\n}\nconst cdf = (z: number) => 0.5 * (1 + erf(z / Math.SQRT2))\nconst Z95 = 1.959964\n\n// Wilson score interval: stays inside 0 to 1 and behaves with small counts, unlike the plain +/- interval.\nfunction wilson(x: number, n: number, z = Z95) {\n  if (!n) return [0, 0] as const\n  const p = x / n\n  const d = 1 + (z * z) / n\n  const c = p + (z * z) / (2 * n)\n  const m = z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))\n  return [(c - m) / d, (c + m) / d] as const\n}\n\nconst pct = (x: number, d = 2) => `${(x * 100).toFixed(d)}%`\n// Never promise certainty: a chance of 99.97% reads as 99.9%, and 0.02% as 0.1%.\nconst chance = (x: number) => `${Math.min(99.9, Math.max(0.1, x * 100)).toFixed(1)}%`\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-6\">\n      <Skeleton className=\"h-28 rounded-2xl\" />\n      <div className=\"rounded-2xl border p-6\">\n        {[0, 1, 2].map((i) => (\n          <div key={i} className=\"py-5 first:pt-0 last:pb-0\">\n            <Skeleton className=\"h-5 w-40\" />\n            <Skeleton className=\"mt-4 h-6 w-full\" />\n          </div>\n        ))}\n      </div>\n    </div>\n  )\n}\n\n/**\n * A/B test results with the statistics done properly: each variant's conversion rate with its 95% confidence interval drawn on one shared\n * axis, the lift over the control, the chance it really beats the control, and a plain verdict (a winner, no clear difference, or how much\n * more traffic is needed). It works out everything from visitors and conversions; the maths is deterministic.\n */\nfunction Experiments1({\n  title = \"Signup page: form length\",\n  hypothesis = \"A shorter signup form will get more people to start a trial.\",\n  metric = \"Started a trial\",\n  status = \"Running since 4 October\",\n  variants = sample,\n  confidence = 0.95,\n  onShip,\n  state = \"ready\",\n  onRetry,\n  emptyState,\n  errorState,\n  className,\n  ...props\n}: Experiments1Props) {\n  const id = React.useId()\n  const list = state === \"empty\" ? [] : variants\n  const control = list.find((v) => v.control) ?? list[0]\n\n  const rows = list.map((v) => {\n    const rate = v.visitors ? v.conversions / v.visitors : 0\n    const [lo, hi] = wilson(v.conversions, v.visitors)\n    let lift: number | null = null\n    let beat: number | null = null\n    let p: number | null = null\n    if (control && v !== control && control.visitors && v.visitors) {\n      const p1 = control.conversions / control.visitors\n      const se = Math.sqrt((p1 * (1 - p1)) / control.visitors + (rate * (1 - rate)) / v.visitors)\n      const z = se ? (rate - p1) / se : 0\n      lift = p1 ? (rate - p1) / p1 : null\n      beat = cdf(z)\n      // Two-sided p-value of the difference between two proportions.\n      const pooled = (control.conversions + v.conversions) / (control.visitors + v.visitors)\n      const sep = Math.sqrt(pooled * (1 - pooled) * (1 / control.visitors + 1 / v.visitors))\n      p = sep ? 2 * (1 - cdf(Math.abs(rate - p1) / sep)) : 1\n    }\n    return { v, rate, lo, hi, lift, beat, p }\n  })\n\n  const alpha = 1 - confidence\n  const contenders = rows.filter((r) => r.p !== null)\n  const best = contenders.reduce<(typeof rows)[number] | null>((b, r) => (!b || (r.lift ?? -1) > (b.lift ?? -1) ? r : b), null)\n  const significant = best && best.p! < alpha\n  // Visitors still needed per variant to see the observed gap with 80% power at this confidence.\n  const need = (() => {\n    if (!best || !control) return 0\n    const p1 = control.conversions / control.visitors\n    const p2 = best.rate\n    const d = Math.abs(p2 - p1)\n    if (d < 1e-6) return Infinity\n    const za = 1.959964\n    const zb = 0.841621\n    const n = (2 * (za + zb) ** 2 * ((p1 + p2) / 2) * (1 - (p1 + p2) / 2)) / (d * d)\n    return Math.max(0, Math.ceil(n) - Math.min(control.visitors, best.v.visitors))\n  })()\n\n  const verdict = !control || !best\n    ? null\n    : significant && (best.lift ?? 0) > 0\n      ? { tone: \"win\", icon: CircleCheck, head: `${best.v.name} is winning`, body: `It converts ${pct(best.rate)} against ${pct(control.conversions / control.visitors)}, ${pct(best.lift ?? 0, 1)} better, and there is a ${chance(best.beat ?? 0)} chance it truly beats ${control.name}.` }\n      : significant\n        ? { tone: \"lose\", icon: TrendingDown, head: `${control.name} is still the best`, body: `${best.v.name} converts worse than ${control.name}, and the gap is unlikely to be chance.` }\n        : Number.isFinite(need) && need > 0\n          ? { tone: \"wait\", icon: Hourglass, head: \"Not enough data to call it yet\", body: `${best.v.name} looks ahead, but the gap could still be luck. Roughly ${new Intl.NumberFormat(\"en\").format(Math.ceil(need / 100) * 100)} more visitors for each version would settle it.` }\n          : { tone: \"flat\", icon: Minus, head: \"No clear difference\", body: `The versions convert about the same. More traffic will not change that much.` }\n\n  const max = Math.max(...rows.map((r) => r.hi), 0.0001)\n  const min = Math.min(...rows.map((r) => r.lo), max)\n  const pad = (max - min) * 0.12\n  const lo0 = Math.max(0, min - pad)\n  const hi0 = max + pad\n  const x = (v: number) => `${((v - lo0) / (hi0 - lo0 || 1)) * 100}%`\n  const toneBox = { win: \"bg-chart-2/10 border-chart-2/30\", lose: \"bg-destructive/10 border-destructive/30\", wait: \"bg-amber-500/10 border-amber-500/40\", flat: \"bg-muted border-border\" }\n\n  const content = !list.length ? (\n    <div className=\"bg-card rounded-2xl border px-6 py-16 text-center\">\n      <span aria-hidden=\"true\" className=\"bg-muted mx-auto flex size-12 items-center justify-center rounded-full\">\n        <FlaskConical className=\"size-6\" />\n      </span>\n      <h2 className=\"mt-5 text-lg font-semibold tracking-tight\">{emptyState?.title ?? \"No results yet\"}</h2>\n      <p className=\"text-muted-foreground mx-auto mt-1.5 max-w-xs text-sm text-pretty\">{emptyState?.description ?? \"Once visitors have seen the versions, their results appear here.\"}</p>\n      {emptyState?.action}\n    </div>\n  ) : (\n    <div className=\"grid gap-6\">\n      {verdict ? (\n        <div role=\"status\" className={cn(\"flex items-start gap-4 rounded-2xl border p-5 sm:p-6\", toneBox[verdict.tone as keyof typeof toneBox])}>\n          <verdict.icon aria-hidden=\"true\" className=\"mt-0.5 size-7 shrink-0\" />\n          <div className=\"min-w-0\">\n            <p className=\"text-xl font-semibold tracking-tight text-balance\">{verdict.head}</p>\n            <p className=\"text-muted-foreground mt-1 text-sm text-pretty\">{verdict.body}</p>\n          </div>\n        </div>\n      ) : null}\n\n      <div className=\"bg-card rounded-2xl border\">\n        <div className=\"flex items-center justify-between gap-4 border-b px-5 py-3.5 text-sm sm:px-6\">\n          <p className=\"font-medium\">{metric}</p>\n          <p className=\"text-muted-foreground tabular-nums\">\n            {new Intl.NumberFormat(\"en\").format(rows.reduce((n, r) => n + r.v.visitors, 0))} visitors\n          </p>\n        </div>\n        <ul className=\"divide-y\">\n          {rows.map((r) => {\n            const leader = r === best && significant && (r.lift ?? 0) > 0\n            return (\n              <li key={r.v.id} className=\"p-5 sm:p-6\">\n                <div className=\"flex flex-wrap items-start justify-between gap-x-6 gap-y-3\">\n                  <div className=\"min-w-0\">\n                    <h3 className=\"flex flex-wrap items-center gap-2 font-semibold tracking-tight\">\n                      {r.v.name}\n                      {r.v.control ? <span className=\"bg-muted rounded-full border px-2 py-0.5 text-xs font-medium\">Control</span> : null}\n                      {leader ? <span className=\"bg-chart-2/15 rounded-full border px-2 py-0.5 text-xs font-medium\">Winning</span> : null}\n                    </h3>\n                    <p className=\"text-muted-foreground mt-0.5 text-sm tabular-nums\">\n                      {new Intl.NumberFormat(\"en\").format(r.v.conversions)} of {new Intl.NumberFormat(\"en\").format(r.v.visitors)} visitors\n                    </p>\n                  </div>\n                  <div className=\"text-end\">\n                    <p className=\"text-3xl font-semibold tracking-[-0.04em] tabular-nums\">{pct(r.rate)}</p>\n                    {r.lift !== null ? (\n                      <p className={cn(\"mt-0.5 inline-flex items-center gap-1 text-sm font-medium tabular-nums\", r.lift > 0 ? \"text-chart-2\" : r.lift < 0 ? \"text-destructive\" : \"text-muted-foreground\")}>\n                        {r.lift >= 0 ? <TrendingUp aria-hidden=\"true\" className=\"size-4\" /> : <TrendingDown aria-hidden=\"true\" className=\"size-4\" />}\n                        <span className=\"text-foreground\">\n                          {r.lift >= 0 ? \"+\" : \"\"}\n                          {pct(r.lift, 1)} <span className=\"text-muted-foreground font-normal\">vs control</span>\n                        </span>\n                      </p>\n                    ) : (\n                      <p className=\"text-muted-foreground mt-0.5 text-sm\">The baseline</p>\n                    )}\n                  </div>\n                </div>\n\n                <div dir=\"ltr\" role=\"img\" aria-label={`${r.v.name}: ${pct(r.rate)}, likely between ${pct(r.lo)} and ${pct(r.hi)}`} className=\"relative mt-5 h-6\">\n                  <span aria-hidden=\"true\" className=\"bg-border absolute inset-x-0 top-1/2 h-px\" />\n                  {control ? <span aria-hidden=\"true\" className=\"bg-foreground/40 absolute inset-y-0 w-px border-s border-dashed\" style={{ left: x(control.conversions / control.visitors) }} /> : null}\n                  <span aria-hidden=\"true\" className={cn(\"absolute top-1/2 h-2.5 -translate-y-1/2 rounded-full\", leader ? \"bg-chart-2/40\" : \"bg-foreground/15\")} style={{ left: x(r.lo), width: `calc(${x(r.hi)} - ${x(r.lo)})` }} />\n                  <span aria-hidden=\"true\" className={cn(\"border-background absolute top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-sm\", leader ? \"bg-chart-2\" : \"bg-foreground\")} style={{ left: x(r.rate) }} />\n                </div>\n                <div aria-hidden=\"true\" dir=\"ltr\" className=\"text-muted-foreground mt-1 flex justify-between text-xs tabular-nums\">\n                  <span>{pct(lo0, 1)}</span>\n                  <span>{pct(hi0, 1)}</span>\n                </div>\n\n                {r.beat !== null ? (\n                  <div className=\"mt-4 flex flex-wrap items-center justify-between gap-3\">\n                    <p className=\"text-sm\">\n                      <span className=\"font-semibold tabular-nums\">{chance(r.beat)}</span> <span className=\"text-muted-foreground\">chance to beat the control</span>\n                      <span className=\"text-muted-foreground tabular-nums\"> · p = {r.p! < 0.001 ? \"<0.001\" : r.p!.toFixed(3)}</span>\n                    </p>\n                    {onShip ? (\n                      <Button variant={leader ? \"default\" : \"outline\"} size=\"sm\" onClick={() => onShip(r.v)}>\n                        <Rocket aria-hidden=\"true\" />\n                        Ship {r.v.name}\n                      </Button>\n                    ) : null}\n                  </div>\n                ) : null}\n              </li>\n            )\n          })}\n        </ul>\n      </div>\n      <p className=\"text-muted-foreground text-xs text-pretty\">\n        Bars show where the true rate most likely lies (95% confidence interval). The dashed line is the control. A winner is called when the chance the difference is luck is under {pct(alpha, 0)}.\n      </p>\n    </div>\n  )\n\n  return (\n    <section data-slot=\"experiments-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-4xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <p className=\"text-muted-foreground flex items-center gap-2 text-sm font-medium\">\n          <FlaskConical aria-hidden=\"true\" className=\"size-4\" />\n          Experiment · {status}\n        </p>\n        <h1 id={`${id}-title`} className=\"mt-2 text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 max-w-2xl text-pretty\">{hypothesis}</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading experiment results\" loading={<Skel />} error={{ title: errorState?.title ?? \"Results could not be loaded\", description: errorState?.description ?? \"Nothing was changed. Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Experiments1, type Experiments1Props, type Experiments1Variant }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/experiments-1/experiments-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "experiments",
      "ab test",
      "a/b testing",
      "conversion",
      "statistics",
      "significance",
      "confidence interval",
      "growth",
      "optimization"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A/B test results. <Experiments1 title hypothesis metric status variants={[{ id, name, visitors, conversions, control?: true }]} confidence={0.95} onShip={(variant) => ...} />. Pass raw counts: rates, Wilson 95% intervals, relative lift, chance to beat the control (normal approximation), a two-sided p-value and the verdict are worked out. The verdict is a winner (p below 1 minus confidence), a loser, 'not enough data' with the visitors still needed at 80% power, or 'no clear difference'.",
      "whenToUse": [
        "An experimentation or growth tool showing test results",
        "A landing-page testing report"
      ],
      "whenNotToUse": [
        "Plain KPI cards (use stats-1 or a dashboard block)",
        "Many metrics over time (use a chart)"
      ],
      "composesWith": [
        "app-shell-1",
        "funnel-1",
        "settings-5"
      ],
      "a11y": [
        {
          "keys": "Tab",
          "action": "Each Ship button; the verdict is a live region so a change in the data is announced"
        },
        {
          "keys": "Intervals",
          "action": "Every drawn interval has a text equivalent: the rate and the range it likely lies in"
        },
        {
          "keys": "Winning",
          "action": "Marked with a word and an icon as well as color; losses are written as lift in words, such as -4.2% vs control"
        }
      ],
      "customization": [
        "confidence: 0.8 to 0.99 sets how sure to be before calling a winner",
        "The interval axis runs left to right in every language, like a chart",
        "All statistics are deterministic, so server and client agree"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "experiments-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton verdict and variant rows under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No results yet",
        "example": "experiments-1-empty",
        "trigger": "state=\"empty\" (or variants={[]})",
        "note": "Says results appear once visitors have seen the versions; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "experiments-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry)."
      }
    ],
    "examples": [
      "experiments-1-demo",
      "experiments-1-early",
      "experiments-1-flat",
      "experiments-1-loading",
      "experiments-1-empty",
      "experiments-1-error"
    ],
    "url": "https://ui.ballmac.com/components/experiments-1"
  },
  "categories": [
    "blocks",
    "experiments"
  ],
  "type": "registry:block"
}