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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
paragraphs | ParallelColumnParagraph[] | — | The parallel content: paragraphs on the outside, sentence pairs inside |
leftLabel | ReactNode | — | Left column heading |
rightLabel | ReactNode | — | Right column heading |
activeId | string | null | — | Controlled mode: the id of the highlighted sentence (passing it enters controlled mode) |
defaultActiveId | string | null | null | Initial highlighted sentence id in uncontrolled mode |
onActiveChange | (id: string | null) => void | — | Fires when the highlighted sentence changes |
trackScroll | boolean | true | While scrolling, track the paragraph closest to the center and give it the active state |
container | RefObject<HTMLElement | null> | — | Nested scroll container ref; omit it to use the window |
interactive | boolean | true | Whether sentence hover / focus / click linking is enabled |
stickyHeader | boolean | true | Whether the column headings stick to the top of the scroll container |
columnClassName | string | — | Class applied to each column cell |
headerClassName | string | — | Class applied to the heading row |
className | string | — | 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 thatid, 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
trackScrollon, a scroll listener throttled byrequestAnimationFramecomputes 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 throughonActiveChange.
Accessibility
- The whole comparison block is a
role="group"with anaria-label, and the heading row labels the language / version of each column. - Each sentence is a focusable linking trigger (
role="button",tabIndex=0) supportingEnter/Spaceto pin; the active one carriesaria-currentandaria-pressed, and there is afocus-visibleoutline. prefers-reduced-motionis 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.
Synced Outline Reader
A two-column reading layout of content plus outline, where an indicator spine stretches and slides continuously with the reading position and the current section comes into focus in sync.
Archive Index
A studio-archive-style full-page index table — multiple sortable columns that reorder with FLIP, and rows that expand into full-width detail while the rest elastically make way.