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.
Installation
npx shadcn@latest add https://craftbits.dev/r/expand-shrink.jsonUsage
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
- Window invariant. The window is two inclusive indices,
start(left, L) andend(right, R). Whenendis less thanstart, the window is empty and no cells are shaded; the caption falls back to awindow emptylabel. - Four canonical actions.
expand-rightincrementsend;shrink-leftincrementsstart;expand-leftdecrementsstart;shrink-rightdecrementsend. Each is clamped so the window stays inside the array bounds (or collapses to empty). Buttons that would no-op are disabled. - Pointer labels tween.
LandRlabels mount inside a one-slot-per-cell lane above the row; when the underlying index changes,AnimatePresenceslides 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 asLR. - 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.
- Sum is derived. The caption recomputes whenever the window or array changes; override the formatter via
formatCaptionfor any other shape of readout. - Controlled + uncontrolled.
window+onStepis the canonical Radix controlled pattern;defaultWindowlets the component own the window internally. - Reduced motion. When
prefers-reduced-motion: reduceis set, pointer transitions, band scaling, and button tap feedback collapse to instant.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
array | readonly number[] | required | Numeric 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) => void | — | Fires 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. |
title | ReactNode | — | Optional title rendered above the cells. |
leftLabel | string | "L" | Label for the left pointer. |
rightLabel | string | "R" | Label for the right pointer. |
hideControls | boolean | false | Hide the built-in action buttons. |
hideCaption | boolean | false | Hide the live sum / length readout. |
formatCaption | (state) => ReactNode | — | Override the caption with a caller-formatted node. |
cellSize | number | 44 | Ideal cell size in px. Shrinks responsively. |
compact | boolean | false | Smaller cells, tighter row. |
transition | Transition | SPRINGS.smooth | Override the pointer / band transition. |
className | string | — | Merged onto the outer <div> via cn(). |
Accessibility
- The root carries
role="img"witharia-roledescription="sliding window"and anaria-labelsummarising array length, current window, and sum on every render. - The action button cluster is
role="group"; each button has an explicitaria-labeldescribing 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-visiblefocus 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.