Layout Composition

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

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/parallel-text-columns.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.

Active edited usageDefault props
<ParallelTextColumns/>

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/parallel-text-columns.en.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
leftLabelReactNode—Left column heading
rightLabelReactNode—Right column heading
activeIdstring | null—Controlled mode: the id of the highlighted sentence (passing it enters controlled mode)
defaultActiveIdstring | nullnullInitial highlighted sentence id in uncontrolled mode
onActiveChange(id: string | null) => void—Fires 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
columnClassNamestring—Class applied to each column cell
headerClassNamestring—Class applied to the heading row
classNamestring—Class 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.