buildmymcpserver/apps/web/components/pulse.tsx

122 lines
3.3 KiB
TypeScript
Raw Normal View History

feat(web): glow-pulse on primary CTAs + hero fills full first viewport Two coordinated polish moves: 1. **<PulseLink> / <PulseButton>** — new `apps/web/components/pulse.tsx`. Click anywhere on a wrapped link or button and a small indigo dot detonates from the click point, scaling 1x→80x over 650ms before fading to transparent. Same visual language as the hero load-in glow — the click effectively says "this is the brand reaching back." The dot lives in a `pointer-events: none` overlay, so it never blocks the underlying navigation. `overflow-hidden + relative` are added to the host so the bloom stays inside the rounded shape. `glow-pulse` keyframe sits in globals.css next to the existing `pulse-dot` / `shimmer` / `fade-in` definitions; reduced-motion suppresses the animation to instant-opacity-0 so the click flow is preserved without the bloom. Wired into the highest-conversion CTAs only — the user explicitly asked "wo's Sinn macht": - Hero "Start building free" + "Read the docs" - Marketing header Login / Dashboard button - Dashboard header "+ New server" pill Deliberately NOT applied to dashboard nav links, logout, destructive buttons, form internals, carousel dots — pulse on every click would be noise. 2. **Hero fills 100svh − nav** (`min-height: calc(100svh - 3rem)`). `svh` (small viewport height) instead of `vh` so the hero doesn't jump when the mobile address bar hides/shows. The 3rem subtracts the sticky marketing nav (h-12 = 48px), so the hero ends right at the loadscreen's natural bottom edge. `flex items-center` plus the inner grid's existing `md:items-center` keep the content vertically centred inside the tall section. The ParticleHero background now has cinematic-scale room and the indigo radial-glow + dot-mask read as the dominant background motif — which is the effect the user loved at load-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:20:25 +02:00
'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>
);
}