Flappy hero
A sky of endlessly scrolling pipes and a bobbing, flapping bird over a big outlined headline.
Built withlucide-react
Preview
FlappyHeroSection.tsx
Launch day, no crash landings.
Acme keeps your landing page light and fast, so it clears every pipe between you and your first customer.
Notes
- The pipe track is drawn twice side by side and slides one copy's width, so the loop never jumps. Edit GAPS to change where the openings fall.
- There's no card behind the text: a thick white outline (text stroke painted under the fill) keeps it readable as the pipes pass behind.
- No JavaScript: a server component, with CSS animation that holds still for reduced motion.
Source
src/components/sections/FlappyHeroSection.tsx
"use client";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { ArrowRight } from "lucide-react";
/** Horizontal distance between pipe pairs, in px. */
const SPACING = 220;
/** Height of the ground strip, in px. */
const GROUND = 64;
/** Pipe shaft width (w-14) and how far each cap overhangs it. */
const PIPE_W = 56;
const CAP = 6;
/** Bird box size (size-12) and a little forgiveness on every side. */
const BIRD = 48;
const INSET = 8;
const SPEED = 80; // px/s, matching the ground hatching
const GRAVITY = 1500; // px/s²
const FLAP = -430; // px/s
const MAX_FALL = 620; // px/s
const MAX_DT = 1 / 30; // a long frame can't tunnel through a pipe
/** Where each pair's gap opens, as [top of gap, bottom of gap] in % of the sky's height. */
const GAPS: [number, number][] = [
[18, 52],
[34, 68],
[26, 60],
[44, 78],
[14, 48],
[38, 72],
[22, 56],
[30, 64],
];
const TRACK = GAPS.length * SPACING;
/** First pipe's distance from the track's left edge. */
const LEAD = 120;
export type FlyMode = "auto" | "play" | "crash";
export type Flight = { offset: number; y: number; vy: number; mode: FlyMode; score: number; crashFor: number };
/** Every pipe pair on screen at this scroll offset: its left edge and its gap, in px. */
export function pipesAt(offset: number, sky: number) {
const out: { x: number; top: number; bottom: number }[] = [];
for (let copy = 0; copy < 2; copy++) {
GAPS.forEach(([top, bottom], i) => {
out.push({ x: copy * TRACK + i * SPACING + LEAD - offset, top: (top / 100) * sky, bottom: (bottom / 100) * sky });
});
}
return out;
}
/** The bird's horizontal center: a little in from the left, more room on wide heroes. */
export const birdX = (width: number) => Math.max(44, Math.min(180, width * 0.14));
/** Does the bird (centered at x, y) touch a pipe or the ground? */
export function crashed(x: number, y: number, offset: number, sky: number) {
const left = x - BIRD / 2 + INSET;
const right = x + BIRD / 2 - INSET;
const top = y - BIRD / 2 + INSET;
const bottom = y + BIRD / 2 - INSET;
if (bottom >= sky) return true;
return pipesAt(offset, sky).some(
(p) => right > p.x - CAP && left < p.x + PIPE_W + CAP && (top < p.top || bottom > p.bottom),
);
}
/**
* One frame of flight. On autopilot the bird aims for the middle of the next
* gap and flaps whenever it sinks below it; in play it only flaps when told
* to, crashes are checked, and every pipe passed scores a point.
*/
export function fly(f: Flight, seconds: number, width: number, sky: number, flap = false): Flight {
const dt = Math.min(seconds, MAX_DT);
const x = birdX(width);
if (f.mode === "crash") {
// The world stops; the bird drops to the ground, then autopilot takes over again.
const vy = Math.min(MAX_FALL, f.vy + GRAVITY * dt);
const y = Math.min(sky - BIRD / 2, f.y + vy * dt);
const crashFor = f.crashFor - dt;
if (crashFor > 0) return { ...f, y, vy, crashFor };
return { ...f, mode: "auto", y: sky * 0.45, vy: 0, crashFor: 0 };
}
const offset = (f.offset + SPEED * dt) % TRACK;
let vy = f.vy;
if (f.mode === "auto") {
const next = pipesAt(offset, sky).find((p) => p.x + PIPE_W + CAP > x - BIRD / 2) ?? { top: sky * 0.3, bottom: sky * 0.6 };
const target = (next.top + next.bottom) / 2 + 10;
if (f.y > target && vy >= 0) vy = FLAP;
} else if (flap) {
vy = FLAP;
}
vy = Math.min(MAX_FALL, vy + GRAVITY * dt);
const y = Math.max(BIRD / 2, f.y + vy * dt);
// A pipe whose trailing edge just slipped past the bird is a point.
const passedLine = x - BIRD / 2;
const scored = pipesAt(offset, sky).some((p) => {
const before = p.x + SPEED * dt + PIPE_W;
return before >= passedLine && p.x + PIPE_W < passedLine;
});
const next: Flight = { ...f, offset, y, vy, score: f.mode === "play" && scored ? f.score + 1 : f.score };
if (f.mode === "play" && crashed(x, y, offset, sky)) return { ...next, mode: "crash", crashFor: 1.2, vy: Math.max(vy, 0) };
return next;
}
function Pipe({ from, height }: { from: "top" | "bottom"; height: string }) {
// The cap faces the gap: at the bottom of a top pipe, the top of a bottom pipe.
const cap = <span className="relative -mx-1.5 block h-6 shrink-0 border-4 border-ink bg-pink" />;
return (
<div className={`absolute inset-x-0 flex flex-col ${from === "top" ? "top-0" : "bottom-0"}`} style={{ height }}>
{from === "bottom" && cap}
<span className={`block flex-1 border-x-4 border-ink bg-pink ${from === "top" ? "-mb-1" : "-mt-1"}`}>
<span className="ml-2 block h-full w-2 bg-pink-soft" />
</span>
{from === "top" && cap}
</div>
);
}
function PipePairs() {
return (
<>
{GAPS.map(([top, bottom], i) => (
<div key={i} className="absolute inset-y-0 w-14" style={{ left: i * SPACING + LEAD }}>
<Pipe from="top" height={`${top}%`} />
<Pipe from="bottom" height={`${100 - bottom}%`} />
</div>
))}
</>
);
}
function Bird() {
return (
<div className="relative size-12">
<span
className="absolute top-4 -right-3 block size-0 border-y-8 border-l-14 border-y-transparent border-l-pink"
style={{ filter: "drop-shadow(2px 0 0 var(--ink)) drop-shadow(-0.5px 0 0 var(--ink))" }}
/>
<span className="absolute inset-0 block rounded-md border-4 border-ink bg-orange shadow-brutal-sm" />
<span className="absolute top-2 right-2 grid size-4 place-items-center rounded-full border-2 border-ink bg-paper-raised">
<span className="block size-1.5 rounded-full bg-ink" />
</span>
<span className="flappy-hero-wing absolute top-6 left-1 block h-3 w-5 rounded-sm border-2 border-ink bg-violet" />
</div>
);
}
/**
* Flappy hero: pink pipe pairs scroll past and the bird flies itself through
* every gap. Tap the sky (or the badge, or press Space once it's focused) to
* take over: each tap flaps, pipes passed score, and a crash hands the bird
* back to autopilot. Everything holds still for reduced motion.
*/
export function FlappyHeroSection({ headingAs: Heading = "h1" }: { headingAs?: "h1" | "h2" | "h3" }) {
const section = useRef<HTMLElement>(null);
const skyRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const birdRef = useRef<HTMLDivElement>(null);
const flight = useRef<Flight>({ offset: 0, y: 0, vy: 0, mode: "auto", score: 0, crashFor: 0 });
const flapQueued = useRef(false);
const [hud, setHud] = useState<{ mode: FlyMode; score: number }>({ mode: "auto", score: 0 });
const [best, setBest] = useState(0);
useEffect(() => {
const sky = skyRef.current;
if (!sky) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
flight.current.y = sky.clientHeight * 0.45;
const paint = () => {
const f = flight.current;
const x = birdX(sky.clientWidth);
if (trackRef.current) trackRef.current.style.transform = `translateX(${-f.offset}px)`;
if (birdRef.current) {
const tilt = f.mode === "crash" ? 70 : Math.max(-25, Math.min(60, f.vy / 9));
birdRef.current.style.transform = `translate(${x - BIRD / 2}px, ${f.y - BIRD / 2}px) rotate(${tilt}deg)`;
}
};
paint();
if (still) return;
let raf = 0;
let last = performance.now();
const frame = (now: number) => {
const prev = flight.current;
const next = fly(prev, (now - last) / 1000, sky.clientWidth, sky.clientHeight, flapQueued.current);
flapQueued.current = false;
last = now;
flight.current = next;
if (next.mode !== prev.mode || next.score !== prev.score) {
setHud({ mode: next.mode, score: next.score });
if (next.mode === "crash") setBest((b) => Math.max(b, next.score));
}
paint();
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
/** Take over from autopilot, or flap if already flying. Ignored while crashed. */
const flap = useCallback(() => {
const f = flight.current;
if (f.mode === "crash") return;
if (f.mode === "auto") {
flight.current = { ...f, mode: "play", score: 0 };
setHud({ mode: "play", score: 0 });
}
flapQueued.current = true;
}, []);
function onPointerDown(e: PointerEvent) {
// Links and buttons do their own thing; anywhere else in the sky flaps.
if (e.button !== 0 || (e.target as HTMLElement).closest("a, button")) return;
section.current?.focus({ preventScroll: true });
flap();
}
function onKeyDown(e: KeyboardEvent) {
if ((e.key === " " || e.key === "ArrowUp") && e.target === section.current) {
e.preventDefault();
flap();
}
}
const badge =
hud.mode === "play" ? `Score ${hud.score}` : hud.mode === "crash" ? `Crashed · ${hud.score}` : `Best ${best} · Tap to fly`;
return (
<section
ref={section}
tabIndex={-1}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
className="@container relative cursor-pointer overflow-hidden bg-blue-soft outline-none select-none"
>
<style href="flappy-hero" precedence="default">{`
@keyframes flappy-hero-ground { to { background-position: -32px 0; } }
@keyframes flappy-hero-wing { 50% { transform: translateY(-6px) scaleY(0.6); } }
.flappy-hero-ground { animation: flappy-hero-ground 0.4s linear infinite; }
.flappy-hero-ground[data-stopped] { animation-play-state: paused; }
.flappy-hero-wing { animation: flappy-hero-wing 0.18s steps(2, end) infinite; }
@media (prefers-reduced-motion: reduce) {
.flappy-hero-ground, .flappy-hero-wing { animation: none; }
}
`}</style>
{/* Sky: sun, two copies of the pipe track (so the loop is seamless), and the bird. */}
<div ref={skyRef} aria-hidden className="pointer-events-none absolute inset-x-0 top-0" style={{ bottom: GROUND }}>
<span className="absolute top-8 right-10 block size-16 rounded-full border-4 border-ink bg-orange" />
<div ref={trackRef} className="absolute inset-y-0 left-0 flex will-change-transform" style={{ width: TRACK * 2 }}>
<div className="relative h-full" style={{ width: TRACK }}>
<PipePairs />
</div>
<div className="relative h-full" style={{ width: TRACK }}>
<PipePairs />
</div>
</div>
<div ref={birdRef} className="absolute top-0 left-0 will-change-transform" style={{ transform: "translate(60px, 40%)" }}>
<Bird />
</div>
</div>
{/* Ground, with hatching that scrolls with the pipes (and stops on a crash). */}
<div
aria-hidden
data-stopped={hud.mode === "crash" ? "" : undefined}
className="flappy-hero-ground absolute inset-x-0 bottom-0 border-t-4 border-ink bg-lime"
style={{
height: GROUND,
backgroundImage: "repeating-linear-gradient(-45deg, oklch(0 0 0 / 0.16) 0 3px, transparent 3px 16px)",
backgroundSize: "32px 100%",
}}
/>
<div className="relative z-10 mx-auto flex max-w-2xl flex-col items-center px-4 pt-24 text-center @3xl:pt-28" style={{ paddingBottom: GROUND + 72 }}>
<button
type="button"
onClick={() => {
section.current?.focus({ preventScroll: true });
flap();
}}
className="cursor-pointer border-2 border-ink bg-orange px-3 py-1 font-mono text-xs font-bold tracking-widest text-on-bright uppercase shadow-brutal-sm transition-transform duration-150 hover:-translate-y-0.5 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-blue"
>
<span aria-live="polite">{badge}</span>
</button>
{/* No card behind the text: a thick white outline keeps it readable over the moving pipes. */}
<Heading
className="mt-6 font-display text-5xl leading-none tracking-tight text-balance @3xl:text-7xl"
style={{ WebkitTextStroke: "10px var(--paper-raised)", paintOrder: "stroke fill", textShadow: "6px 6px 0 var(--pink)" } as CSSProperties}
>
Launch day, no crash landings.
</Heading>
<p
className="mt-6 max-w-md text-lg leading-relaxed font-semibold"
style={{ WebkitTextStroke: "5px var(--paper-raised)", paintOrder: "stroke fill" } as CSSProperties}
>
Acme keeps your landing page light and fast, so it clears every pipe between you and your first
customer.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<a
href="#"
className="inline-flex h-13 items-center gap-2 border-2 border-ink bg-lime px-6 font-bold text-on-bright shadow-brutal transition-[translate,box-shadow] duration-150 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-lg active:translate-x-1 active:translate-y-1 active:shadow-none focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-blue"
>
Start flying <ArrowRight aria-hidden className="size-4" />
</a>
<a
href="#"
className="inline-flex h-13 items-center border-2 border-ink bg-paper-raised px-6 font-bold shadow-brutal transition-[translate,box-shadow] duration-150 hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-brutal-lg active:translate-x-1 active:translate-y-1 active:shadow-none focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-blue"
>
See the code
</a>
</div>
</div>
</section>
);
}