All checks were successful
Deploy to Production / deploy (push) Successful in 1m1s
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>
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>
|
|
);
|
|
}
|