# Experiments 1: A/B test results

> 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.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/experiments-1).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/experiments-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/experiments-1-light-8fe4bf4f6bd36957.png
- Registry item: https://ui.ballmac.com/r/experiments-1.json
- Categories: blocks, experiments
- Listed: 2026-10-18, source last changed 2026-10-18

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/ballmac/experiments-1.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/button.json`, `https://ui.ballmac.com/r/skeleton.json`, `https://ui.ballmac.com/r/state-view.json`

## Files

### `registry/ballmac/components/blocks/experiments-1/experiments-1.tsx`

```tsx
// Ballmac UI: Experiments 1. https://ui.ballmac.com/blocks/experiments-1
"use client"

import * as React from "react"
import { CircleCheck, FlaskConical, Hourglass, Minus, Rocket, TrendingDown, TrendingUp } from "lucide-react"

import { Button } from "@/components/ballmac/button"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView, type StateProps } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"

type Experiments1Variant = { id: string; name: string; visitors: number; conversions: number; /** The version everyone sees today. */ control?: boolean }

type Experiments1Props = Omit<React.ComponentProps<"section">, "title"> &
  StateProps & {
    title?: string
    /** What was changed, in a sentence. */
    hypothesis?: string
    /** What counts as a win, such as "Started a trial". */
    metric?: string
    /** "Running since 4 October" or "Ended 16 October", already written for people. */
    status?: string
    variants?: Experiments1Variant[]
    /** How sure to be before calling a winner, from 0.8 to 0.99. */
    confidence?: number
    /** Called when someone chooses to ship a variant. */
    onShip?: (variant: Experiments1Variant) => void
  }

const sample: Experiments1Variant[] = [
  { id: "a", name: "Current form", visitors: 6420, conversions: 385, control: true },
  { id: "b", name: "Shorter form", visitors: 6388, conversions: 471 },
  { id: "c", name: "Social proof above the fold", visitors: 6451, conversions: 412 },
]

// Normal distribution helpers: Abramowitz and Stegun 7.1.26 for the error function, so there are no dependencies and no randomness.
function erf(x: number) {
  const s = x < 0 ? -1 : 1
  const t = 1 / (1 + 0.3275911 * Math.abs(x))
  const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-x * x)
  return s * y
}
const cdf = (z: number) => 0.5 * (1 + erf(z / Math.SQRT2))
const Z95 = 1.959964

// Wilson score interval: stays inside 0 to 1 and behaves with small counts, unlike the plain +/- interval.
function wilson(x: number, n: number, z = Z95) {
  if (!n) return [0, 0] as const
  const p = x / n
  const d = 1 + (z * z) / n
  const c = p + (z * z) / (2 * n)
  const m = z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))
  return [(c - m) / d, (c + m) / d] as const
}

const pct = (x: number, d = 2) => `${(x * 100).toFixed(d)}%`
// Never promise certainty: a chance of 99.97% reads as 99.9%, and 0.02% as 0.1%.
const chance = (x: number) => `${Math.min(99.9, Math.max(0.1, x * 100)).toFixed(1)}%`

function Skel() {
  return (
    <div aria-hidden="true" className="grid gap-6">
      <Skeleton className="h-28 rounded-2xl" />
      <div className="rounded-2xl border p-6">
        {[0, 1, 2].map((i) => (
          <div key={i} className="py-5 first:pt-0 last:pb-0">
            <Skeleton className="h-5 w-40" />
            <Skeleton className="mt-4 h-6 w-full" />
          </div>
        ))}
      </div>
    </div>
  )
}

/**
 * A/B test results with the statistics done properly: each variant's conversion rate with its 95% confidence interval drawn on one shared
 * 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
 * more traffic is needed). It works out everything from visitors and conversions; the maths is deterministic.
 */
function Experiments1({
  title = "Signup page: form length",
  hypothesis = "A shorter signup form will get more people to start a trial.",
  metric = "Started a trial",
  status = "Running since 4 October",
  variants = sample,
  confidence = 0.95,
  onShip,
  state = "ready",
  onRetry,
  emptyState,
  errorState,
  className,
  ...props
}: Experiments1Props) {
  const id = React.useId()
  const list = state === "empty" ? [] : variants
  const control = list.find((v) => v.control) ?? list[0]

  const rows = list.map((v) => {
    const rate = v.visitors ? v.conversions / v.visitors : 0
    const [lo, hi] = wilson(v.conversions, v.visitors)
    let lift: number | null = null
    let beat: number | null = null
    let p: number | null = null
    if (control && v !== control && control.visitors && v.visitors) {
      const p1 = control.conversions / control.visitors
      const se = Math.sqrt((p1 * (1 - p1)) / control.visitors + (rate * (1 - rate)) / v.visitors)
      const z = se ? (rate - p1) / se : 0
      lift = p1 ? (rate - p1) / p1 : null
      beat = cdf(z)
      // Two-sided p-value of the difference between two proportions.
      const pooled = (control.conversions + v.conversions) / (control.visitors + v.visitors)
      const sep = Math.sqrt(pooled * (1 - pooled) * (1 / control.visitors + 1 / v.visitors))
      p = sep ? 2 * (1 - cdf(Math.abs(rate - p1) / sep)) : 1
    }
    return { v, rate, lo, hi, lift, beat, p }
  })

  const alpha = 1 - confidence
  const contenders = rows.filter((r) => r.p !== null)
  const best = contenders.reduce<(typeof rows)[number] | null>((b, r) => (!b || (r.lift ?? -1) > (b.lift ?? -1) ? r : b), null)
  const significant = best && best.p! < alpha
  // Visitors still needed per variant to see the observed gap with 80% power at this confidence.
  const need = (() => {
    if (!best || !control) return 0
    const p1 = control.conversions / control.visitors
    const p2 = best.rate
    const d = Math.abs(p2 - p1)
    if (d < 1e-6) return Infinity
    const za = 1.959964
    const zb = 0.841621
    const n = (2 * (za + zb) ** 2 * ((p1 + p2) / 2) * (1 - (p1 + p2) / 2)) / (d * d)
    return Math.max(0, Math.ceil(n) - Math.min(control.visitors, best.v.visitors))
  })()

  const verdict = !control || !best
    ? null
    : significant && (best.lift ?? 0) > 0
      ? { 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}.` }
      : significant
        ? { 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.` }
        : Number.isFinite(need) && need > 0
          ? { 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.` }
          : { tone: "flat", icon: Minus, head: "No clear difference", body: `The versions convert about the same. More traffic will not change that much.` }

  const max = Math.max(...rows.map((r) => r.hi), 0.0001)
  const min = Math.min(...rows.map((r) => r.lo), max)
  const pad = (max - min) * 0.12
  const lo0 = Math.max(0, min - pad)
  const hi0 = max + pad
  const x = (v: number) => `${((v - lo0) / (hi0 - lo0 || 1)) * 100}%`
  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" }

  const content = !list.length ? (
    <div className="bg-card rounded-2xl border px-6 py-16 text-center">
      <span aria-hidden="true" className="bg-muted mx-auto flex size-12 items-center justify-center rounded-full">
        <FlaskConical className="size-6" />
      </span>
      <h2 className="mt-5 text-lg font-semibold tracking-tight">{emptyState?.title ?? "No results yet"}</h2>
      <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>
      {emptyState?.action}
    </div>
  ) : (
    <div className="grid gap-6">
      {verdict ? (
        <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])}>
          <verdict.icon aria-hidden="true" className="mt-0.5 size-7 shrink-0" />
          <div className="min-w-0">
            <p className="text-xl font-semibold tracking-tight text-balance">{verdict.head}</p>
            <p className="text-muted-foreground mt-1 text-sm text-pretty">{verdict.body}</p>
          </div>
        </div>
      ) : null}

      <div className="bg-card rounded-2xl border">
        <div className="flex items-center justify-between gap-4 border-b px-5 py-3.5 text-sm sm:px-6">
          <p className="font-medium">{metric}</p>
          <p className="text-muted-foreground tabular-nums">
            {new Intl.NumberFormat("en").format(rows.reduce((n, r) => n + r.v.visitors, 0))} visitors
          </p>
        </div>
        <ul className="divide-y">
          {rows.map((r) => {
            const leader = r === best && significant && (r.lift ?? 0) > 0
            return (
              <li key={r.v.id} className="p-5 sm:p-6">
                <div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
                  <div className="min-w-0">
                    <h3 className="flex flex-wrap items-center gap-2 font-semibold tracking-tight">
                      {r.v.name}
                      {r.v.control ? <span className="bg-muted rounded-full border px-2 py-0.5 text-xs font-medium">Control</span> : null}
                      {leader ? <span className="bg-chart-2/15 rounded-full border px-2 py-0.5 text-xs font-medium">Winning</span> : null}
                    </h3>
                    <p className="text-muted-foreground mt-0.5 text-sm tabular-nums">
                      {new Intl.NumberFormat("en").format(r.v.conversions)} of {new Intl.NumberFormat("en").format(r.v.visitors)} visitors
                    </p>
                  </div>
                  <div className="text-end">
                    <p className="text-3xl font-semibold tracking-[-0.04em] tabular-nums">{pct(r.rate)}</p>
                    {r.lift !== null ? (
                      <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")}>
                        {r.lift >= 0 ? <TrendingUp aria-hidden="true" className="size-4" /> : <TrendingDown aria-hidden="true" className="size-4" />}
                        <span className="text-foreground">
                          {r.lift >= 0 ? "+" : ""}
                          {pct(r.lift, 1)} <span className="text-muted-foreground font-normal">vs control</span>
                        </span>
                      </p>
                    ) : (
                      <p className="text-muted-foreground mt-0.5 text-sm">The baseline</p>
                    )}
                  </div>
                </div>

                <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">
                  <span aria-hidden="true" className="bg-border absolute inset-x-0 top-1/2 h-px" />
                  {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}
                  <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)})` }} />
                  <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) }} />
                </div>
                <div aria-hidden="true" dir="ltr" className="text-muted-foreground mt-1 flex justify-between text-xs tabular-nums">
                  <span>{pct(lo0, 1)}</span>
                  <span>{pct(hi0, 1)}</span>
                </div>

                {r.beat !== null ? (
                  <div className="mt-4 flex flex-wrap items-center justify-between gap-3">
                    <p className="text-sm">
                      <span className="font-semibold tabular-nums">{chance(r.beat)}</span> <span className="text-muted-foreground">chance to beat the control</span>
                      <span className="text-muted-foreground tabular-nums"> · p = {r.p! < 0.001 ? "<0.001" : r.p!.toFixed(3)}</span>
                    </p>
                    {onShip ? (
                      <Button variant={leader ? "default" : "outline"} size="sm" onClick={() => onShip(r.v)}>
                        <Rocket aria-hidden="true" />
                        Ship {r.v.name}
                      </Button>
                    ) : null}
                  </div>
                ) : null}
              </li>
            )
          })}
        </ul>
      </div>
      <p className="text-muted-foreground text-xs text-pretty">
        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)}.
      </p>
    </div>
  )

  return (
    <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)}>
      <header className="mb-8">
        <p className="text-muted-foreground flex items-center gap-2 text-sm font-medium">
          <FlaskConical aria-hidden="true" className="size-4" />
          Experiment · {status}
        </p>
        <h1 id={`${id}-title`} className="mt-2 text-3xl font-semibold tracking-[-0.035em] text-balance">
          {title}
        </h1>
        <p className="text-muted-foreground mt-2 max-w-2xl text-pretty">{hypothesis}</p>
      </header>
      <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 }}>
        {content}
      </StateView>
    </section>
  )
}

export { Experiments1, type Experiments1Props, type Experiments1Variant }
```
