Swap Animator

A linear row of values plus an ordered list of [fromIdx, toIdx] swap pairs. Advance currentStep from 0 (nothing swapped) to swaps.length (every swap applied) and the component replays the swaps onto the array, morphing each cell to its new slot via a shared layoutId. Drives selection sort, bubble sort, the quicksort partition step, and any in-place index-pair-swap algorithm.

Fully controlled. (values, swaps, currentStep) is the source of truth — the same triple always produces the same layout. No internal history, no race conditions, no off-by-one drift.

Starting array: 5, 2, 8, 1, 9, 3.
Initial array
0 / 5

Installation

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

Usage

import { SwapAnimator } from "@craft-bits/core";
 
<SwapAnimator
  values={[5, 2, 8, 1, 9, 3]}
  swaps={[[0, 3], [1, 5], [2, 5], [3, 4], [4, 5]]}
  currentStep={2}
/>

Step through a swap trace from a parent reducer or timer:

const [step, setStep] = useState(0);
const swaps = useMemo(() => selectionSortTrace(values), [values]);
 
<SwapAnimator
  values={values}
  swaps={swaps}
  currentStep={step}
/>

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

<SwapAnimator values={values} swaps={swaps} currentStep={step} tone="warning" />

Understanding the component

  1. Pure step replay. The plan is derived from (values, swaps, currentStep) alone. Going forward and backward in currentStep is symmetric — there is no internal history to keep in sync.
  2. Shared-layout morph. Each entry carries a stable layoutId that follows the value across every swap. When currentStep advances, Framer Motion morphs the cell from its old slot to its new slot in a single spring rather than unmount-remounting in place.
  3. Active-pair highlight. The two indices participating in the swap at currentStep are tinted with the selected tone and gain a thicker inset ring. Every other cell stays neutral.
  4. Five tones. default is neutral; accent is the library default; success is "winning swap"; warning is "watch this"; error is "wrong swap". The active cells also gain a contrasting fill, so colourblind users see the highlight.
  5. Hit target. Every cell is at least 44 by 44 px regardless of the cellSize prop, so narrow visual cells still satisfy WCAG 2.5.8 AAA on touch screens.
  6. Reduced motion. The shared-layout morph collapses to instant under prefers-reduced-motion: reduce. The swap still applies; only the motion drops.

Props

PropTypeDefaultDescription
valuesnumber[]requiredStarting array. Swaps are applied to a copy.
swaps[number, number][]requiredOrdered list of index-pair swaps.
currentStepnumberrequiredHow many swaps to have applied so far. Clamped to swaps length.
tone"default" | "accent" | "success" | "warning" | "error""accent"Highlight palette for the active swap pair.
showIndicesbooleantrueShow a small index label below each cell.
cellSizenumber44Cell width / height in pixels.
cellGapnumber6Gap between cells in pixels.
captionReactNodeOptional caption rendered above the row.
transitionTransitionSPRINGS.smoothOverride the swap transition. Reduced-motion users snap regardless.
classNamestringMerged onto the root via cn().

Accessibility

  • A visually-hidden aria-live="polite" region narrates the current swap pair and the running array, so screen readers stay current as currentStep advances.
  • Every cell carries role="img" and an explicit aria-label naming the slot index, value, and whether it is participating in the current swap.
  • The root exposes data-state (swapping / complete) and data-tone, and every cell exposes data-state (active / rest) so consumer apps can hook custom styles or assistive tooling.
  • Each cell is at least 44 by 44 px regardless of the cellSize prop so narrow visual cells still satisfy WCAG 2.5.8 AAA on touch screens.
  • Motion respects prefers-reduced-motion: reduce — the shared-layout morph collapses to instant. The swap still applies; only the motion drops.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/viz/SwapAnimator.tsx). The source was an imperative overlay animator that measured DOM rects via querySelector and floated absolutely-positioned overlay divs from one cell to another. The library extract collapses to a fully declarative (values, swaps, currentStep) triple and uses Framer Motion's shared layoutId instead of manual rect measurement — so the same component covers parent-driven scrubbing, autoplay timers, and reverse playback from one API.