Compare Split
MarketingTwo model answers side by side in resizable, scroll-synced panes — the view for A/B-ing completions to one prompt. Hand-built (no libraries): a draggable role=separator with pointer capture and keyboard nudging, and proportional scroll sync guarded by a one-shot ignore flag so the two panes never stutter in a feedback loop. Fully keyboard-accessible and monochrome.
1280pxOpen
components/blocks/compare-split.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* CompareSplit — two model answers side by side in resizable, scroll-synced
* panes. The kind of view you use to A/B two completions to the same prompt.
*
* Two hard parts, both hand-built (no libraries):
*
* 1. **Resizable panes.** A `role="separator"` handle you drag (pointer capture,
* so the drag survives leaving the handle) or nudge with the keyboard. The
* split is a `flex-grow` ratio, so it stays fluid at any container width.
* 2. **Scroll sync without feedback stutter.** Scrolling one pane sets the
* other's `scrollTop`, which fires *its* scroll event — a feedback loop that
* stutters if unguarded. We arm a one-shot "ignore" flag on the pane we're
* about to move, and *only* when it will actually move (guarding the no-op
* case that would otherwise strand the flag). The echoed event consumes the
* flag and returns. Sync is proportional, so panes of unequal length track.
*
* Keyboard-accessible, monochrome, honors `prefers-reduced-motion`.
*/
interface Model {
id: string;
name: string;
meta: string;
answer: string[];
}
const PROMPT =
"Explain how a Bloom filter works, and when you'd reach for one.";
const MODELS: [Model, Model] = [
{
id: "atlas",
name: "Atlas 2",
meta: "1.2s · 214 tok",
answer: [
"A Bloom filter is a compact, probabilistic set. It answers one question — “have I possibly seen this key?” — using far less memory than storing the keys themselves.",
"It's a bit array of m zeros plus k independent hash functions. To add a key, hash it k ways and set those k bits to 1. To test a key, hash it the same k ways: if any of those bits is 0 it is definitely absent; if all are 1 it is probably present.",
"That asymmetry is the whole point. False negatives are impossible, but false positives happen because unrelated keys can collectively light up the same bits. The false-positive rate rises as the array fills, and you trade it off against m and k up front.",
"Reach for one as a cheap gate in front of something expensive: skip a disk or network lookup when the filter says “definitely not there.” LSM-tree databases put a Bloom filter on each SST file for exactly this reason.",
"The catch: a classic Bloom filter can't delete or count, and it can't enumerate its members. If you need deletion, look at a counting or cuckoo filter instead.",
],
},
{
id: "nova",
name: "Nova 1",
meta: "0.8s · 176 tok",
answer: [
"Think of a Bloom filter as a membership test that's allowed to say “maybe.” It never forgets something you added, but it will occasionally claim to recognize something you didn't.",
"Under the hood it's just a row of bits and a handful of hash functions. Adding an item flips a few bits on; checking an item asks whether those same bits are all on.",
"Because different items can flip overlapping bits, an “all on” result isn't proof — it's a strong hint. A “not all on” result, though, is a guarantee the item was never added.",
"The practical win is memory. You can represent millions of items in a few megabytes and get near-constant-time checks, which is why caches, databases, and crawlers use them to avoid pointless work.",
"Just remember the limits: you can't remove items or ask what's inside, and you must size it for your expected item count or the false-positive rate creeps up on you.",
],
},
];
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
const MIN_PCT = 18;
const MAX_PCT = 82;
export function CompareSplit() {
const containerRef = React.useRef<HTMLDivElement>(null);
const leftRef = React.useRef<HTMLDivElement>(null);
const rightRef = React.useRef<HTMLDivElement>(null);
// One-shot guards: true means "the next scroll event on this pane is our own
// programmatic echo — swallow it and disarm."
const ignore = React.useRef({ left: false, right: false });
const [leftPct, setLeftPct] = React.useState(50);
const [dragging, setDragging] = React.useState(false);
const [syncOn, setSyncOn] = React.useState(true);
const pctFromClientX = (clientX: number) => {
const c = containerRef.current;
if (!c) return leftPct;
const r = c.getBoundingClientRect();
return clamp(((clientX - r.left) / r.width) * 100, MIN_PCT, MAX_PCT);
};
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
};
const onHandleMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
setLeftPct(pctFromClientX(e.clientX));
};
const onHandleUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
e.currentTarget.releasePointerCapture(e.pointerId);
setDragging(false);
};
const onHandleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
let delta = 0;
if (e.key === "ArrowLeft") delta = e.shiftKey ? -8 : -2;
else if (e.key === "ArrowRight") delta = e.shiftKey ? 8 : 2;
else if (e.key === "Home") return setLeftPct(MIN_PCT);
else if (e.key === "End") return setLeftPct(MAX_PCT);
else return;
e.preventDefault();
setLeftPct((p) => clamp(p + delta, MIN_PCT, MAX_PCT));
};
const syncFrom = (
src: HTMLDivElement,
dst: HTMLDivElement,
dstKey: "left" | "right",
) => {
const srcMax = src.scrollHeight - src.clientHeight;
const dstMax = dst.scrollHeight - dst.clientHeight;
const target = srcMax > 0 ? (src.scrollTop / srcMax) * dstMax : 0;
// Only arm the guard when the destination will actually move — otherwise no
// scroll event fires to disarm it and the flag would strand.
if (Math.abs(dst.scrollTop - target) < 0.5) return;
ignore.current[dstKey] = true;
dst.scrollTop = target;
};
const onPaneScroll = (key: "left" | "right") => {
if (ignore.current[key]) {
ignore.current[key] = false; // consume our own echo
return;
}
if (!syncOn) return;
const left = leftRef.current;
const right = rightRef.current;
if (!left || !right) return;
if (key === "left") syncFrom(left, right, "right");
else syncFrom(right, left, "left");
};
return (
<section className="bg-zinc-50 px-6 py-16 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
<div className="mx-auto max-w-4xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="font-mono text-xs tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
Prompt
</div>
<p className="mt-1 max-w-xl text-zinc-950 dark:text-zinc-50">
{PROMPT}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={syncOn}
onClick={() => setSyncOn((v) => !v)}
className="inline-flex shrink-0 items-center gap-2 rounded-xl border border-zinc-200 bg-white px-3 py-1.5 text-sm text-zinc-500 transition-colors hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-50/50"
>
<span
aria-hidden="true"
data-on={syncOn}
className="dark:true]:bg-zinc-50 relative h-4 w-7 rounded-full bg-zinc-200 transition-colors data-[on=true]:bg-zinc-950 dark:bg-zinc-800"
>
<span
data-on={syncOn}
className="absolute top-0.5 left-0.5 size-3 rounded-full bg-white transition-transform data-[on=true]:translate-x-3 motion-reduce:transition-none dark:bg-zinc-900"
/>
</span>
Sync scroll
</button>
</div>
<div
ref={containerRef}
className={cn(
"mt-5 flex h-[26rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900",
dragging && "cursor-col-resize select-none",
)}
>
<Pane
ref={leftRef}
model={MODELS[0]}
grow={leftPct}
onScroll={() => onPaneScroll("left")}
/>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize panes"
aria-valuenow={Math.round(leftPct)}
aria-valuemin={MIN_PCT}
aria-valuemax={MAX_PCT}
tabIndex={0}
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onKeyDown={onHandleKey}
className={cn(
"group relative w-px shrink-0 cursor-col-resize touch-none bg-zinc-200 dark:bg-zinc-800",
"focus-visible:outline-none",
)}
>
{/* Widened invisible hit area over the 1px line. */}
<span className="absolute inset-y-0 -right-2 -left-2 z-10" />
{/* Grip, lit on hover / focus / drag. */}
<span
data-dragging={dragging}
className={cn(
"absolute top-1/2 left-1/2 z-20 flex h-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-zinc-200 bg-white px-[3px] shadow-sm dark:border-zinc-800 dark:bg-zinc-900",
"dark:true]:text-zinc-50 text-zinc-200 transition-colors group-hover:text-zinc-950 group-focus-visible:border-zinc-950 group-focus-visible:text-zinc-950 data-[dragging=true]:text-zinc-950 dark:text-zinc-800 dark:group-hover:text-zinc-50 dark:group-focus-visible:border-zinc-50 dark:group-focus-visible:text-zinc-50",
)}
>
<svg
viewBox="0 0 4 16"
width={4}
height={16}
aria-hidden="true"
fill="currentColor"
>
<circle cx={2} cy={3} r={1} />
<circle cx={2} cy={8} r={1} />
<circle cx={2} cy={13} r={1} />
</svg>
</span>
</div>
<Pane
ref={rightRef}
model={MODELS[1]}
grow={100 - leftPct}
onScroll={() => onPaneScroll("right")}
/>
</div>
</div>
</section>
);
}
interface PaneProps {
model: Model;
grow: number;
onScroll: () => void;
}
const Pane = React.forwardRef<HTMLDivElement, PaneProps>(function Pane(
{ model, grow, onScroll },
ref,
) {
return (
<div
className="flex min-w-0 flex-col"
style={{ flexGrow: grow, flexBasis: 0 }}
>
<div className="flex items-center justify-between gap-2 border-b border-zinc-200 px-4 py-2.5 dark:border-zinc-800">
<span className="truncate font-medium text-zinc-950 dark:text-zinc-50">
{model.name}
</span>
<span className="shrink-0 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{model.meta}
</span>
</div>
<div
ref={ref}
onScroll={onScroll}
aria-label={`${model.name} answer`}
tabIndex={0}
className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4 text-sm leading-relaxed text-zinc-500 focus-visible:ring-2 focus-visible:ring-zinc-950/40 focus-visible:outline-none focus-visible:ring-inset dark:text-zinc-400 dark:focus-visible:ring-zinc-50/40"
>
{model.answer.map((p, i) => (
<p
key={i}
className={i === 0 ? "text-zinc-950 dark:text-zinc-50" : undefined}
>
{p}
</p>
))}
</div>
</div>
);
});Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/compare-split.jsonInstalls the block and its component dependencies in one step.
Install dependencies
Terminal
npm install clsx tailwind-mergeCopy the source
components/blocks/compare-split.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* CompareSplit — two model answers side by side in resizable, scroll-synced
* panes. The kind of view you use to A/B two completions to the same prompt.
*
* Two hard parts, both hand-built (no libraries):
*
* 1. **Resizable panes.** A `role="separator"` handle you drag (pointer capture,
* so the drag survives leaving the handle) or nudge with the keyboard. The
* split is a `flex-grow` ratio, so it stays fluid at any container width.
* 2. **Scroll sync without feedback stutter.** Scrolling one pane sets the
* other's `scrollTop`, which fires *its* scroll event — a feedback loop that
* stutters if unguarded. We arm a one-shot "ignore" flag on the pane we're
* about to move, and *only* when it will actually move (guarding the no-op
* case that would otherwise strand the flag). The echoed event consumes the
* flag and returns. Sync is proportional, so panes of unequal length track.
*
* Keyboard-accessible, monochrome, honors `prefers-reduced-motion`.
*/
interface Model {
id: string;
name: string;
meta: string;
answer: string[];
}
const PROMPT =
"Explain how a Bloom filter works, and when you'd reach for one.";
const MODELS: [Model, Model] = [
{
id: "atlas",
name: "Atlas 2",
meta: "1.2s · 214 tok",
answer: [
"A Bloom filter is a compact, probabilistic set. It answers one question — “have I possibly seen this key?” — using far less memory than storing the keys themselves.",
"It's a bit array of m zeros plus k independent hash functions. To add a key, hash it k ways and set those k bits to 1. To test a key, hash it the same k ways: if any of those bits is 0 it is definitely absent; if all are 1 it is probably present.",
"That asymmetry is the whole point. False negatives are impossible, but false positives happen because unrelated keys can collectively light up the same bits. The false-positive rate rises as the array fills, and you trade it off against m and k up front.",
"Reach for one as a cheap gate in front of something expensive: skip a disk or network lookup when the filter says “definitely not there.” LSM-tree databases put a Bloom filter on each SST file for exactly this reason.",
"The catch: a classic Bloom filter can't delete or count, and it can't enumerate its members. If you need deletion, look at a counting or cuckoo filter instead.",
],
},
{
id: "nova",
name: "Nova 1",
meta: "0.8s · 176 tok",
answer: [
"Think of a Bloom filter as a membership test that's allowed to say “maybe.” It never forgets something you added, but it will occasionally claim to recognize something you didn't.",
"Under the hood it's just a row of bits and a handful of hash functions. Adding an item flips a few bits on; checking an item asks whether those same bits are all on.",
"Because different items can flip overlapping bits, an “all on” result isn't proof — it's a strong hint. A “not all on” result, though, is a guarantee the item was never added.",
"The practical win is memory. You can represent millions of items in a few megabytes and get near-constant-time checks, which is why caches, databases, and crawlers use them to avoid pointless work.",
"Just remember the limits: you can't remove items or ask what's inside, and you must size it for your expected item count or the false-positive rate creeps up on you.",
],
},
];
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
const MIN_PCT = 18;
const MAX_PCT = 82;
export function CompareSplit() {
const containerRef = React.useRef<HTMLDivElement>(null);
const leftRef = React.useRef<HTMLDivElement>(null);
const rightRef = React.useRef<HTMLDivElement>(null);
// One-shot guards: true means "the next scroll event on this pane is our own
// programmatic echo — swallow it and disarm."
const ignore = React.useRef({ left: false, right: false });
const [leftPct, setLeftPct] = React.useState(50);
const [dragging, setDragging] = React.useState(false);
const [syncOn, setSyncOn] = React.useState(true);
const pctFromClientX = (clientX: number) => {
const c = containerRef.current;
if (!c) return leftPct;
const r = c.getBoundingClientRect();
return clamp(((clientX - r.left) / r.width) * 100, MIN_PCT, MAX_PCT);
};
const onHandleDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
};
const onHandleMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
setLeftPct(pctFromClientX(e.clientX));
};
const onHandleUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
e.currentTarget.releasePointerCapture(e.pointerId);
setDragging(false);
};
const onHandleKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
let delta = 0;
if (e.key === "ArrowLeft") delta = e.shiftKey ? -8 : -2;
else if (e.key === "ArrowRight") delta = e.shiftKey ? 8 : 2;
else if (e.key === "Home") return setLeftPct(MIN_PCT);
else if (e.key === "End") return setLeftPct(MAX_PCT);
else return;
e.preventDefault();
setLeftPct((p) => clamp(p + delta, MIN_PCT, MAX_PCT));
};
const syncFrom = (
src: HTMLDivElement,
dst: HTMLDivElement,
dstKey: "left" | "right",
) => {
const srcMax = src.scrollHeight - src.clientHeight;
const dstMax = dst.scrollHeight - dst.clientHeight;
const target = srcMax > 0 ? (src.scrollTop / srcMax) * dstMax : 0;
// Only arm the guard when the destination will actually move — otherwise no
// scroll event fires to disarm it and the flag would strand.
if (Math.abs(dst.scrollTop - target) < 0.5) return;
ignore.current[dstKey] = true;
dst.scrollTop = target;
};
const onPaneScroll = (key: "left" | "right") => {
if (ignore.current[key]) {
ignore.current[key] = false; // consume our own echo
return;
}
if (!syncOn) return;
const left = leftRef.current;
const right = rightRef.current;
if (!left || !right) return;
if (key === "left") syncFrom(left, right, "right");
else syncFrom(right, left, "left");
};
return (
<section className="bg-zinc-50 px-6 py-16 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50">
<div className="mx-auto max-w-4xl">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="font-mono text-xs tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
Prompt
</div>
<p className="mt-1 max-w-xl text-zinc-950 dark:text-zinc-50">
{PROMPT}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={syncOn}
onClick={() => setSyncOn((v) => !v)}
className="inline-flex shrink-0 items-center gap-2 rounded-xl border border-zinc-200 bg-white px-3 py-1.5 text-sm text-zinc-500 transition-colors hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-950/50 focus-visible:outline-none dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-50/50"
>
<span
aria-hidden="true"
data-on={syncOn}
className="dark:true]:bg-zinc-50 relative h-4 w-7 rounded-full bg-zinc-200 transition-colors data-[on=true]:bg-zinc-950 dark:bg-zinc-800"
>
<span
data-on={syncOn}
className="absolute top-0.5 left-0.5 size-3 rounded-full bg-white transition-transform data-[on=true]:translate-x-3 motion-reduce:transition-none dark:bg-zinc-900"
/>
</span>
Sync scroll
</button>
</div>
<div
ref={containerRef}
className={cn(
"mt-5 flex h-[26rem] overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900",
dragging && "cursor-col-resize select-none",
)}
>
<Pane
ref={leftRef}
model={MODELS[0]}
grow={leftPct}
onScroll={() => onPaneScroll("left")}
/>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize panes"
aria-valuenow={Math.round(leftPct)}
aria-valuemin={MIN_PCT}
aria-valuemax={MAX_PCT}
tabIndex={0}
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onKeyDown={onHandleKey}
className={cn(
"group relative w-px shrink-0 cursor-col-resize touch-none bg-zinc-200 dark:bg-zinc-800",
"focus-visible:outline-none",
)}
>
{/* Widened invisible hit area over the 1px line. */}
<span className="absolute inset-y-0 -right-2 -left-2 z-10" />
{/* Grip, lit on hover / focus / drag. */}
<span
data-dragging={dragging}
className={cn(
"absolute top-1/2 left-1/2 z-20 flex h-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-zinc-200 bg-white px-[3px] shadow-sm dark:border-zinc-800 dark:bg-zinc-900",
"dark:true]:text-zinc-50 text-zinc-200 transition-colors group-hover:text-zinc-950 group-focus-visible:border-zinc-950 group-focus-visible:text-zinc-950 data-[dragging=true]:text-zinc-950 dark:text-zinc-800 dark:group-hover:text-zinc-50 dark:group-focus-visible:border-zinc-50 dark:group-focus-visible:text-zinc-50",
)}
>
<svg
viewBox="0 0 4 16"
width={4}
height={16}
aria-hidden="true"
fill="currentColor"
>
<circle cx={2} cy={3} r={1} />
<circle cx={2} cy={8} r={1} />
<circle cx={2} cy={13} r={1} />
</svg>
</span>
</div>
<Pane
ref={rightRef}
model={MODELS[1]}
grow={100 - leftPct}
onScroll={() => onPaneScroll("right")}
/>
</div>
</div>
</section>
);
}
interface PaneProps {
model: Model;
grow: number;
onScroll: () => void;
}
const Pane = React.forwardRef<HTMLDivElement, PaneProps>(function Pane(
{ model, grow, onScroll },
ref,
) {
return (
<div
className="flex min-w-0 flex-col"
style={{ flexGrow: grow, flexBasis: 0 }}
>
<div className="flex items-center justify-between gap-2 border-b border-zinc-200 px-4 py-2.5 dark:border-zinc-800">
<span className="truncate font-medium text-zinc-950 dark:text-zinc-50">
{model.name}
</span>
<span className="shrink-0 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{model.meta}
</span>
</div>
<div
ref={ref}
onScroll={onScroll}
aria-label={`${model.name} answer`}
tabIndex={0}
className="min-h-0 flex-1 space-y-3 overflow-y-auto px-4 py-4 text-sm leading-relaxed text-zinc-500 focus-visible:ring-2 focus-visible:ring-zinc-950/40 focus-visible:outline-none focus-visible:ring-inset dark:text-zinc-400 dark:focus-visible:ring-zinc-50/40"
>
{model.answer.map((p, i) => (
<p
key={i}
className={i === 0 ? "text-zinc-950 dark:text-zinc-50" : undefined}
>
{p}
</p>
))}
</div>
</div>
);
});lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}