Side By Side Probe

Compare a baseline against an experiment by plotting both metrics on one shared axis and then revealing the move between them. A sweep grows from the baseline marker to the experiment marker, the headline number counts up, and a bracket measures the gain — so the magnitude of a change is something you see (a distance) and the change itself is something you watch (a sweep), instead of two numbers you mentally subtract. The reveal autoplays once, then stays scrubbable and replayable.

Comparison on a shared scale. Baseline: 0.842 val acc. + LayerNorm: 0.887 val acc. Delta +0.045 val acc, an improvement.Revealed 0 percent. Current 0.842 val acc, delta 0 val acc.
Baseline+ LayerNorm
0.842val acc
0 val acc· better
closed 0% of the gap to 1
Customize
Metrics
0.842
0.887
Reveal
900ms

Installation

npx shadcn@latest add https://craftbits.dev/r/side-by-side-probe.json

Usage

import { SideBySideProbe } from "@craft-bits/core";
 
<SideBySideProbe
  unit="val acc"
  domain={[0.8, 1]}
  target={1}
  baseline={{ label: "Baseline", metric: 0.842 }}
  experiment={{ label: "+ LayerNorm", metric: 0.887 }}
/>;

A lower-is-better metric — flip higherIsBetter so a drop reads as the improvement (latency, loss, parameter count):

<SideBySideProbe
  unit="ms"
  higherIsBetter={false}
  baseline={{ label: "Eager", metric: 84 }}
  experiment={{ label: "+ KV cache", metric: 61 }}
/>

Drive the reveal yourself by wiring progress to your own scrubber, scroll position, or lesson step:

const [p, setP] = useState(0);
 
<SideBySideProbe
  progress={p}
  onProgressChange={setP}
  autoPlay={false}
  baseline={{ label: "Before", metric: 12.4 }}
  experiment={{ label: "After", metric: 18.9 }}
/>;

Understanding the component

  1. One shared axis, two markers. Both metrics map onto a single horizontal scale (domain), so the gap between them is a visible distance. The baseline is a hollow reference ring; the experiment is a dashed goalpost.
  2. progress ∈ [0, 1] is the whole machine. The sweep length, moving marker, counting headline, and bracket all derive from one number. Autoplay eases it 0 → 1 on mount; the scrubber sets it directly; progress / onProgressChange make it fully controlled.
  3. The sweep is the lesson. A bar grows from baseline to the live value while the headline counts up — the change is watched, not presented as a finished fact.
  4. The delta, measured three ways. Headline (absolute landing value), bracket (spatial span + relative %), and badge (+0.045 · better) frame the same delta differently so significance is unambiguous.
  5. Valence, not just sign. higherIsBetter decides improvement (success) vs regression (error); an arrow, the signed number, and a word all agree, so color is never the only cue.
  6. Honest scale + headroom. Pass an explicit domain (a tight auto-range exaggerates small gaps — the ticks show what you chose). With target, the caption reframes the delta as "closed 28% of the gap to 1.00".
  7. Reduced motion skips the sweep to the end-state; the scrubber still works.

Props

PropTypeDefaultDescription
baselineSideBySideProbeSideReference configuration — the fixed origin of the sweep.
experimentSideBySideProbeSideExperiment configuration — the sweep reveals the move toward it.
unitstringUnit suffix on the headline / badge.
domain[number, number]padded auto-rangeBounds of the shared axis. Pass a range for an honest scale.
higherIsBetterbooleantrueWhether a larger metric is the improvement.
targetnumberGoalpost; frames the delta as the share of the remaining gap closed.
formatValue(value) => stringtrimmed, magnitude-awareFormat the readouts.
formatDelta(delta) => stringsign + formatValue(|delta|)Format the signed delta.
progressnumberControlled reveal progress in [0, 1].
defaultProgressnumber0Uncontrolled initial progress.
onProgressChange(progress) => voidFires on autoplay, scrub, and replay.
autoPlaybooleantrueAuto-play the reveal once after mount (uncontrolled).
revealDurationnumber900Reveal-sweep duration (ms).
transitionTransitionSPRINGS.snapSpring for the moving marker + sweep.
classNamestringMerged onto the root <div> via cn().

Each side has the shape:

FieldTypeDescription
labelstringDisplay name for the configuration.
metricnumberNumeric value plotted on the shared axis.
valueReactNodeOptional override for the headline (shown when fully revealed).

Accessibility

  • The root is role="group" labelled by a hidden sentence naming both configurations, their metrics, and the delta + valence ("an improvement" / "a regression").
  • A hidden aria-live="polite" region announces the revealed value and delta as progress changes — the same play-by-play as the sweep.
  • The scrubber is a native <input type="range"> with aria-valuetext of the current value + delta; the replay button has an explicit aria-label. Both show visible :focus-visible rings.
  • Valence is triple-encoded — color, an arrow (▲ / ▼), and a word ("better" / "worse") — so direction survives red/green color-vision deficiency.
  • prefers-reduced-motion disables the autoplay sweep and renders the end-state; manual scrubbing remains.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/nn/SideBySideProbe.tsx). The source was a static two-panel layout with a floating delta chip. craft-bits re-architects it into a teaching mechanic — a shared-axis reveal that makes magnitude spatial and change temporal — while staying a general baseline-vs-experiment comparison primitive.