learning_ai_common_plat/packages/motion/src/Magnetic.tsx
saravanakumardb1 d6ba66f27f feat(motion): @bytelyst/motion@0.2.0 — Wave 13.D spatial primitives
Three new primitives that unlock the §3.6 'visionOS-inspired surfaces'
column of v3.1 — and the MAG.1 customer-magnet hero in §9.1.

──────────────────────────────────────────────────────────────────
<Spotlight>  ·  cursor-tracking radial gradient
──────────────────────────────────────────────────────────────────
  packages/motion/src/Spotlight.tsx (new)

  - Tracks pointer via two CSS custom props (--bl-spot-x/y); zero
    React re-renders on move
  - color-mix(in srgb, var(--bl-accent) 22%, transparent) default
  - prefers-reduced-motion → renders static centred gradient
  - data-testid + data-reduced for Playwright + visual review

──────────────────────────────────────────────────────────────────
<Magnetic>  ·  Arc-browser-style pointer attraction wrapper
──────────────────────────────────────────────────────────────────
  packages/motion/src/Magnetic.tsx (new)

  - Field radius (default 120 px) — child translates toward cursor
    only inside that radius, with strength clamp
  - window-level pointermove listener so the field works even
    before hover
  - SPRINGS.snappy transition on release (280 ms)
  - reduced-motion → transition: none, no translation

──────────────────────────────────────────────────────────────────
<MeshBackground>  ·  ambient OKLCH gradient
──────────────────────────────────────────────────────────────────
  packages/motion/src/MeshBackground.tsx (new)

  - 4-stop color-mix() palette (token-driven, sRGB fallback)
  - Three mood tiers: calm (24s) · focus (16s) · celebrate (10s)
  - Pure CSS keyframes (translate3d + scale) emitted inline
  - reduced-motion → renders static blobs (no <style>)
  - isolation: isolate so children get their own stacking context

──────────────────────────────────────────────────────────────────
Quality gates
──────────────────────────────────────────────────────────────────
  ✓ pnpm -F @bytelyst/motion test  →  23/23 passing (was 16/16)
    +7 new cases: Spotlight (3) · Magnetic (2) · MeshBackground (2)
  ✓ pnpm -F @bytelyst/motion typecheck  →  clean
  ✓ Exports wired in src/index.ts; doc-block bumped to mention
    Wave 13.D additions; package.json 0.1.0 → 0.2.0
  ✓ Description string lists all 8 primitives

──────────────────────────────────────────────────────────────────
Roadmap tracker — 5 boxes flipped (§11)
──────────────────────────────────────────────────────────────────
  13.D.2  Spotlight shipped
  13.D.3  Magnetic shipped
  13.D.4  MeshBackground shipped
  13.D.6  spatial-hero showcase (lands in paired showcase commit)
  MAG.1   the customer-magnet hero 

  13.D.1 (Parallax) + 13.D.5 (TiltGallery) explicitly deferred to
  motion@0.3.x with notes in the tracker.

Wave 13 Futurism: 0/39 → 5/39 (13%) · TOTAL 14/202 → 19/202 (9%)

Vendored snapshot + showcase /futurism routes land in the paired
showcase commit.
2026-05-27 16:02:14 -07:00

92 lines
2.6 KiB
TypeScript

import {
useEffect,
useRef,
type CSSProperties,
type ReactNode,
} from 'react';
import { prefersReducedMotion, SPRINGS } from './utils.js';
export interface MagneticProps {
/** Child element — typically a button or link. */
children: ReactNode;
/** Maximum translation in px. Default 12. */
strength?: number;
/** Activation radius around the element. Default 120 px. */
radius?: number;
/** Bypass motion entirely (renders without transform). */
disableMotion?: boolean;
className?: string;
style?: CSSProperties;
}
/**
* `<Magnetic>` — Arc-browser-style pointer attraction wrapper. As the
* cursor enters a `radius` around the element, the child translates
* toward it (clamped to `strength`). Resets smoothly on pointer leave.
*
* Implementation note: we attach pointer listeners to **window**, not
* the wrapped element, so the magnet has a "field" — the cursor pulls
* the child even before hovering it.
*/
export function Magnetic({
children,
strength = 12,
radius = 120,
disableMotion,
className,
style,
}: MagneticProps) {
const ref = useRef<HTMLSpanElement>(null);
const reduced = disableMotion ?? prefersReducedMotion();
useEffect(() => {
if (reduced) return;
const el = ref.current;
if (!el) return;
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const dist = Math.hypot(dx, dy);
if (dist > radius) {
el.style.transform = 'translate3d(0,0,0)';
return;
}
const f = (1 - dist / radius) * strength;
const tx = (dx / Math.max(1, dist)) * f;
const ty = (dy / Math.max(1, dist)) * f;
el.style.transform = `translate3d(${tx}px, ${ty}px, 0)`;
};
const onLeave = () => {
el.style.transform = 'translate3d(0,0,0)';
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerleave', onLeave);
return () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerleave', onLeave);
};
}, [reduced, radius, strength]);
return (
<span
ref={ref}
data-testid="bl-magnetic"
data-reduced={reduced ? 'true' : 'false'}
className={className}
style={{
display: 'inline-block',
willChange: 'transform',
transition: reduced
? 'none'
: `transform 280ms ${SPRINGS.snappy}`,
...style,
}}
>
{children}
</span>
);
}