Expand Shrink

A sliding-window playback primitive. The caller supplies a numeric array and a window of two inclusive pointers (start and end); the component draws the array as a row of cells, shades the cells inside the window, floats L and R pointer labels above the endpoints, and prints a live sum / length readout below. Four built-in action buttons — expand L, shrink L, shrink R, expand R — drive the window when no external driver is wired in.

Pure visualization — no protocol logic, no validity gating, no scoring. Controlled (window + onStep) and uncontrolled (defaultWindow) on the Radix pattern. Drop into any sliding-window narrative — minimum-window-substring, longest-substring-without-repeating, minimum-length-subarray, or any other two-pointer expand / shrink template — and layer narration, predictions, or score around it.

Customize
Appearance
1
Chrome

Installation

npx shadcn@latest add https://craftbits.dev/r/expand-shrink.json

Usage

import { ExpandShrink } from "@craft-bits/core";
 
<ExpandShrink array={[2, 3, 1, 2, 4, 3]} defaultWindow={{ start: 0, end: 2 }} />

Controlled — the parent owns the window so it can record actions, pair the visualization with an external code panel, or persist the cursor:

const [win, setWin] = useState({ start: 0, end: 2 });
 
<ExpandShrink
  array={values}
  window={win}
  onStep={(step) => setWin(step.next)}
/>

Uncontrolled, chrome stripped — surface a frozen window the parent drives from outside:

<ExpandShrink
  array={values}
  defaultWindow={{ start: 1, end: 4 }}
  hideControls
  hideCaption
/>

Understanding the component

  1. Window invariant. The window is two inclusive indices, start (left, L) and end (right, R). When end is less than start, the window is empty and no cells are shaded; the caption falls back to a window empty label.
  2. Four canonical actions. expand-right increments end; shrink-left increments start; expand-left decrements start; shrink-right decrements end. Each is clamped so the window stays inside the array bounds (or collapses to empty). Buttons that would no-op are disabled.
  3. Pointer labels tween. L and R labels mount inside a one-slot-per-cell lane above the row; when the underlying index changes, AnimatePresence slides the old slot out and the new one in with a single critically-damped spring. When both pointers land on the same cell, the slot stacks them as LR.
  4. Cells shade by membership. Cells inside the window carry full opacity plus a tone-coloured ring; cells outside dim with a muted border. Endpoint cells get a thicker ring so the boundary reads on top of the band.
  5. Sum is derived. The caption recomputes whenever the window or array changes; override the formatter via formatCaption for any other shape of readout.
  6. Controlled + uncontrolled. window + onStep is the canonical Radix controlled pattern; defaultWindow lets the component own the window internally.
  7. Reduced motion. When prefers-reduced-motion: reduce is set, pointer transitions, band scaling, and button tap feedback collapse to instant.

Props

PropTypeDefaultDescription
arrayreadonly number[]requiredNumeric array under the window.
window{ start: number; end: number }Controlled window. Pair with onStep. Clamped to the array bounds.
defaultWindow{ start: number; end: number }{ start: 0, end: -1 }Uncontrolled initial window. end < start means empty.
onStep(step) => voidFires after every action with the action name plus prev and next windows.
tone"default" | "accent" | "success" | "warning" | "error""accent"Tone for the band, pointer labels, and sum readout.
titleReactNodeOptional title rendered above the cells.
leftLabelstring"L"Label for the left pointer.
rightLabelstring"R"Label for the right pointer.
hideControlsbooleanfalseHide the built-in action buttons.
hideCaptionbooleanfalseHide the live sum / length readout.
formatCaption(state) => ReactNodeOverride the caption with a caller-formatted node.
cellSizenumber44Ideal cell size in px. Shrinks responsively.
compactbooleanfalseSmaller cells, tighter row.
transitionTransitionSPRINGS.smoothOverride the pointer / band transition.
classNamestringMerged onto the outer <div> via cn().

Accessibility

  • The root carries role="img" with aria-roledescription="sliding window" and an aria-label summarising array length, current window, and sum on every render.
  • The action button cluster is role="group"; each button has an explicit aria-label describing the direction.
  • The caption is aria-live="polite" so screen-reader users hear the sum / length update on each window change without focus stealing.
  • Every action button enforces the WCAG 2.5.8 AAA minimum 44 by 44 px hit area, with visible :focus-visible focus rings.
  • Window membership is conveyed by ring colour, ring width, and opacity — never colour alone.
  • Motion respects prefers-reduced-motion: reduce.

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/observation/ExpandShrink.tsx). The source was a 1992-line four-phase lesson bundling a brute-force budget meter, a discovery moment, an expand / shrink decision engine with per-step prediction gates, a three-step code morph, a micro-quiz, audio cues, and scoring rollups. The library extract strips every lesson concern and keeps only the geometric primitive: the array plus window pair, the L / R pointer band, the shaded cells, and the four canonical sliding-window operations.