stream/ui
Browse the library

Falling blocks hero

A centered headline built from letter tiles that drop into place, over a grid of falling tetrominoes.

Built withlucide-react

Preview

BlocksHeroSection.tsx

Level 01 · The component kit

Block by block.

Acme gives you snap-together components for landing pages. Drop them in, line them up, and ship.

Notes

  • Each letter is its own tile and drops in a few steps, one after another, like pieces landing. Screen readers get the plain headline text instead.
  • Edit LINES to change the headline; words wrap between each other, never mid-word. Tiles grow with the container.
  • No JavaScript: it's a server component, and every animation is CSS that holds still for reduced motion.

Source

src/components/sections/BlocksHeroSection.tsx
import type { CSSProperties } from "react";
import { ArrowRight } from "lucide-react";

/** Grid cell size in px: the background grid, the falling pieces, and the floor share it. */
const CELL = 24;

const TONES = ["bg-lime", "bg-pink", "bg-blue", "bg-violet", "bg-orange"];

/** The headline, one tile per letter; each line wraps between words, never inside one. */
const LINES = [["Block", "by"], ["block."]];

type Faller = { shape: [number, number][]; tone: string; left: string; duration: number; delay: number; rest: string };

const I: [number, number][] = [[0, 0], [0, 1], [0, 2], [0, 3]];
const O: [number, number][] = [[0, 0], [1, 0], [0, 1], [1, 1]];
const T: [number, number][] = [[0, 0], [1, 0], [2, 0], [1, 1]];
const S: [number, number][] = [[1, 0], [2, 0], [0, 1], [1, 1]];
const Z: [number, number][] = [[0, 0], [1, 0], [1, 1], [2, 1]];
const L: [number, number][] = [[0, 0], [0, 1], [0, 2], [1, 2]];

const fallers: Faller[] = [
  { shape: S, tone: "bg-lime", left: "5%", duration: 10, delay: -3, rest: "14%" },
  { shape: I, tone: "bg-blue", left: "15%", duration: 13, delay: -9, rest: "48%" },
  { shape: Z, tone: "bg-pink", left: "27%", duration: 11, delay: -1, rest: "6%" },
  { shape: O, tone: "bg-orange", left: "68%", duration: 9, delay: -5, rest: "26%" },
  { shape: T, tone: "bg-violet", left: "79%", duration: 12, delay: -7, rest: "58%" },
  { shape: L, tone: "bg-blue", left: "91%", duration: 10.5, delay: -2, rest: "10%" },
];

/** Column heights of the locked stack along the bottom, repeated across the width. */
const floor = [1, 2, 3, 2, 1, 2, 4, 2, 1, 3, 2, 2, 1, 0, 2, 3];

function Block({ tone, style }: { tone: string; style?: CSSProperties }) {
  return <span className={`absolute border-2 border-ink pixel-bevel ${tone}`} style={{ width: CELL, height: CELL, ...style }} />;
}

/**
 * Falling-blocks hero: a centered headline built from letter tiles that
 * drop into place one by one, over a pixel grid where tetrominoes fall onto
 * a locked stack. All motion is decorative and holds still for reduced
 * motion. No JavaScript: a server component.
 */
export function BlocksHeroSection({ headingAs: Heading = "h1" }: { headingAs?: "h1" | "h2" | "h3" }) {
  let n = 0; // letter index across the whole headline, for colors and the drop order

  return (
    <section
      className="@container relative overflow-hidden bg-paper-sunken bg-pixel-grid"
      style={{ backgroundSize: `${CELL}px ${CELL}px` }}
    >
      {/* Pieces and letters move in whole steps, like a game tick; still for reduced motion. */}
      <style href="blocks-hero" precedence="default">{`
        @keyframes blocks-hero-fall { from { top: -${CELL * 4}px; } to { top: 100%; } }
        @keyframes blocks-hero-drop { from { transform: translateY(-480%); } to { transform: translateY(0); } }
        .blocks-hero-faller { animation: blocks-hero-fall var(--fall) steps(28, end) var(--delay) infinite; }
        .blocks-hero-tile { animation: blocks-hero-drop 0.5s steps(6, end) calc(var(--i) * 70ms) both; }
        @media (prefers-reduced-motion: reduce) {
          .blocks-hero-faller { animation: none; top: var(--rest); }
          .blocks-hero-tile { animation: none; }
        }
      `}</style>

      <div aria-hidden className="pointer-events-none absolute inset-0">
        {fallers.map((f, i) => (
          <div
            key={i}
            className="blocks-hero-faller absolute opacity-80"
            style={{ left: f.left, "--fall": `${f.duration}s`, "--delay": `${f.delay}s`, "--rest": f.rest } as CSSProperties}
          >
            {f.shape.map(([x, y]) => (
              <Block key={`${x}-${y}`} tone={f.tone} style={{ left: x * CELL, top: y * CELL }} />
            ))}
          </div>
        ))}
      </div>

      <div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end">
        {Array.from({ length: 64 }, (_, col) => (
          <div key={col} className="relative shrink-0" style={{ width: CELL, height: floor[col % floor.length] * CELL }}>
            {Array.from({ length: floor[col % floor.length] }, (_, row) => (
              <Block key={row} tone={TONES[(col + row) % TONES.length]} style={{ left: 0, bottom: row * CELL }} />
            ))}
          </div>
        ))}
      </div>

      <div className="relative mx-auto flex max-w-4xl flex-col items-center px-4 pt-16 pb-36 text-center @3xl:pt-24 @3xl:pb-44">
        <p className="border-2 border-ink bg-paper-raised px-3 py-1 font-mono text-xs font-bold tracking-widest uppercase shadow-brutal-sm">
          Level 01 · The component kit
        </p>

        <Heading className="mt-8 flex flex-col items-center gap-2 @3xl:gap-3">
          <span className="sr-only">{LINES.flat().join(" ")}</span>
          {LINES.map((words, li) => (
            <span key={li} aria-hidden className="flex flex-wrap justify-center gap-x-4 gap-y-2 @3xl:gap-x-6">
              {words.map((word) => (
                <span key={word} className="flex gap-1 @3xl:gap-1.5">
                  {[...word].map((ch, ci) => {
                    const i = n++;
                    return (
                      <span
                        key={ci}
                        className={`blocks-hero-tile grid size-11 place-items-center border-2 border-ink font-display text-2xl text-on-bright uppercase shadow-brutal-sm pixel-bevel @md:size-14 @md:text-3xl @3xl:size-20 @3xl:border-4 @3xl:text-5xl @3xl:shadow-brutal ${TONES[i % TONES.length]}`}
                        style={{ "--i": i } as CSSProperties}
                      >
                        {ch}
                      </span>
                    );
                  })}
                </span>
              ))}
            </span>
          ))}
        </Heading>

        <p className="mt-8 max-w-xl border-2 border-ink bg-paper px-5 py-4 text-lg leading-relaxed text-ink-muted shadow-brutal-sm">
          Acme gives you snap-together components for landing pages. Drop them in, line them up, and
          ship.
        </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-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"
          >
            Start building <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"
          >
            See the pieces
          </a>
        </div>
      </div>
    </section>
  );
}