Merge Animator

Two sorted source arrays — left and right — sit side-by-side over a merged output row. Advance the step prop from 0 (nothing merged) to left.length + right.length (fully merged) and the component picks the smaller of the two head pointers (ties favour the left) and morphs the chosen cell from its source position down to the output row via shared layoutId. Drives the merge-sort merge phase, the "merge two sorted lists" interview problem, and any side-by-side consume-the-smaller-head animation.

Controlled via step and onStepChange, uncontrolled via defaultStep. The component is interactive by default — the head cell on the "correct" side is tappable and advances step on click. Disable interactive to drive the merge purely from a parent (e.g. an autoplay timer or a parent reducer).

Merging left [1, 4, 7, 9] and right [2, 3, 6, 8]. Merged so far: empty.
Left
Right
Merged
0 / 8
Customize
Layout
44
4
Highlight
1

Installation

npx shadcn@latest add https://craftbits.dev/r/merge-animator.json

Usage

import { MergeAnimator } from "@craft-bits/core";
 
<MergeAnimator
  left={[1, 4, 7, 9]}
  right={[2, 3, 6, 8]}
/>

Controlled — parent owns the step, tap or auto-advance to merge:

const [step, setStep] = useState(0);
 
<MergeAnimator
  left={[1, 4, 7]}
  right={[2, 3, 6]}
  step={step}
  onStepChange={setStep}
/>

Read-only autoplay-driven mode — the parent advances step on a timer; the component never accepts taps:

<MergeAnimator
  left={left}
  right={right}
  step={step}
  interactive={false}
/>

Highlight the active head in a warning tone for a "watch this" beat:

<MergeAnimator left={left} right={right} tone="warning" />

Understanding the component

  1. Pure step replay. The merge plan is derived from (left, right, step) alone — the same triple always produces the same leftPtr, rightPtr, and ordered pick sequence. No internal history, no race conditions, no off-by-one drift.
  2. Shared-layout morph. Every source cell carries a stable layoutId. When step advances, the picked cell unmounts from the source row and mounts in the output row under the same layoutId, so Framer Motion morphs the position in a single spring instead of an unmount-remount hard cut.
  3. Tie-break: left wins. When the two head values are equal, the left pointer advances first — matches the stable behaviour of merge-sort's standard merge step.
  4. AnimatePresence on output. The output row is wrapped in <AnimatePresence mode="popLayout"> so the cells settle into their final positions via spring, and any consumer-driven reset (step back to zero) plays a graceful exit before the row goes empty.
  5. Five tones. default reads as "neutral pick"; accent as "currently relevant" (the default); success as "winning pick"; warning as "watch this head"; error as "wrong side". The tone paints only the active head cell.
  6. Hit target. Every source cell is at least 44 by 44 px regardless of the cellSize prop, so narrow visual cells still satisfy WCAG 2.5.8 on touch screens.
  7. Reduced motion. The shared-layout morph, the cell enter / exit, and the completion chip all collapse to instant under prefers-reduced-motion: reduce. The merge still advances; only the motion drops.

Props

PropTypeDefaultDescription
leftnumber[]requiredLeft source array, sorted ascending.
rightnumber[]requiredRight source array, sorted ascending.
stepnumberControlled merge progress. Pair with onStepChange.
defaultStepnumber0Uncontrolled initial step.
onStepChange(next: number) => voidFires with the next step whenever the merge advances.
interactivebooleantrueWhen false, head cells are not clickable or focusable.
tone"default" | "accent" | "success" | "warning" | "error""accent"Highlight palette for the active head + completion chip.
leftLabelstring"Left"Column header for the left source.
rightLabelstring"Right"Column header for the right source.
outputLabelstring"Merged"Column header for the output row.
cellSizenumber44Cell width / height in pixels.
cellGapnumber4Gap between cells in pixels.
transitionTransitionSPRINGS.smoothOverride cell transitions. Reduced-motion users snap regardless.
classNamestringMerged onto the root via cn().

Accessibility

  • A visually-hidden aria-live="polite" region narrates the current source arrays and the merged-so-far sequence, so screen readers stay current as the merge advances.
  • Every interactive head cell is a real <button> with an explicit aria-label naming the side, index, value, and current state (consumed, head, waiting), plus aria-pressed to flag the next-pick head.
  • Space / Enter keyboard activation mirrors click-to-advance; non-tappable cells are removed from the tab order via tabIndex of -1.
  • Each source cell is at least 44 by 44 px regardless of the cellSize prop so narrow cells still satisfy WCAG 2.5.8 AAA on touch screens.
  • The root exposes data-state (merging / complete) and data-tone, and every cell exposes data-state (consumed / head / waiting) and data-side so consumer apps can hook custom styles or assistive tooling.
  • Tone is never the only signal — the active head also gains a thicker inset ring and a contrasting fill, so colourblind users see the highlight even when the tone hue is hard to discriminate.
  • Motion respects prefers-reduced-motion: reduce — the shared-layout morph and the completion chip collapse to instant. The merge still advances; only the motion drops.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/construction/MergeAnimator.tsx). The source coupled the merge visual to a pickLeft / pickRight reducer, a per-track audio cue, a typed MergeItem shape with id and label fields, and a manual output array driven by the lesson reducer. The library extract simplifies to two plain number[] arrays plus a single step index — the merge plan is derived purely from that triple, so the same component covers parent-driven autoplay, student-driven taps, and a hands-off controlled scrubber from one API.