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).

Perpendicular finder on a 2D grid.

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?

not found yet
Customize
Reference vector a
4
3
Palette thresholds
8
3
Behaviour

Installation

npx shadcn@latest add https://craftbits.dev/r/perpendicular-finder.json

Usage

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

  1. The grid and the two vectors. Centred 2D grid spanning domain.min…domain.max with dashed axes. The fixed vector a is drawn in muted ink, the draggable b in the accent palette. Both originate at the origin.
  2. The dot-product readout. Below the canvas, a monospaced strip prints a · b = ax·bx + ay·by = <value>. The value is tweened by motion's imperative animate() on SPRINGS.snap (overridable via transition) so the digit changes smoothly without re-rendering the SVG.
  3. Phase machine. idlesearchingwarmhotfound, driven by warmThreshold (default 8) and hotThreshold (default 3).
  4. Drag and keyboard. The drag hit-target is an oversized invisible circle around the tip of b, exposed as a role="slider". Arrow keys move b by keyboardStep (Shift doubles it).
  5. The discovery moment. When a · b = 0 away from the origin, a right-angle arc springs in on SPRINGS.bouncy, a faint perpendicular line extends across the grid, a pulse ring breathes at the tip of b, and the narration explains the why.
  6. The ghost hint. After hintDelayMs (default 15000) of dragging without finding a solution, a low-opacity arrow points toward the nearest integer perpendicular. Pass hintDelayMs={0} to disable.
  7. Reduced motion. Under prefers-reduced-motion: reduce, every spring collapses to an instant attribute set.

Props

PropTypeDefaultDescription
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) => voidFires on drag and arrow-key updates.
onFound(vec) => voidFires once when a · b first transitions to zero away from the origin.
onReset() => voidFires when Try Again is clicked.
domain{ min: number; max: number }{ min: -6, max: 6 }Mathematical range along each axis.
snapStepnumber1Snap step for drag (math units).
keyboardStepnumber1Per-arrow-press step. Shift doubles.
warmThresholdnumber8`
hotThresholdnumber3`
hintDelayMsnumber15000Milliseconds before the ghost-hint arrow. 0 disables.
widthnumber360Width of the SVG viewBox.
heightnumber360Height of the SVG viewBox.
transitionTransitionSPRINGS.snapOverride the spring used by the readout.
classNamestringMerged onto the root via cn().

Accessibility

  • The SVG is role="img" with an aria-label reporting a, b, the current dot product, and whether perpendicular has been found.
  • The drag handle is a role="slider" element with a position-specific aria-label and aria-valuetext. Reachable via Tab, draggable with the pointer, movable with ArrowUp / ArrowDown / ArrowLeft / ArrowRight (hold Shift for 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-phase attribute 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 imported SvgLabel from the lesson chrome, hardcoded a = [4, 3], and depended on per-track palette tokens. The viz extract drops SvgLabel, generalises a, domain, the warm/hot thresholds, the hint delay, and the snap/keyboard steps into props, exposes a Radix-style controlled/uncontrolled vec + onVecChange API, surfaces the discovery via onFound, swaps the non-existent SPRINGS.snappy for SPRINGS.snap, and remaps every colour to var(--cb-*) semantic tokens so consumer themes repaint freely.