Data Duel Split
A page-scale A/B duel split screen — scrolling reveals the metrics round by round while the divider in the middle slides like a tug-of-war rope and the running score at the bottom jumps in sync.
This is a WebberUI Pro component
Free during the launch campaign: sign up or sign in, then hit “Copy install command” in the preview above and it installs straight away — no payment, no credit card. The command below returns 401 while you are signed out.
npx shadcn@latest add "https://webberui.com/r/data-duel-split.json?t=<install token>"Playground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<DataDuelSplit />
Installation
npx shadcn@latest add "https://webberui.com/r/data-duel-split.json?t=<install token>"Or, once registries are configured in components.json, install it as @webberui/data-duel-split.
Usage
import { DataDuelSplit } from "@/components/ui/data-duel-split";
<DataDuelSplit
left={{ name: "Nova" }}
right={{ name: "Atlas" }}
rounds={[
{ label: "First paint", left: 420, right: 680, suffix: "ms", win: "lower" },
{ label: "Conversion rate", left: 3.1, right: 4.6, suffix: "%", format: (v) => v.toFixed(1) },
{ label: "Monthly active users", left: 128, right: 96, suffix: "k" },
]}
/>By default the whole window is the scroll container and the stage height is 100vh, so dropping it into a page makes it a scroll narrative on its own. To embed it inside an overflow-y-auto block, pass that block's ref to container and set stageHeight to a fixed pixel value:
const scrollerRef = React.useRef<HTMLDivElement>(null);
<div ref={scrollerRef} className="h-[300px] overflow-y-auto">
<DataDuelSplit container={scrollerRef} stageHeight="300px" left={/* … */} right={/* … */} rounds={/* … */} />
</div>Props
DataDuelSplit
| Prop | Type | Default | Description |
|---|---|---|---|
left | DuelTeam | — | Configuration of the left side |
right | DuelTeam | — | Configuration of the right side |
rounds | DuelRound[] | — | Round-by-round duel data, revealed in scroll order |
stageHeight | string | "100vh" | Stage height (a CSS length); a fixed pixel value is recommended inside a nested container |
container | RefObject<HTMLElement | null> | — | Ref of the nested overflow scroll container; defaults to the window as scroll container |
className | string | — | Class appended to the outermost container |
DuelTeam
| Field | Type | Default | Description |
|---|---|---|---|
name | string | — | Side name |
accent | string | Left #4f46e5 / right #e11d48 | Accent color (any CSS color), used for the fill, the score, and the winning value |
icon | React.ReactNode | — | Small icon or logo shown beside the name |
DuelRound
| Field | Type | Default | Description |
|---|---|---|---|
label | string | — | Name of the metric being compared in this round |
left | number | — | Value for the left side |
right | number | — | Value for the right side |
format | (value: number) => string | toLocaleString | Value formatting function |
suffix | string | — | Suffix for the value, for example "%", "ms", or "k" |
note | React.ReactNode | — | Supplementary note for this round |
win | "higher" | "lower" | "higher" | How the winner is decided: the higher value wins or the lower value wins |
How it works
- Tug-of-war divider:
useScrollprovides the scroll progress, which interpolates continuously between each round's advantage share, and a layer ofuseSpringon top gives the divider some momentum, like a rope being pulled from both sides. The advantage share is clamped between 12% and 88%, ensuring the labels on both sides always stay visible. - Running score: the score counts only the rounds up to and including the current one, and when the round changes the score springs to give that "jump" feeling. In a round with
win: "lower", the lower value takes the win. - Round value reveal: each round's metric number springs from 0 to the target value as it enters; integers stay integers, and values with decimals keep their decimals.
- Nested scrolling: the outermost container's height is
stageHeight × the number of rounds, and the stage inside is pinned withsticky top-0.containerlets it scroll inside anyoverflow-y-autoblock.
Accessibility
- When the user has "reduce motion" enabled at the system level, it is presented as static row-by-row comparison bars and a final score, no longer depending on scroll or animation.
- The visual stage is hidden from assistive technology (
aria-hidden), and a separatesr-onlyfull summary list announces both sides' values and the winner round by round. - The outermost element marks the whole duel with
role="group"plus anaria-label, making it easy for assistive technology to identify what the block is for.
Waypoint Stage
Place each passage of content by coordinate on an oversized canvas, and let scroll drive a camera that pans, zooms, and rotates along a waypoint path for a Prezi-style spatial narrative.
Data View Morph
One dataset across list, table, board, and card layouts — every entry morphs continuously to its new position with a shared-element animation, preserving the user's spatial memory.