Scrollytelling

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

The install command below needs a licence to run; without one it returns 401.

How to install Pro components →See the plans →

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add "https://webberui.com/r/data-duel-split.en.json?t=<install token>"

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

300
Active edited usageDefault props
<DataDuelSplit/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add "https://webberui.com/r/data-duel-split.en.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

PropTypeDefaultDescription
leftDuelTeam—Configuration of the left side
rightDuelTeam—Configuration of the right side
roundsDuelRound[]—Round-by-round duel data, revealed in scroll order
stageHeightstring"100vh"Stage height (a CSS length); a fixed pixel value is recommended inside a nested container
containerRefObject<HTMLElement | null>—Ref of the nested overflow scroll container; defaults to the window as scroll container
classNamestring—Class appended to the outermost container

DuelTeam

FieldTypeDefaultDescription
namestring—Side name
accentstringLeft #4f46e5 / right #e11d48Accent color (any CSS color), used for the fill, the score, and the winning value
iconReact.ReactNode—Small icon or logo shown beside the name

DuelRound

FieldTypeDefaultDescription
labelstring—Name of the metric being compared in this round
leftnumber—Value for the left side
rightnumber—Value for the right side
format(value: number) => stringtoLocaleStringValue formatting function
suffixstring—Suffix for the value, for example "%", "ms", or "k"
noteReact.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: useScroll provides the scroll progress, which interpolates continuously between each round's advantage share, and a layer of useSpring on 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 with sticky top-0. container lets it scroll inside any overflow-y-auto block.

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 separate sr-only full summary list announces both sides' values and the winner round by round.
  • The outermost element marks the whole duel with role="group" plus an aria-label, making it easy for assistive technology to identify what the block is for.