122 lines
3.3 KiB
TypeScript
122 lines
3.3 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import Link from 'next/link';
|
||
|
|
import {
|
||
|
|
type ComponentProps,
|
||
|
|
type MouseEvent as ReactMouseEvent,
|
||
|
|
useRef,
|
||
|
|
useState,
|
||
|
|
} from 'react';
|
||
|
|
|
||
|
|
interface Pulse {
|
||
|
|
id: number;
|
||
|
|
x: number; // percentage 0-100
|
||
|
|
y: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Shared indigo glow-pulse-on-click effect used by hero CTAs and other
|
||
|
|
* primary actions across the site. Same visual language as the hero
|
||
|
|
* load-in glow — a small bright dot detonates from the click point and
|
||
|
|
* scales out fast, fading to transparent.
|
||
|
|
*
|
||
|
|
* Two variants exposed:
|
||
|
|
* - <PulseLink> wraps next/link
|
||
|
|
* - <PulseButton> wraps a native <button>
|
||
|
|
*
|
||
|
|
* Both compose with whatever className the caller passes. They add
|
||
|
|
* `relative overflow-hidden` so the expanding pulse stays inside the
|
||
|
|
* button's rounded shape — without overflow-hidden, the pulse would
|
||
|
|
* bleed onto neighbouring content.
|
||
|
|
*
|
||
|
|
* The CSS keyframe `glow-pulse` lives in app/globals.css. It scales the
|
||
|
|
* pulse dot from 1x to 80x over 650ms, which on a 2px base size gives a
|
||
|
|
* ~160px max radius — comfortably bigger than every button on the site,
|
||
|
|
* so the bloom always fills the chrome.
|
||
|
|
*
|
||
|
|
* Reduced-motion: the keyframe is suppressed to instant-opacity-0 in
|
||
|
|
* globals.css, so the click flow is preserved but the bloom is silent.
|
||
|
|
*/
|
||
|
|
function usePulseState() {
|
||
|
|
const [pulses, setPulses] = useState<Pulse[]>([]);
|
||
|
|
const idRef = useRef(0);
|
||
|
|
|
||
|
|
const trigger = (e: ReactMouseEvent<HTMLElement>) => {
|
||
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
||
|
|
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||
|
|
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||
|
|
const id = ++idRef.current;
|
||
|
|
setPulses((p) => [...p, { id, x, y }]);
|
||
|
|
// Self-cleanup after the animation finishes (650ms keyframe + 50ms slack).
|
||
|
|
// We don't rely on `onAnimationEnd` because if the user clicks twice
|
||
|
|
// rapidly the second pulse's mount can race the first's animation event.
|
||
|
|
setTimeout(() => setPulses((p) => p.filter((q) => q.id !== id)), 700);
|
||
|
|
};
|
||
|
|
|
||
|
|
return { pulses, trigger };
|
||
|
|
}
|
||
|
|
|
||
|
|
function PulseLayer({ pulses }: { pulses: Pulse[] }) {
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{pulses.map((p) => (
|
||
|
|
<span
|
||
|
|
key={p.id}
|
||
|
|
aria-hidden
|
||
|
|
className="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||
|
|
style={{
|
||
|
|
left: `${p.x}%`,
|
||
|
|
top: `${p.y}%`,
|
||
|
|
background: 'radial-gradient(circle, rgba(99,102,241,0.85), transparent 65%)',
|
||
|
|
animation: 'glow-pulse 0.65s cubic-bezier(0.16, 1, 0.3, 1) forwards',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PulseLink({
|
||
|
|
className,
|
||
|
|
children,
|
||
|
|
onClick,
|
||
|
|
...rest
|
||
|
|
}: ComponentProps<typeof Link>) {
|
||
|
|
const { pulses, trigger } = usePulseState();
|
||
|
|
return (
|
||
|
|
<Link
|
||
|
|
{...rest}
|
||
|
|
onClick={(e) => {
|
||
|
|
trigger(e);
|
||
|
|
onClick?.(e);
|
||
|
|
}}
|
||
|
|
className={`relative overflow-hidden ${className ?? ''}`}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
<PulseLayer pulses={pulses} />
|
||
|
|
</Link>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PulseButton({
|
||
|
|
className,
|
||
|
|
children,
|
||
|
|
onClick,
|
||
|
|
...rest
|
||
|
|
}: ComponentProps<'button'>) {
|
||
|
|
const { pulses, trigger } = usePulseState();
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
{...rest}
|
||
|
|
onClick={(e) => {
|
||
|
|
trigger(e);
|
||
|
|
onClick?.(e);
|
||
|
|
}}
|
||
|
|
className={`relative overflow-hidden ${className ?? ''}`}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
<PulseLayer pulses={pulses} />
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|