feat(web): glow-pulse on primary CTAs + hero fills full first viewport
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>
This commit is contained in:
Marco Sadjadi 2026-05-27 12:20:25 +02:00
parent 0cf9c66b6b
commit 6f8b8da151
5 changed files with 167 additions and 12 deletions

View File

@ -1,6 +1,7 @@
import { CookieBanner } from '@/components/cookie-banner'; import { CookieBanner } from '@/components/cookie-banner';
import { Logo } from '@/components/logo'; import { Logo } from '@/components/logo';
import { MobileActionBar } from '@/components/mobile-action-bar'; import { MobileActionBar } from '@/components/mobile-action-bar';
import { PulseLink } from '@/components/pulse';
import { UserMenu } from '@/components/user-menu'; import { UserMenu } from '@/components/user-menu';
import { FileClock, LayoutGrid, Package, Server, Settings } from 'lucide-react'; import { FileClock, LayoutGrid, Package, Server, Settings } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
@ -33,12 +34,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</nav> </nav>
</div> </div>
<div className="flex items-center gap-1 sm:gap-2"> <div className="flex items-center gap-1 sm:gap-2">
<Link <PulseLink
href="/servers/new" href="/servers/new"
className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex" className="hidden h-7 items-center gap-1.5 rounded-md bg-[--color-accent] px-2.5 text-[12px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8] sm:inline-flex"
> >
+ New server + New server
</Link> </PulseLink>
<UserMenu /> <UserMenu />
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import { HeroStepRotator } from '@/components/hero-step-rotator'; import { HeroStepRotator } from '@/components/hero-step-rotator';
import { JsonLd } from '@/components/json-ld'; import { JsonLd } from '@/components/json-ld';
import { ParticleHero } from '@/components/particle-hero'; import { ParticleHero } from '@/components/particle-hero';
import { PulseLink } from '@/components/pulse';
import { ScrollCue } from '@/components/scroll-cue'; import { ScrollCue } from '@/components/scroll-cue';
import { StaticCodeBlock } from '@/components/static-code-block'; import { StaticCodeBlock } from '@/components/static-code-block';
import { FAQ, faqJsonLd } from '@/lib/seo'; import { FAQ, faqJsonLd } from '@/lib/seo';
@ -92,14 +93,20 @@ export default function Landing() {
three artifacts (prompt build.log claude config) with a three artifacts (prompt build.log claude config) with a
mouse-reactive 3D tilt and a step indicator. Shorter overall mouse-reactive 3D tilt and a step indicator. Shorter overall
so the video section below is teased above the fold. */} so the video section below is teased above the fold. */}
<section className="relative overflow-hidden border-b border-[--color-border]"> <section
className="relative flex items-center overflow-hidden border-b border-[--color-border]"
style={{ minHeight: 'calc(100svh - 3rem)' }}
>
{/* WebGL particle field capability-detected client component. {/* WebGL particle field capability-detected client component.
Sits behind the hero content at z-0 with pointer-events:none Sits behind the hero content at z-0 with pointer-events:none
so the CTAs above remain fully interactive. The canvas listens so the CTAs above remain fully interactive. The canvas listens
for pointermove on window itself, so the ring still tracks for pointermove on window itself, so the ring still tracks
the cursor through the content above. */} the cursor through the content above. With the hero now
filling the full first-viewport (minus the 48px sticky nav),
the field has cinematic-scale room and the indigo radial
glow + dot mask read as the dominant background motif. */}
<ParticleHero /> <ParticleHero />
<div className="relative z-10 mx-auto grid max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12 md:py-28"> <div className="relative z-10 mx-auto grid w-full max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12">
<div className="min-w-0"> <div className="min-w-0">
<span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]"> <span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]">
v0.1 updated 2026-05-20 v0.1 updated 2026-05-20
@ -116,18 +123,18 @@ export default function Landing() {
for Claude, Cursor and ChatGPT. for Claude, Cursor and ChatGPT.
</p> </p>
<div className="mt-7 flex flex-wrap items-center gap-3"> <div className="mt-7 flex flex-wrap items-center gap-3">
<Link <PulseLink
href="/login" href="/login"
className="inline-flex h-9 items-center justify-center rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]" className="inline-flex h-9 items-center justify-center rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
> >
Start building free Start building free
</Link> </PulseLink>
<Link <PulseLink
href="/docs" href="/docs"
className="inline-flex h-9 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-4 text-[13px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]" className="inline-flex h-9 items-center justify-center rounded-md border border-[--color-border] bg-[--color-bg-elevated] px-4 text-[13px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
> >
Read the docs Read the docs
</Link> </PulseLink>
</div> </div>
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-2 text-[12px] text-[--color-fg-subtle]"> <div className="mt-8 flex flex-wrap gap-x-6 gap-y-2 text-[12px] text-[--color-fg-subtle]">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">

View File

@ -168,3 +168,29 @@
transform: translateY(0); transform: translateY(0);
} }
} }
/* Click-pulse for primary actions (see components/pulse.tsx).
Scales a small indigo dot from 1x to 80x over 650ms on a 2px base
that's ~160px max radius, comfortably larger than any button on the
site. The translate(-50%, -50%) keeps the dot anchored to the click
point as it grows. Reduced-motion users get an instant fade-out so
the click action still proceeds without the bloom. */
@keyframes glow-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.85;
}
100% {
transform: translate(-50%, -50%) scale(80);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
@keyframes glow-pulse {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0;
}
}
}

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { PulseLink } from '@/components/pulse';
import { apiFetch } from '@/lib/api'; import { apiFetch } from '@/lib/api';
import Link from 'next/link';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
/** /**
@ -26,11 +26,11 @@ export function MarketingAuthButtons() {
const showDashboard = authed === true; const showDashboard = authed === true;
return ( return (
<Link <PulseLink
href={showDashboard ? '/dashboard' : '/login'} href={showDashboard ? '/dashboard' : '/login'}
className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]" className="rounded-md bg-[--color-accent] px-3 py-1.5 text-[13px] font-medium text-white transition-colors duration-200 hover:bg-[#5557e8]"
> >
{showDashboard ? 'Dashboard' : 'Login'} {showDashboard ? 'Dashboard' : 'Login'}
</Link> </PulseLink>
); );
} }

View File

@ -0,0 +1,121 @@
'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>
);
}