WebberUI

Parallel Text Columns

A side-by-side comparison layout — bilingual or two-version content on facing columns, paired at the sentence level, auto-aligned on scroll with linked hover highlighting.

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.

How to install Pro components →See the plans →

Loading preview…
npx shadcn@latest add "https://webberui.com/r/parallel-text-columns.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.

<ParallelTextColumns />

Installation

npx shadcn@latest add "https://webberui.com/r/parallel-text-columns.json?t=<install token>"

Or, once registries are configured in components.json, install it as @webberui/parallel-text-columns.

Usage

Content is structured with paragraphs on the outside and sentence pairs inside. The left and right sentences sharing an id are linked: hover a sentence in either column and the matching sentence in the other column highlights with the same background.

import {
  ParallelTextColumns,
  type ParallelColumnParagraph,
} from "@/components/ui/parallel-text-columns";

const paragraphs: ParallelColumnParagraph[] = [
  {
    id: "p1",
    segments: [
      { id: "s1", left: "The city wakes slowly.", right: "城市緩緩甦醒。" },
      { id: "s2", left: "Trams hum along the avenues.", right: "電車沿著大道低鳴。" },
    ],
  },
];

<ParallelTextColumns
  paragraphs={paragraphs}
  leftLabel="English"
  rightLabel="中文"
/>;

If the content sits inside a nested scroll container, pass that container's ref via container (you can omit it for page-level scrolling). While scrolling, the paragraph closest to the center automatically takes the active state:

const scrollerRef = React.useRef<HTMLDivElement>(null);

<div ref={scrollerRef} className="h-[70vh] overflow-y-auto">
  <ParallelTextColumns
    paragraphs={paragraphs}
    container={scrollerRef}
    trackScroll
  />
</div>;

Controlled mode: pass activeId and the highlighted sentence is driven from the outside (for example synced to audio playback progress), while hover and focus still report intent through onActiveChange:

const [activeId, setActiveId] = React.useState<string | null>(null);

<ParallelTextColumns
  paragraphs={paragraphs}
  activeId={activeId}
  onActiveChange={setActiveId}
/>;

Props

PropTypeDefaultDescription
paragraphsParallelColumnParagraph[]The parallel content: paragraphs on the outside, sentence pairs inside
leftLabelReactNodeLeft column heading
rightLabelReactNodeRight column heading
activeIdstring | nullControlled mode: the id of the highlighted sentence (passing it enters controlled mode)
defaultActiveIdstring | nullnullInitial highlighted sentence id in uncontrolled mode
onActiveChange(id: string | null) => voidFires when the highlighted sentence changes
trackScrollbooleantrueWhile scrolling, track the paragraph closest to the center and give it the active state
containerRefObject<HTMLElement | null>Nested scroll container ref; omit it to use the window
interactivebooleantrueWhether sentence hover / focus / click linking is enabled
stickyHeaderbooleantrueWhether the column headings stick to the top of the scroll container
columnClassNamestringClass applied to each column cell
headerClassNamestringClass applied to the heading row
classNamestringClass on the root container

Types

interface ParallelSegment {
  id: string; // pairing identifier; the two columns link by it
  left: React.ReactNode;
  right: React.ReactNode;
}

interface ParallelColumnParagraph {
  id?: string;
  segments: ParallelSegment[];
}

How it works

  • The layout is a two-column CSS Grid: every paragraph emits a left and a right cell that land on the same row, so the tops of corresponding paragraphs align automatically no matter how long the text on either side is.
  • Sentence linking is driven by the shared id: hovering (or focusing) a sentence in either column applies the highlight background to both sentences with that id, creating a visual "link" across the columns.
  • Touch devices have no hover, so the same linking is achieved by tap-to-pin: tap once to pin, tap again to release.
  • With trackScroll on, a scroll listener throttled by requestAnimationFrame computes which paragraph is closest to the container's center and gives it the active background and the connecting axis between the columns; only one paragraph is active at a time.
  • Controlled and uncontrolled modes: with no activeId, the highlight state is managed internally (hover takes priority over pinning); pass it and you own the state, while interactions still report through onActiveChange.

Accessibility

  • The whole comparison block is a role="group" with an aria-label, and the heading row labels the language / version of each column.
  • Each sentence is a focusable linking trigger (role="button", tabIndex=0) supporting Enter / Space to pin; the active one carries aria-current and aria-pressed, and there is a focus-visible outline.
  • prefers-reduced-motion is respected: the connecting axis transition and the sentence background CSS transition are disabled, and the active state switches instantly — column alignment and the linking behavior are unchanged.
  • Set interactive={false} to turn interaction off and use it as a pure comparison display layout.

On this page