Perpendicular Finder
A drag-to-discover visualisation of vector perpendicularity via the dot product. The learner moves a free vector b around a 2D integer grid while a live readout of a · b acts as a compass. When the product hits exactly zero, a right-angle marker springs in at the origin and the narration explains why.
For the default reference vector a = [4, 3], the integer perpendicular solutions on the −6…6 grid are [3, −4] and [−3, 4]. The component generalises: pass any a and the ghost-hint arrow snaps to the nearest integer rotation (ay, −ax) or (−ay, ax).
a · b = 4×2 + 3×2 = 14
Vector a = [4, 3] is fixed. Drag the second arrow until the dot product readout hits exactly 0. What angle will that make?
Installation
npx shadcn@latest add https://craftbits.dev/r/perpendicular-finder.jsonUsage
import { PerpendicularFinder } from "@craft-bits/viz/perpendicular-finder";
<PerpendicularFinder />Controlled — own the state of b:
import { useState } from "react";
import {
PerpendicularFinder,
type PerpendicularFinderVec,
} from "@craft-bits/viz/perpendicular-finder";
function Demo() {
const [vec, setVec] = useState<PerpendicularFinderVec>({ x: 2, y: 2 });
return <PerpendicularFinder vec={vec} onVecChange={setVec} />;
}Subscribe to the moment of discovery:
<PerpendicularFinder
onFound={(b) => analytics.track("perpendicular-found", b)}
/>Understanding the component
- The grid and the two vectors. Centred 2D grid spanning
domain.min…domain.maxwith dashed axes. The fixed vectorais drawn in muted ink, the draggablebin the accent palette. Both originate at the origin. - The dot-product readout. Below the canvas, a monospaced strip prints
a · b = ax·bx + ay·by = <value>. The value is tweened bymotion's imperativeanimate()onSPRINGS.snap(overridable viatransition) so the digit changes smoothly without re-rendering the SVG. - Phase machine.
idle→searching→warm→hot→found, driven bywarmThreshold(default8) andhotThreshold(default3). - Drag and keyboard. The drag hit-target is an oversized invisible circle around the tip of
b, exposed as arole="slider". Arrow keys movebbykeyboardStep(Shiftdoubles it). - The discovery moment. When
a · b = 0away from the origin, a right-angle arc springs in onSPRINGS.bouncy, a faint perpendicular line extends across the grid, a pulse ring breathes at the tip ofb, and the narration explains the why. - The ghost hint. After
hintDelayMs(default15000) of dragging without finding a solution, a low-opacity arrow points toward the nearest integer perpendicular. PasshintDelayMs={0}to disable. - Reduced motion. Under
prefers-reduced-motion: reduce, every spring collapses to an instant attribute set.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
a | { x: number; y: number } | { x: 4, y: 3 } | The fixed reference vector. |
vec | { x: number; y: number } | — | Controlled position of b. Pair with onVecChange. |
defaultVec | { x: number; y: number } | { x: 2, y: 2 } | Initial position when uncontrolled. |
onVecChange | (next) => void | — | Fires on drag and arrow-key updates. |
onFound | (vec) => void | — | Fires once when a · b first transitions to zero away from the origin. |
onReset | () => void | — | Fires when Try Again is clicked. |
domain | { min: number; max: number } | { min: -6, max: 6 } | Mathematical range along each axis. |
snapStep | number | 1 | Snap step for drag (math units). |
keyboardStep | number | 1 | Per-arrow-press step. Shift doubles. |
warmThreshold | number | 8 | ` |
hotThreshold | number | 3 | ` |
hintDelayMs | number | 15000 | Milliseconds before the ghost-hint arrow. 0 disables. |
width | number | 360 | Width of the SVG viewBox. |
height | number | 360 | Height of the SVG viewBox. |
transition | Transition | SPRINGS.snap | Override the spring used by the readout. |
className | string | — | Merged onto the root via cn(). |
Accessibility
- The SVG is
role="img"with anaria-labelreportinga,b, the current dot product, and whether perpendicular has been found. - The drag handle is a
role="slider"element with a position-specificaria-labelandaria-valuetext. Reachable via Tab, draggable with the pointer, movable withArrowUp/ArrowDown/ArrowLeft/ArrowRight(holdShiftfor2×step). - The narration paragraph is
aria-live="polite"and mutes during drag. - Colour is never the only signal: phase is encoded in narration prose and the
data-phaseattribute on the root. - Focus state is rendered as a visible accent-toned ring around the drag handle.
- Motion respects
prefers-reduced-motion: reduce.
Credits
- Extracted from:
craftingattention(app/src/lessons/primitives/math/PerpendicularFinder.tsx). The source was a lesson-bound component — it importedSvgLabelfrom the lesson chrome, hardcodeda = [4, 3], and depended on per-track palette tokens. The viz extract dropsSvgLabel, generalisesa,domain, the warm/hot thresholds, the hint delay, and the snap/keyboard steps into props, exposes a Radix-style controlled/uncontrolledvec+onVecChangeAPI, surfaces the discovery viaonFound, swaps the non-existentSPRINGS.snappyforSPRINGS.snap, and remaps every colour tovar(--cb-*)semantic tokens so consumer themes repaint freely.