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.
92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
|
|
import { prefersReducedMotion } from './utils.js';
|
|
|
|
export interface SpotlightProps {
|
|
/** Slot rendered inside the spotlight wrapper. */
|
|
children: ReactNode;
|
|
/** Spotlight radius in px. Default 320. */
|
|
size?: number;
|
|
/** Gradient center colour. Defaults to `var(--bl-accent)` with .22 alpha. */
|
|
color?: string;
|
|
/** Background base colour beneath the spotlight. Defaults to transparent. */
|
|
baseColor?: string;
|
|
/** Bypass motion entirely (renders static `baseColor`). */
|
|
disableMotion?: boolean;
|
|
className?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
/**
|
|
* `<Spotlight>` — radial gradient that follows the pointer.
|
|
*
|
|
* Wraps any block; on `pointermove` the gradient centre tracks the cursor
|
|
* via two CSS custom properties (`--bl-spot-x` / `--bl-spot-y`). The CSS
|
|
* variable update is the cheapest possible re-paint trigger — no React
|
|
* re-renders, no setState in the listener.
|
|
*
|
|
* Honours `prefers-reduced-motion` (renders the static base layer
|
|
* with the spotlight pinned to the centre).
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <Spotlight>
|
|
* <h1>Hover me</h1>
|
|
* </Spotlight>
|
|
* ```
|
|
*/
|
|
export function Spotlight({
|
|
children,
|
|
size = 320,
|
|
color = 'color-mix(in srgb, var(--bl-accent, #6366f1) 22%, transparent)',
|
|
baseColor = 'transparent',
|
|
disableMotion,
|
|
className,
|
|
style,
|
|
}: SpotlightProps) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const reduced = disableMotion ?? prefersReducedMotion();
|
|
|
|
useEffect(() => {
|
|
if (reduced) return;
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const onMove = (e: PointerEvent) => {
|
|
const rect = el.getBoundingClientRect();
|
|
el.style.setProperty('--bl-spot-x', `${e.clientX - rect.left}px`);
|
|
el.style.setProperty('--bl-spot-y', `${e.clientY - rect.top}px`);
|
|
};
|
|
const onLeave = () => {
|
|
el.style.removeProperty('--bl-spot-x');
|
|
el.style.removeProperty('--bl-spot-y');
|
|
};
|
|
el.addEventListener('pointermove', onMove);
|
|
el.addEventListener('pointerleave', onLeave);
|
|
return () => {
|
|
el.removeEventListener('pointermove', onMove);
|
|
el.removeEventListener('pointerleave', onLeave);
|
|
};
|
|
}, [reduced]);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
data-testid="bl-spotlight"
|
|
data-reduced={reduced ? 'true' : 'false'}
|
|
className={className}
|
|
style={
|
|
{
|
|
position: 'relative',
|
|
background: baseColor,
|
|
backgroundImage: reduced
|
|
? `radial-gradient(${size}px circle at 50% 50%, ${color}, transparent 60%)`
|
|
: `radial-gradient(${size}px circle at var(--bl-spot-x, 50%) var(--bl-spot-y, 50%), ${color}, transparent 60%)`,
|
|
transition: 'background 200ms ease',
|
|
...style,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|