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.
The normal view. Pick a state to see what the block shows while it loads, is empty or fails.
// 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 }Installation
$ pnpm dlx shadcn@latest add @ballmac/experiments-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/button @ballmac/skeleton @ballmac/state-viewCopy the source into your project.
components/ballmac/blocks/experiments-1/experiments-1.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 }Update the import paths to match your project setup.
Usage
"use client"
import { Experiments1 } from "@/components/ballmac/blocks/experiments-1/experiments-1"
export default function Experiments1Demo() {
return <Experiments1 onShip={() => {}} />
}States
Pass state and the block does the rest: skeletons that match its layout, a clear empty message, an error you can retry, and announcements for screen readers.
Loading
A skeleton verdict and variant rows under the real heading, announced as loading.
state="loading"No results yet
Says results appear once visitors have seen the versions; emptyState sets the copy.
state="empty" (or variants={[]})Could not load
An alert saying nothing was changed, with Try again (onRetry).
state="error"
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
Every endless animation stops when the visitor asks for less motion.
Not applicable: Right to left
Not mirrored by design: The confidence-interval axes run from low to high rates left to right in every language, like a chart, and carry most of the page's elements.
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
3 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Passed: Loading, empty and error
Ships loading, empty, error states, each with an example.
Passed: Automated tests
Covered by experiments-1.test.tsx.
For information: Footprint
4.8 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
Use with AI
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'. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Experiments 1: A/B test results (@ballmac/experiments-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An experimentation or growth tool showing test results
- A landing-page testing report
Not for
- Plain KPI cards (use stats-1 or a dashboard block)
- Many metrics over time (use a chart)
Registry JSON: https://ui.ballmac.com/r/experiments-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.