stream/ui
Browse the library

Platformer hero

A retro platformer scene: a HUD, bonus blocks that pop coins, pipes, clouds, and a bouncing pixel mascot.

Built withlucide-react

Preview

PlatformerHeroSection.tsx
Score 000000Coins ×00Level 1-1

Press start on your next launch

Acme ships fast, hard-edged landing pages you can build in an afternoon. Bump a block to try it.

Notes

  • Bonus blocks are real buttons: click or press Enter to pop a coin and add to the HUD, once per block. The count is announced to screen readers.
  • The mascot is an original sprite drawn from a text grid in SPRITE; swap the letters to draw your own.
  • The bounce, coin pops, and block bumps are decorative and hold still for reduced motion.

Source

src/components/sections/PlatformerHeroSection.tsx
"use client";

import { useState, type CSSProperties } from "react";
import { ArrowRight } from "lucide-react";

/** Pixel size of one tile: blocks, bricks, pipes, and clouds share it. */
const TILE = 32;

/** The mascot, one character per pixel: K ink, L lime, P pink, W white, . empty. */
const SPRITE = [
  "..KKKKKK..",
  ".KLLLLLLK.",
  ".KLWKLWKK.",
  ".KLLLLLLK.",
  ".KLPPPPLK.",
  ".KLLLLLLK.",
  "..KKKKKK..",
  "..KL..LK..",
  ".KPPK.KPPK",
];
const SPRITE_FILL: Record<string, string> = { K: "bg-ink", L: "bg-lime", P: "bg-pink", W: "bg-paper-raised" };

/** The row of blocks above the headline: bricks, and bonus blocks that pay out a coin. */
const BLOCKS: ("brick" | "bonus")[] = ["brick", "bonus", "brick", "bonus", "brick"];

const CLOUDS = [
  { left: "6%", top: 64, width: 3 },
  { left: "72%", top: 40, width: 4 },
  { left: "84%", top: 112, width: 2 },
];

function Sprite() {
  return (
    <div className="grid" style={{ gridTemplateColumns: `repeat(${SPRITE[0].length}, 4px)` }}>
      {SPRITE.flatMap((row, y) =>
        [...row].map((c, x) => <span key={`${x}-${y}`} className={`size-1 ${SPRITE_FILL[c] ?? ""}`} />),
      )}
    </div>
  );
}

function Pipe({ height, style }: { height: number; style: CSSProperties }) {
  return (
    <div aria-hidden className="absolute bottom-full flex flex-col items-center" style={style}>
      <span className="block border-4 border-ink bg-lime pixel-bevel" style={{ width: TILE * 2 + 8, height: TILE * 0.75 }} />
      <span className="-mt-1 block border-4 border-t-0 border-ink bg-lime pixel-bevel" style={{ width: TILE * 2 - 8, height }} />
    </div>
  );
}

/**
 * Retro platformer hero: a sky with clouds and pipes, a HUD, and a row of
 * blocks above the headline. Bonus blocks pop a coin and add to the
 * counters, once each, from a click or the keyboard. The mascot is an
 * original sprite; its bounce and the coin pops are decorative and hold
 * still for reduced motion.
 */
export function PlatformerHeroSection({ headingAs: Heading = "h1" }: { headingAs?: "h1" | "h2" | "h3" }) {
  const [used, setUsed] = useState<number[]>([]);
  const coins = used.length;
  const score = coins * 200;

  return (
    <section className="@container relative overflow-hidden bg-blue-soft">
      <style href="platformer-hero" precedence="default">{`
        @keyframes platformer-hop { 0%, 60%, 100% { transform: translateY(0); } 75% { transform: translateY(-28px); } }
        @keyframes platformer-coin { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-56px); opacity: 0; } }
        @keyframes platformer-bump { 50% { transform: translateY(-8px); } }
        .platformer-hop { animation: platformer-hop 1.6s steps(4, end) infinite; }
        .platformer-coin { animation: platformer-coin 0.6s steps(6, end) forwards; }
        .platformer-bump { animation: platformer-bump 0.2s steps(2, end); }
        @media (prefers-reduced-motion: reduce) {
          .platformer-hop, .platformer-bump { animation: none; }
          .platformer-coin { animation: none; opacity: 0; }
        }
      `}</style>

      {/* Clouds: rows of white tiles. */}
      <div aria-hidden className="pointer-events-none absolute inset-0">
        {CLOUDS.map((c, i) => (
          <div key={i} className="absolute flex" style={{ left: c.left, top: c.top }}>
            {Array.from({ length: c.width }, (_, j) => (
              <span key={j} className="-ml-0.5 block border-2 border-ink bg-paper-raised first:ml-0" style={{ width: TILE, height: TILE * 0.75 }} />
            ))}
          </div>
        ))}
      </div>

      {/* HUD */}
      <div className="relative flex flex-wrap justify-center gap-x-8 gap-y-1 border-b-2 border-ink bg-paper-raised px-4 py-2 font-mono text-xs font-bold tracking-widest uppercase @3xl:justify-between @3xl:px-8">
        <span>
          Score <span className="tabular-nums">{String(score).padStart(6, "0")}</span>
        </span>
        <span>
          Coins ×<span className="tabular-nums">{String(coins).padStart(2, "0")}</span>
        </span>
        <span>Level 1-1</span>
      </div>
      <p aria-live="polite" className="sr-only">
        {coins > 0 ? `${coins} ${coins === 1 ? "coin" : "coins"}, score ${score}.` : ""}
      </p>

      {/* Above the ground layer, with room at the bottom so the pipes clear the buttons. */}
      <div className="relative z-10 mx-auto flex max-w-3xl flex-col items-center px-4 pt-12 pb-60 text-center @3xl:pt-16 @3xl:pb-64">
        {/* Blocks to bump */}
        <ul aria-label="Blocks" className="flex">
          {BLOCKS.map((kind, i) => {
            const spent = used.includes(i);
            if (kind === "brick") {
              return (
                <li key={i} aria-hidden>
                  <span className="relative -ml-0.5 block border-2 border-ink bg-orange pixel-bevel" style={{ width: TILE * 1.5, height: TILE * 1.5 }}>
                    <span className="absolute inset-x-0 top-1/2 border-t-2 border-ink" />
                    <span className="absolute top-0 left-1/2 h-1/2 border-l-2 border-ink" />
                    <span className="absolute bottom-0 left-1/4 h-1/2 border-l-2 border-ink" />
                  </span>
                </li>
              );
            }
            return (
              <li key={i} className="relative">
                <button
                  type="button"
                  disabled={spent}
                  aria-label={spent ? "Empty block" : "Bonus block: collect a coin"}
                  onClick={() => setUsed((u) => (u.includes(i) ? u : [...u, i]))}
                  className={`relative -ml-0.5 grid cursor-pointer place-items-center border-2 border-ink font-display text-2xl text-on-bright pixel-bevel focus-visible:z-10 focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-blue disabled:cursor-default ${spent ? "platformer-bump bg-paper-sunken text-ink-muted" : "bg-lime hover:-translate-y-1"} transition-transform duration-150`}
                  style={{ width: TILE * 1.5, height: TILE * 1.5 }}
                >
                  {spent ? "" : "!"}
                </button>
                {spent && (
                  <span
                    aria-hidden
                    className="platformer-coin pointer-events-none absolute -top-2 left-1/2 -ml-3 block h-8 w-6 rounded-full border-4 border-ink bg-orange"
                  />
                )}
              </li>
            );
          })}
        </ul>

        <Heading
          className="mt-10 font-display text-5xl leading-none tracking-tight text-balance uppercase @3xl:text-7xl"
          style={{ textShadow: "3px 3px 0 var(--paper-raised), 6px 6px 0 var(--ink)" } as CSSProperties}
        >
          Press start on your next launch
        </Heading>
        <p className="mt-6 max-w-lg border-2 border-ink bg-paper-raised px-5 py-3 font-mono text-sm leading-relaxed shadow-brutal-sm">
          Acme ships fast, hard-edged landing pages you can build in an afternoon. Bump a block to try it.
        </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-pink px-6 font-mono text-sm font-bold text-on-bright uppercase pixel-bevel 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"
          >
            Press start <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-mono text-sm font-bold uppercase 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"
          >
            View the code
          </a>
        </div>
      </div>

      {/* Ground: grass over bricks, with pipes, a hill, and the mascot standing on it. */}
      <div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0">
        <div className="relative h-24">
          <Pipe height={TILE * 1.5} style={{ left: "8%" }} />
          <Pipe height={TILE * 2.5} style={{ right: "10%" }} />
          <div className="absolute bottom-full flex flex-col items-center" style={{ left: "24%" }}>
            <span className="block h-6 w-16 rounded-t-full border-4 border-b-0 border-ink bg-lime-soft" />
            <span className="block h-6 w-28 border-4 border-b-0 border-ink bg-lime-soft" />
          </div>
          <div className="platformer-hop absolute bottom-full" style={{ left: "42%" }}>
            <Sprite />
          </div>
          <div className="h-4 border-y-4 border-ink bg-lime" />
          <div
            className="h-full bg-orange"
            style={{
              backgroundImage:
                "linear-gradient(var(--ink) 2px, transparent 2px), linear-gradient(90deg, var(--ink) 2px, transparent 2px)",
              backgroundSize: `${TILE}px ${TILE / 2}px`,
            }}
          />
        </div>
      </div>
    </section>
  );
}