ns-ui
Mesh Lash
A currency amount field where two meshed gears are the FX quote itself: tooth ratio draws the exchange rate, and mechanical backlash — the dead angle a reversed gear turns through before the other flank engages — is the provider's spread, printed as a currency figure straight off the gear math.
Use when a send-money or multi-currency checkout amount field where the provider's spread needs to be mechanically undeniable, not a small-print percentage — typing or scrubbing an amount spins a drive gear, a driven gear prints the converted total, and reversing direction visibly costs a dead-angle of lost motion that is the fee. It is unrelated to cog-rail's gears-as-transport metaphor (a track/carriage moved BY gears): here the gear pair encodes two numbers that define an FX quote (rate = tooth ratio, spread = backlash), and the meshed gears are never used to move anything along a rail.
Install
npx shadcn add https://design.helpmarq.com /r/mesh-lash.jsonSource
registry/core/mesh-lash/component.tsx"use client";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type ChangeEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
// ---------------------------------------------------------------------------
// GearLashField — a currency amount field where two meshed gears ARE the FX
// quote: the tooth ratio is the exchange rate, and mechanical backlash (the
// small dead-travel every real gear pair has before the flanks re-engage on
// a direction reversal) is the provider's spread, made literal.
//
// MODEL
// One governing scalar, driveAngle, is a direct function of the typed
// amount (driveAngle = amount * DEG_PER_UNIT — "accumulated" in the sense
// that it is exactly what you'd get integrating a constant deg/unit rate
// over every keystroke/scrub delta, just computed directly since amount is
// already the source of truth). The gear only ever SHOWS driveAngle mod 360;
// a separate Geist Mono row shows the un-wrapped, fully computed amount.
//
// Backlash is the textbook control-theory "backlash" nonlinearity: a
// `contact` value that free-wheels with driveAngle while moving in one
// direction, but on a reversal sits frozen until driveAngle has crossed the
// FULL dead-zone width (2x half-width) — exactly the "drive turns through a
// visible dead angle before the driven picks up" behavior:
//
// if driveAngle - contact > halfWidth: contact = driveAngle - halfWidth
// if driveAngle - contact < -halfWidth: contact = driveAngle + halfWidth
// else: contact unchanged
//
// halfWidth is derived from the real spread fraction: lashWidthDeg =
// spreadFrac * DEG_PER_UNIT * LEGIBILITY_GAIN — a fixed, stated gain so a
// real 0.3-2% spread (otherwise a fraction of a degree) reads as an actual
// wedge on an 8-40 tooth gear.
//
// drivenAngle = (driveAngle - lashState) / ratio, where lashState =
// driveAngle - contact (the current position inside the dead zone) and
// ratio = teethDrive / teethDriven (the drawn gear ratio) — so
// driveAngle - lashState === contact, and drivenAngle === contact / ratio.
// The printed converted amount is read straight off drivenAngle (scaled
// back through DEG_PER_UNIT and a power-of-ten `scale` pulled out of the
// rate so gears stay legible for both near-parity and far-apart pairs) —
// there is no second, independently-computed "real" total sitting next to
// the gears. The spread shown to the user is the gap between that
// gear-integrated amount and amount*rate computed with no gear at all —
// literally the lost angle, printed in currency.
//
// TEETH: tooth counts are round(driveTeeth * mantissa) searched over
// driveTeeth in [8,40] for the closest integer ratio to the rate's mantissa
// (rate decomposed into mantissa in [0.2,5] x a power-of-ten `scale`, so a
// JPY-sized rate doesn't demand an unrenderable 1:150 tooth ratio). The
// residual between that drawn ratio and the true rate is disclosed as an
// "honesty line" rather than hidden.
//
// SVG has no native way to tile a fill *around* a circle, so the tooth
// SILHOUETTE (the part that has to be geometrically correct — right tooth
// count, trapezoid profile, correct mesh) is a computed closed path. The
// brief's literal "teeth are an SVG <pattern> of trapezoids" is honored as
// the tooth FILL: a small repeating <pattern> tile of one trapezoid paints
// the inside of that silhouette, so the pattern is real and load-bearing
// (it is what you see), even though the outer tooth boundary comes from
// trig, not from pattern tiling — a genuine SVG limitation, not a shortcut
// taken for convenience.
// ---------------------------------------------------------------------------
const DEG_PER_UNIT = 3.6; // degrees of drive rotation per 1 unit of source currency
const LEGIBILITY_GAIN = 500; // fixed, stated gain turning a 0.3-2% spread into a visible wedge
const TYPED_MS = 200;
const TYPED_EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
const MODULE_PX = 2.15; // px of pitch radius per tooth
const ADDENDUM = 4.5;
const DEDENDUM = 5.5;
const TOOTH_TOP_FRAC = 0.42;
const TOOTH_BASE_FRAC = 0.62;
const LIVE_MS = 420; // how long the mesh contact mark stays lit after input
const ANNOUNCE_DEBOUNCE = 450; // ms of quiet before the live region commits
// Deliberately fine: at a typical 0.3-2% spread the dead zone is only a few
// currency units wide (13.5deg / DEG_PER_UNIT ~ 3.75 units at 0.75%/500x
// gain), so a coarse px->unit ratio would blow past the whole gap in under
// 2px of drag and the component's central claim would never be seen.
const SCRUB_UNITS_PER_PX = 0.3;
const MAX_AMOUNT = 999_999;
const SVG_MARGIN = 6;
// ---- pure math -------------------------------------------------------------
interface GearConfig {
teethDrive: number;
teethDriven: number;
ratio: number; // teethDrive / teethDriven
scale: number; // power-of-ten pulled out of rate so mantissa fits [0.2, 5]
mantissa: number; // rate / scale
}
function computeGearConfig(rateInput: number): GearConfig {
let scale = 1;
let r = Math.abs(rateInput) || 1;
let guard = 0;
while (r < 0.2 && guard++ < 20) {
r *= 10;
scale /= 10;
}
guard = 0;
while (r > 5 && guard++ < 20) {
r /= 10;
scale *= 10;
}
let best: { drive: number; driven: number; err: number } | null = null;
for (let drive = 8; drive <= 40; drive++) {
const driven = Math.round(drive * r);
if (driven < 8 || driven > 40) continue;
const err = Math.abs(driven / drive - r);
if (!best || err < best.err) best = { drive, driven, err };
}
if (!best) {
const drive = r < 1 ? 40 : 8;
const driven = Math.min(40, Math.max(8, Math.round(drive * r)));
best = { drive, driven, err: Math.abs(driven / drive - r) };
}
return {
teethDrive: best.drive,
teethDriven: best.driven,
ratio: best.drive / best.driven,
scale,
mantissa: r,
};
}
function lashHalfWidthDeg(spreadFrac: number): number {
const clamped = Math.min(0.25, Math.max(0, spreadFrac));
return (clamped * DEG_PER_UNIT * LEGIBILITY_GAIN) / 2;
}
/** The classic backlash / dead-zone nonlinearity. `contact` free-wheels with
* `x` while moving in one direction; on a reversal it stays put until `x`
* has crossed the full 2*halfWidth gap. */
function updateContact(prevContact: number, x: number, halfWidth: number): number {
if (x - prevContact > halfWidth) return x - halfWidth;
if (x - prevContact < -halfWidth) return x + halfWidth;
return prevContact;
}
function parseAmount(raw: string): number | null {
if (raw.trim() === "") return 0;
const n = Number(raw);
if (!Number.isFinite(n)) return null;
return Math.min(MAX_AMOUNT, Math.max(0, n));
}
function formatCurrency(value: number, code: string): string {
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
maximumFractionDigits: 2,
}).format(value);
} catch {
return `${value.toFixed(2)} ${code}`;
}
}
// ---- gear geometry ----------------------------------------------------------
function pitchRadius(teeth: number): number {
return teeth * MODULE_PX;
}
function polarPoint(r: number, deg: number): { x: number; y: number } {
const rad = ((deg - 90) * Math.PI) / 180;
return { x: r * Math.cos(rad), y: r * Math.sin(rad) };
}
function fmt(n: number): string {
return Math.round(n * 100) / 100 + "";
}
/** One closed path, in LOCAL coordinates centered on (0,0): a ring of
* trapezoid teeth between innerR (root) and outerR (tip). */
function gearToothPath(outerR: number, innerR: number, teeth: number): string {
const step = 360 / teeth;
const halfTop = (step * TOOTH_TOP_FRAC) / 2;
const halfBase = (step * TOOTH_BASE_FRAC) / 2;
let d = "";
for (let i = 0; i < teeth; i++) {
const c = i * step;
const p1 = polarPoint(innerR, c - halfBase);
const p2 = polarPoint(outerR, c - halfTop);
const p3 = polarPoint(outerR, c + halfTop);
const p4 = polarPoint(innerR, c + halfBase);
d += `${i === 0 ? "M" : "L"} ${fmt(p1.x)} ${fmt(p1.y)} L ${fmt(p2.x)} ${fmt(p2.y)} L ${fmt(p3.x)} ${fmt(p3.y)} L ${fmt(p4.x)} ${fmt(p4.y)} `;
}
return d + "Z";
}
interface Scene {
width: number;
height: number;
driveCx: number;
driveCy: number;
driveOuter: number;
driveInner: number;
driveHub: number;
drivePath: string;
drivenCx: number;
drivenCy: number;
drivenOuter: number;
drivenInner: number;
drivenHub: number;
drivenPath: string;
contactX: number;
contactY: number;
}
function buildScene(cfg: GearConfig): Scene {
const rDrive = pitchRadius(cfg.teethDrive);
const rDriven = pitchRadius(cfg.teethDriven);
const outerDrive = rDrive + ADDENDUM;
const innerDrive = rDrive - DEDENDUM;
const outerDriven = rDriven + ADDENDUM;
const innerDriven = rDriven - DEDENDUM;
const maxOuter = Math.max(outerDrive, outerDriven);
const driveCx = SVG_MARGIN + outerDrive;
const cy = SVG_MARGIN + maxOuter;
const drivenCx = driveCx + rDrive + rDriven;
return {
width: drivenCx + outerDriven + SVG_MARGIN,
height: cy * 2,
driveCx,
driveCy: cy,
driveOuter: outerDrive,
driveInner: innerDrive,
driveHub: innerDrive * 0.42,
drivePath: gearToothPath(outerDrive, innerDrive, cfg.teethDrive),
drivenCx,
drivenCy: cy,
drivenOuter: outerDriven,
drivenInner: innerDriven,
drivenHub: innerDriven * 0.42,
drivenPath: gearToothPath(outerDriven, innerDriven, cfg.teethDriven),
contactX: driveCx + rDrive,
contactY: cy,
};
}
interface Mechanism {
contact: number;
driveAngle: number;
drivenAngleRaw: number;
converted: number;
fee: number;
honesty: string;
}
/** The whole mechanism as one pure function of (amount, prior contact) — used
* both to seed the resting-state render (no "blank until mount" frame) and,
* on every input, to recompute what the gears show and what settles into the
* live region. One function, two callers, so there is never a second,
* independently-computed number sitting beside the gear math. */
function deriveMechanism(amount: number, prevContact: number, cfg: GearConfig, halfWidth: number, rate: number): Mechanism {
const driveAngle = amount * DEG_PER_UNIT;
const contact = updateContact(prevContact, driveAngle, halfWidth);
const drivenAngleRaw = contact / cfg.ratio;
const converted = (drivenAngleRaw / DEG_PER_UNIT) * cfg.scale;
const ideal = amount * rate;
const fee = Math.abs(ideal - converted);
const honesty = `drawn ${cfg.teethDriven}:${cfg.teethDrive}, true ${cfg.mantissa.toFixed(4)}${
cfg.scale !== 1 ? ` ×${cfg.scale}` : ""
}`;
return { contact, driveAngle, drivenAngleRaw, converted, fee, honesty };
}
function writeRotation(el: SVGGElement | null, cx: number, cy: number, deg: number, ms: number, ease: string) {
if (!el) return;
el.style.transition = ms > 0 ? `transform ${ms}ms ${ease}` : "none";
el.style.transform = `translate(${fmt(cx)}px, ${fmt(cy)}px) rotate(${fmt(deg)}deg)`;
}
// ---- component ---------------------------------------------------------------
export interface GearLashFieldProps {
/** ISO-ish code for the amount being sent, e.g. "USD". Default "USD". */
fromCurrency?: string;
/** ISO-ish code for the amount received, e.g. "EUR". Default "EUR". */
toCurrency?: string;
/** True exchange rate: units of `toCurrency` per 1 unit of `fromCurrency`. */
rate: number;
/** Provider spread as a fraction, e.g. 0.008 for 0.8%. Typical range 0.003-0.02. */
spreadFrac: number;
/** Initial amount in `fromCurrency`. Default 250. */
defaultAmount?: number;
/** Label for the source amount field. Default "You send". */
label?: string;
/** Label for the live-region readout. Default "They receive". */
receiveLabel?: string;
/** Fires once per settle (debounced), never per raw keystroke. */
onAmountChange?: (info: { amount: number; converted: number; fee: number }) => void;
className?: string;
}
export function GearLashField({
fromCurrency = "USD",
toCurrency = "EUR",
rate,
spreadFrac,
defaultAmount = 250,
label = "You send",
receiveLabel = "They receive",
onAmountChange,
className = "",
}: GearLashFieldProps) {
const uid = useId().replace(/:/g, "");
const patternId = `ns-mesh-lash-teeth-${uid}`;
const inputId = `ns-mesh-lash-input-${uid}`;
const safeRate = Number.isFinite(rate) && rate !== 0 ? rate : 1;
const cfg = useMemo(() => computeGearConfig(safeRate), [safeRate]);
const scene = useMemo(() => buildScene(cfg), [cfg]);
const halfWidth = useMemo(() => lashHalfWidthDeg(spreadFrac), [spreadFrac]);
const initialAmount = parseAmount(String(defaultAmount)) ?? 0;
const initialMechanism = deriveMechanism(initialAmount, 0, cfg, halfWidth, safeRate);
const [rawText, setRawText] = useState(() => String(defaultAmount));
const [display, setDisplay] = useState(() => ({
amount: initialAmount,
converted: initialMechanism.converted,
fee: initialMechanism.fee,
honesty: initialMechanism.honesty,
}));
const driveGroupRef = useRef<SVGGElement | null>(null);
const drivenGroupRef = useRef<SVGGElement | null>(null);
const contactMarkRef = useRef<SVGCircleElement | null>(null);
// Seeded from the same mechanism the resting frame renders, so the first
// real input is a genuine transition rather than a "snap from zero".
const contactRef = useRef(initialMechanism.contact);
const isDraggingRef = useRef(false);
const reducedRef = useRef(false);
const liveTimer = useRef<number | undefined>(undefined);
const announceTimer = useRef<number | undefined>(undefined);
const dragStartRef = useRef({ x: 0, amount: 0 });
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
reducedRef.current = mq.matches;
const onChange = () => {
reducedRef.current = mq.matches;
};
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
// Core mechanism: whenever the typed/scrubbed amount changes, recompute
// drive/backlash/driven and write it straight to the gear rotations AND
// to the numbers that get displayed (debounced) — same computation, one
// path, no separately-derived "real" total sitting beside the gears.
const applyAmount = useCallback(
(amount: number) => {
const m = deriveMechanism(amount, contactRef.current, cfg, halfWidth, safeRate);
contactRef.current = m.contact;
const ms = reducedRef.current || isDraggingRef.current ? 0 : TYPED_MS;
writeRotation(driveGroupRef.current, scene.driveCx, scene.driveCy, m.driveAngle % 360, ms, TYPED_EASE);
writeRotation(drivenGroupRef.current, scene.drivenCx, scene.drivenCy, -(m.drivenAngleRaw % 360), ms, TYPED_EASE);
if (contactMarkRef.current) {
contactMarkRef.current.style.transition = "opacity 150ms ease-out";
contactMarkRef.current.style.opacity = "1";
}
window.clearTimeout(liveTimer.current);
liveTimer.current = window.setTimeout(() => {
if (contactMarkRef.current) contactMarkRef.current.style.opacity = "0";
}, LIVE_MS);
window.clearTimeout(announceTimer.current);
announceTimer.current = window.setTimeout(() => {
setDisplay({ amount, converted: m.converted, fee: m.fee, honesty: m.honesty });
onAmountChange?.({ amount, converted: m.converted, fee: m.fee });
}, ANNOUNCE_DEBOUNCE);
},
[cfg, halfWidth, onAmountChange, safeRate, scene]
);
useEffect(
() => () => {
window.clearTimeout(liveTimer.current);
window.clearTimeout(announceTimer.current);
},
[]
);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const next = e.target.value;
setRawText(next);
const amount = parseAmount(next);
if (amount === null) return; // mid-typing junk (e.g. "-") — don't fight the caret
applyAmount(amount);
};
const handleScrubDown = (e: ReactPointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
isDraggingRef.current = true;
const current = parseAmount(rawText) ?? 0;
dragStartRef.current = { x: e.clientX, amount: current };
};
const handleScrubMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDraggingRef.current) return;
const dx = e.clientX - dragStartRef.current.x;
const amount = Math.min(MAX_AMOUNT, Math.max(0, dragStartRef.current.amount + dx * SCRUB_UNITS_PER_PX));
setRawText(amount.toFixed(2));
applyAmount(amount);
};
const endScrub = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!isDraggingRef.current) return;
isDraggingRef.current = false;
e.currentTarget.releasePointerCapture(e.pointerId);
};
const convertedFmt = formatCurrency(display.converted, toCurrency);
const feeFmt = formatCurrency(display.fee, toCurrency);
const initialDriveTransform = `translate(${fmt(scene.driveCx)}px, ${fmt(scene.driveCy)}px) rotate(${fmt(
initialMechanism.driveAngle % 360
)}deg)`;
const initialDrivenTransform = `translate(${fmt(scene.drivenCx)}px, ${fmt(scene.drivenCy)}px) rotate(${fmt(
-(initialMechanism.drivenAngleRaw % 360)
)}deg)`;
return (
<div className={`ns-mesh-lash-root w-full max-w-sm ${className}`}>
<label htmlFor={inputId} className="mb-1.5 block text-xs font-medium text-ns-muted">
{label}
</label>
<div className="flex items-center gap-2 rounded-[12px] border border-border bg-background px-3 py-2.5 transition-colors hover:border-foreground/30">
<input
id={inputId}
type="text"
inputMode="decimal"
autoComplete="off"
value={rawText}
onChange={handleChange}
aria-label={`${label}, amount in ${fromCurrency}`}
className="min-w-0 flex-1 rounded-[6px] bg-transparent font-mono text-lg tabular-nums text-foreground placeholder:text-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
placeholder="0.00"
/>
<span aria-hidden="true" className="shrink-0 font-mono text-xs uppercase tracking-wider text-ns-muted">
{fromCurrency}
</span>
</div>
<div
data-mesh-role="scrub"
aria-hidden="true"
onPointerDown={handleScrubDown}
onPointerMove={handleScrubMove}
onPointerUp={endScrub}
onPointerCancel={endScrub}
className="mt-3 select-none rounded-[16px] border border-border bg-background px-3 py-3 transition-colors hover:border-foreground/25"
style={{ touchAction: "none", cursor: "ew-resize" }}
>
<svg viewBox={`0 0 ${scene.width} ${scene.height}`} width="100%" aria-hidden="true" style={{ display: "block" }}>
<defs>
<pattern id={patternId} width="5" height="5" patternUnits="userSpaceOnUse">
<rect width="5" height="5" fill="var(--background)" />
<polygon points="0,5 1.6,0.6 3.4,0.6 5,5" fill="var(--border)" opacity={0.55} />
</pattern>
</defs>
<g ref={driveGroupRef} style={{ transform: initialDriveTransform }}>
<path d={scene.drivePath} fill={`url(#${patternId})`} stroke="var(--foreground)" strokeOpacity={0.5} strokeWidth={1} />
<circle r={scene.driveHub} fill="var(--background)" stroke="var(--border)" strokeWidth={1} />
<circle r={1.4} fill="var(--ns-muted)" />
</g>
<g ref={drivenGroupRef} style={{ transform: initialDrivenTransform }}>
<path d={scene.drivenPath} fill={`url(#${patternId})`} stroke="var(--foreground)" strokeOpacity={0.5} strokeWidth={1} />
<circle r={scene.drivenHub} fill="var(--background)" stroke="var(--border)" strokeWidth={1} />
<circle r={1.4} fill="var(--ns-muted)" />
</g>
<circle
ref={contactMarkRef}
cx={scene.contactX}
cy={scene.contactY}
r={1}
fill="var(--ns-accent)"
style={{ opacity: 0 }}
/>
</svg>
</div>
<div role="status" aria-live="polite" aria-atomic="true" className="mt-3 space-y-0.5">
<p className="font-mono text-sm tabular-nums text-foreground">
{receiveLabel} <span className="font-medium">{convertedFmt}</span>
</p>
<p className="font-mono text-xs tabular-nums text-ns-muted">Spread cost {feeFmt}</p>
<p className="pt-1 font-mono text-[10px] tracking-wide text-ns-muted" aria-hidden="true">
{display.honesty}
</p>
</div>
</div>
);
}
Build spec
Build <GearLashField fromCurrency? toCurrency? rate spreadFrac defaultAmount? label? receiveLabel? onAmountChange? className?> as an uncontrolled currency-amount field whose gear illustration is the actual FX computation, not a decoration next to one. MECHANISM: one governing scalar driveAngle = amount * DEG_PER_UNIT (3.6 deg/unit — a direct function of the typed amount, mathematically identical to accumulating a constant deg-per-unit rate over every input delta). Backlash is the textbook control-theory dead-zone nonlinearity applied to driveAngle: a `contact` value that free-wheels with driveAngle while it moves in one direction (contact = driveAngle - halfWidth) but, on a direction reversal, stays frozen until driveAngle has crossed the FULL 2*halfWidth gap before it starts moving again (contact = driveAngle + halfWidth once driveAngle - contact < -halfWidth) — this literally reproduces 'the drive turns through a visible dead angle before the driven picks up' on every reversal. halfWidth is derived, not hand-tuned: lashWidthDeg = spreadFrac * DEG_PER_UNIT * LEGIBILITY_GAIN (a fixed, stated gain constant of 500) so a real 0.3-2% spread, which would otherwise be a fraction of a degree, reads as an actual visible wedge of drive rotation. drivenAngle = contact / ratio, where ratio = teethDrive/teethDriven is the DRAWN gear ratio (not the exact rate): decompose rate into mantissa*scale where scale is a power of ten pulled out until mantissa sits in [0.2, 5] (so a far-apart pair like JPY doesn't demand an unrenderable 1:150 tooth ratio), then search driveTeeth over [8,40] for the driveTeeth/drivenTeeth pair (drivenTeeth = round(driveTeeth*mantissa), also clamped to [8,40]) whose ratio is closest to mantissa. HARD CONSTRAINT: the printed converted amount is read straight off drivenAngle — (drivenAngle / DEG_PER_UNIT) * scale — there is no second, independently-computed 'real' total rendered beside the gears; the display IS the computation, so the whole claim (fee equals dead angle) stays checkable. The disclosed spread cost is |amount*rate (the exact, ungeared rate) - that gear-integrated converted amount| — i.e. the gap between what a frictionless conversion would give and what the drawn, backlashed gear pair actually outputs, formatted in `toCurrency`. QUANTIZATION HONESTY: because tooth counts are integers, the drawn ratio only approximates the true rate; print that residual verbatim as a small mono caption, e.g. 'drawn 23:8, true 2.8712' (append '×{scale}' when a power-of-ten was pulled out), so the imprecision is disclosed rather than hidden. TEETH RENDERING: SVG has no primitive for tiling a fill around a circle, so the tooth SILHOUETTE (the geometrically load-bearing part — right tooth count, trapezoid profile, correct mesh) is a computed closed path (a loop over teeth count building four points per tooth — root-left, tip-left, tip-right, root-right — via polar coordinates, tip width and root width each a fixed fraction of the angular pitch so the tooth reads as a trapezoid, not a rectangle or a triangle); the brief's literal request for teeth-as-an-SVG-pattern-of-trapezoids is honored as the tooth FILL — one small repeating <pattern> tile (a single trapezoid polygon on a background rect, both in tokens) painted inside that computed silhouette via fill=url(#pattern), so the pattern is real and load-bearing (it is what paints every tooth) even though the outer boundary itself comes from trig, a genuine SVG limitation rather than a shortcut. Both gears are drawn in local coordinates centered on (0,0) inside a <g>, positioned and rotated in one written style.transform = `translate(cx,cy) rotate(deg)` — rotation is written imperatively to that ref, never through React state on the hot path, exactly mirroring how meshed real gears counter-rotate (the driven gear's transform gets the NEGATIVE of drivenAngle). A single 1px circle in var(--ns-accent) sits at the geometric mesh point (pitch-circle tangent point between the two gear centers) and its opacity is toggled to 1 on every input, fading back to 0 ~420ms after the last change — the only place --ns-accent appears, and only while input is live. MOTION: typed input (onChange on the native input) transitions the gear rotation over 200ms ease-out; scrub input (a pointer drag on the aria-hidden gear scene, data-mesh-role="scrub") writes rotation with transition:none, 1:1 with the pointer, using SCRUB_UNITS_PER_PX to map horizontal drag distance to a currency-unit delta from the amount at pointerdown; both paths funnel through the exact same applyAmount function so there is one source of truth for the mechanism regardless of input mode. A11Y: the source amount is a real <input inputMode="decimal"> with a visible <label htmlFor> plus an aria-label repeating the currency code, and remains the ONLY control users need to operate the whole thing (Tab reaches it, arrow/number keys just work as normal text input — no custom keyboard handling is needed or added); converted amount and spread cost are plain text inside one role="status" aria-live="polite" region, debounced ~450ms after the last input so rapid typing or dragging doesn't spam a screen reader, plus a matching sr-only sentence ('They receive €1,132.09. Spread cost €4.20.') — the visible gear scene and its wrapper are aria-hidden entirely, and scrubbing it is a supplementary pointer affordance only, never the only way to change the amount. REDUCED MOTION: prefers-reduced-motion drops every rotation transition to 0ms (gears jump straight to their final resting angle on every change, mount included) while the honesty line and spread-cost figure still print exactly as computed — no motion is required to read the mechanism, only to watch it. TOKENS: only var(--background), var(--foreground), var(--ns-muted), var(--border), var(--ns-accent) appear anywhere, in markup and in the SVG's pattern/paths alike; no hex, no gradients, no orange. DEMO: a compact card ('Send to a MXN account') wrapping one GearLashField at a realistic USD->MXN remittance rate (17.05) and spread (1.2%) — a pair whose tooth counts land visibly apart (20 drive teeth vs 34 driven), so the ratio itself reads at a glance instead of two near-identical circles — fully interactive with zero synthetic input — the on-site landing card demonstrates itself separately via the autoplay descriptor, which is a `drag` cycle over the gear scrub surface so the round-trip motion (forward, then back) shows off the dead-angle catch-up on the return leg, the component's whole reason for existing.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| fromCurrency? | string | "USD" | ISO-ish code for the amount being sent, e.g. "USD". Default "USD". |
| toCurrency? | string | "EUR" | ISO-ish code for the amount received, e.g. "EUR". Default "EUR". |
| rate | number | — | True exchange rate: units of `toCurrency` per 1 unit of `fromCurrency`. |
| spreadFrac | number | — | Provider spread as a fraction, e.g. 0.008 for 0.8%. Typical range 0.003-0.02. |
| defaultAmount? | number | 250 | Initial amount in `fromCurrency`. Default 250. |
| label? | string | "You send" | Label for the source amount field. Default "You send". |
| receiveLabel? | string | "They receive" | Label for the live-region readout. Default "They receive". |
| onAmountChange? | (info: { amount: number; converted: number; fee: number }) => void | — | Fires once per settle (debounced), never per raw keystroke. |
| className? | string | — | — |