Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ try {
'top nav says List your repo');
assert.equal(await page.getByText(/\bwave\b/i).count(), 0, 'no wave wording left on the landing page');

await page.emulateMedia({ reducedMotion: 'reduce' });
await page.locator('.bit-spark-host').evaluate(el =>
el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 1, clientY: 1 })),
);
assert.equal(await page.locator('.bit-spark').count(), 0, 'reduced motion suppresses click sparks');
await page.emulateMedia({ reducedMotion: 'no-preference' });
await page.waitForTimeout(100);
await page.locator('.bit-spark-host').evaluate(el =>
el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 1, clientY: 1 })),
);
await page.waitForFunction(() => document.querySelectorAll('.bit-spark').length > 0);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.waitForFunction(() => document.querySelectorAll('.bit-spark').length === 0);
await page.emulateMedia({ reducedMotion: 'no-preference' });

await page.getByRole('link', { name: /Explore issues/ }).first().click();
await page.waitForURL('**/explore');
await page.getByRole('heading', { name: 'Explore', exact: true }).waitFor();
Expand Down
45 changes: 33 additions & 12 deletions src/components/bits/interactive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import {
} from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { AnimatePresence, motion, useAnimationFrame, useMotionValue } from 'motion/react';
import { useReducedMotion } from './motion';

const EASE = [0.2, 0.8, 0.2, 1] as const;

const reduced = () =>
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;

/* ==========================================================================
GooeyNav
A blob tracks the active item. An SVG goo filter merges the blob with the
Expand All @@ -18,13 +16,18 @@ const reduced = () =>
interface GooItem { to: string; label: string; end?: boolean }

export function GooeyNav({ items }: { items: GooItem[] }) {
const prefersReducedMotion = useReducedMotion();
const { pathname } = useLocation();
const filterId = useId().replace(/:/g, '');
const wrapRef = useRef<HTMLDivElement>(null);
const [blob, setBlob] = useState<{ x: number; w: number } | null>(null);
const [bursts, setBursts] = useState<{ id: number; x: number }[]>([]);
const seq = useRef(0);

useEffect(() => {
if (prefersReducedMotion) setBursts([]);
}, [prefersReducedMotion]);

// Only commit when the measurement actually changes — setting a fresh object
// every render would re-trigger this effect and loop forever.
const sync = useCallback(() => {
Expand All @@ -47,7 +50,7 @@ export function GooeyNav({ items }: { items: GooItem[] }) {
}, [sync, pathname]);

const burst = (e: React.MouseEvent<HTMLAnchorElement>) => {
if (reduced()) return;
if (prefersReducedMotion) return;
const wrap = wrapRef.current;
if (!wrap) return;
const wr = wrap.getBoundingClientRect();
Expand Down Expand Up @@ -127,6 +130,7 @@ export function GooeyNav({ items }: { items: GooItem[] }) {
========================================================================== */

export function Lanyard({ children, className = '' }: { children: ReactNode; className?: string }) {
const prefersReducedMotion = useReducedMotion();
const hostRef = useRef<HTMLDivElement>(null);
const cordRef = useRef<SVGPathElement>(null);
const x = useMotionValue(0);
Expand All @@ -141,7 +145,7 @@ export function Lanyard({ children, className = '' }: { children: ReactNode; cla
if (!host) return;
const onDown = (e: PointerEvent) => {
const t = e.target as HTMLElement;
if (!t.closest('.lanyard-badge')) return;
if (!t.closest('.lanyard-badge') || prefersReducedMotion) return;
sim.current.dragging = true;
setHeld(true);
host.setPointerCapture(e.pointerId);
Expand All @@ -161,18 +165,28 @@ export function Lanyard({ children, className = '' }: { children: ReactNode; cla
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
}, []);
}, [prefersReducedMotion]);

useAnimationFrame((t, delta) => {
const s = sim.current;
if (prefersReducedMotion) {
s.x = 0;
s.y = 0;
s.vx = 0;
s.vy = 0;
x.set(0);
y.set(0);
cordRef.current?.setAttribute('d', 'M 110 0 Q 110 62 110 76');
return;
}
const dt = Math.min(delta, 32) / 16.67;

if (s.dragging) {
s.vx += (s.tx - s.x) * 0.22 * dt;
s.vy += (s.ty - s.y) * 0.22 * dt;
} else {
// Gravity pulls the badge back to rest; a slow breeze keeps it alive.
const breeze = reduced() ? 0 : Math.sin(t / 1400) * 5;
const breeze = Math.sin(t / 1400) * 5;
s.vx += (breeze - s.x) * 0.035 * dt;
s.vy += (0 - s.y) * 0.05 * dt;
}
Expand Down Expand Up @@ -217,13 +231,14 @@ export function CardSwap({
interval?: number;
className?: string;
}) {
const prefersReducedMotion = useReducedMotion();
const [order, setOrder] = useState(() => cards.map((_, i) => i));

useEffect(() => {
if (reduced() || cards.length < 2) return;
if (prefersReducedMotion || cards.length < 2) return;
const t = window.setInterval(() => setOrder(o => [...o.slice(1), o[0]]), interval);
return () => window.clearInterval(t);
}, [cards.length, interval]);
}, [cards.length, interval, prefersReducedMotion]);

return (
<div className={`swap ${className}`}>
Expand Down Expand Up @@ -304,10 +319,11 @@ export function ProfileCard({
stat?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const prefersReducedMotion = useReducedMotion();

const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el || reduced()) return;
if (!el || prefersReducedMotion) return;
const r = el.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
Expand All @@ -324,6 +340,10 @@ export function ProfileCard({
el.style.setProperty('--ry', '0deg');
};

useEffect(() => {
if (prefersReducedMotion) reset();
}, [prefersReducedMotion]);

return (
<div className="pcard" ref={ref} onPointerMove={onMove} onPointerLeave={reset}>
<span className="pcard-holo" aria-hidden="true" />
Expand Down Expand Up @@ -352,12 +372,13 @@ export function InfiniteSpiral({
items: ReactNode[];
className?: string;
}) {
const prefersReducedMotion = useReducedMotion();
const n = items.length;
return (
<div className={`spiral ${className}`}>
<motion.div
className="spiral-stage"
animate={reduced() ? undefined : { rotate: 360 }}
animate={prefersReducedMotion ? undefined : { rotate: 360 }}
transition={{ duration: 46, ease: 'linear', repeat: Infinity }}
>
{items.map((item, i) => {
Expand All @@ -375,7 +396,7 @@ export function InfiniteSpiral({
top: `calc(50% + ${Math.sin(angle) * radius}px)`,
zIndex: n - i,
}}
animate={reduced() ? undefined : { rotate: -360 }}
animate={prefersReducedMotion ? undefined : { rotate: -360 }}
transition={{ duration: 46, ease: 'linear', repeat: Infinity }}
>
{item}
Expand Down
6 changes: 4 additions & 2 deletions src/components/bits/molten-metal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { useReducedMotion } from './motion';

/**
* MoltenMetal — flowing liquid-metal background.
Expand Down Expand Up @@ -85,11 +86,12 @@ function compile(gl: WebGLRenderingContext, type: number, src: string) {

export function MoltenMetal({ className = '' }: { className?: string }) {
const ref = useRef<HTMLCanvasElement>(null);
const prefersReducedMotion = useReducedMotion();

useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
if (prefersReducedMotion) return;

const gl = canvas.getContext('webgl', { antialias: false, alpha: false, powerPreference: 'low-power' });
if (!gl) return;
Expand Down Expand Up @@ -183,7 +185,7 @@ export function MoltenMetal({ className = '' }: { className?: string }) {
gl.deleteShader(fs);
gl.deleteBuffer(buf);
};
}, []);
}, [prefersReducedMotion]);

return <canvas ref={ref} className={`bit-metal ${className}`} aria-hidden="true" />;
}
16 changes: 14 additions & 2 deletions src/components/bits/motion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
const EASE = [0.2, 0.8, 0.2, 1] as const;

/** True when the OS asks for reduced motion. Reveals then render immediately. */
function useReducedMotion() {
export function useReducedMotion() {
const [reduced, setReduced] = useState(
() => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches,
);
Expand Down Expand Up @@ -184,12 +184,18 @@ export function Magnet({
children: ReactNode; strength?: number; radius?: number; className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const prefersReducedMotion = useReducedMotion();
const x = useSpring(useMotionValue(0), { stiffness: 260, damping: 22 });
const y = useSpring(useMotionValue(0), { stiffness: 260, damping: 22 });

useEffect(() => {
const el = ref.current;
if (!el) return;
if (prefersReducedMotion) {
x.jump(0);
y.jump(0);
return;
}
const onMove = (e: PointerEvent) => {
const r = el.getBoundingClientRect();
const cx = r.left + r.width / 2;
Expand All @@ -206,7 +212,7 @@ export function Magnet({
};
window.addEventListener('pointermove', onMove, { passive: true });
return () => window.removeEventListener('pointermove', onMove);
}, [x, y, strength, radius]);
}, [x, y, strength, radius, prefersReducedMotion]);

return (
<motion.span ref={ref} className={`bit-magnet ${className}`} style={{ x, y }}>
Expand All @@ -218,11 +224,17 @@ export function Magnet({
/* -------------------------------------------------------------- ClickSpark */
/** Emits a short burst of rays wherever the user clicks inside. */
export function ClickSpark({ children }: { children: ReactNode }) {
const prefersReducedMotion = useReducedMotion();
const [sparks, setSparks] = useState<{ id: number; x: number; y: number }[]>([]);
const ref = useRef<HTMLDivElement>(null);
const next = useRef(0);

useEffect(() => {
if (prefersReducedMotion) setSparks([]);
}, [prefersReducedMotion]);

const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (prefersReducedMotion) return;
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
Expand Down