stream/ui
Browse the library

Hammer

Your cursor becomes a neo-brutalist mallet: click to swing it from the handle and drive five nails into a plank.

experimental

Try it

Nails in 0/5

0 of 5 nails in.

How it works

  • Inside the board the mouse cursor is a mallet. A click swings it from the end of its handle, lands the head on the spot, and pops a burst and a BONK!.
  • Each nail takes three hits to go flush; all five in and you can pull them back out. On touch the mallet jumps to your tap, and with a keyboard each nail is a button.
  • The swing is one CSS keyframe around the handle's pivot, restarted per hit. Reduced motion skips the swing and the popups; the hit still lands.
  • Mallet is exported on its own (a plain SVG that takes the theme colors), so you can drop it into a whack-a-mole or a 404 page.

Install

Copy it, with its game rules, into a shadcn/ui project with the shadcn CLI, or open it in v0 to remix it. Or copy the source below.

bunx shadcn@latest add https://streamui.org/r/hammer-lab.json
Open in v0 (opens in a new tab)

Source

src/components/demos/HammerLab.tsx
"use client";

import { useCallback, useId, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { RotateCcw, Volume2, VolumeX } from "lucide-react";
import { Button, cn } from "@streamuilib/react";
import { blip } from "@/lib/games/arcade-sound";

const NAILS = 5;
/** Hits to drive a nail flush. */
const HITS = 3;

/** Swing geometry, in the mallet's 200×200 drawing: it pivots on the end of its handle. */
const PIVOT = { x: 100, y: 190 };
const ARM = 130; // pivot to the middle of the head
const REST = -20; // degrees: raised
const STRIKE = -55; // degrees: head down on the target
const SIZE = 0.62; // drawn at 62%

/**
 * The mallet: a pink barrel head with ink end caps on a wooden handle,
 * drawn upright. The parent rotates it around PIVOT.
 */
export function Mallet({ className, style }: { className?: string; style?: CSSProperties }) {
  return (
    <svg aria-hidden viewBox="0 0 200 200" overflow="visible" className={className} style={style}>
      <g stroke="var(--ink)" strokeWidth="6" strokeLinejoin="round">
        <rect x="91" y="78" width="18" height="118" rx="7" fill="var(--orange)" />
        <rect x="86" y="170" width="28" height="26" rx="6" fill="var(--ink)" />
        <rect x="44" y="34" width="112" height="56" rx="12" fill="var(--pink)" />
        <rect x="38" y="30" width="20" height="64" rx="6" fill="var(--ink)" />
        <rect x="142" y="30" width="20" height="64" rx="6" fill="var(--ink)" />
      </g>
      <rect x="66" y="44" width="56" height="10" rx="5" fill="var(--paper-raised)" opacity="0.75" />
    </svg>
  );
}

/** Where the mallet's box goes so its head lands on (x, y) at the strike angle. */
function placeFor(x: number, y: number) {
  const r = (STRIKE * Math.PI) / 180;
  const pivotX = x - ARM * SIZE * Math.sin(r);
  const pivotY = y + ARM * SIZE * Math.cos(r) - 14; // the head's face, not its middle, meets the target
  return { left: pivotX - PIVOT.x * SIZE, top: pivotY - PIVOT.y * SIZE };
}

const WORDS = ["BONK!", "WHAM!", "THUNK!", "POW!"];

type Hit = { id: number; x: number; y: number; word: string; tilt: number };

/**
 * Hammer: inside the board your cursor is a mallet. Click to swing it from
 * the handle; drive the five nails flush (three hits each). On touch the
 * mallet jumps to your tap; with a keyboard, each nail is a button. Motion
 * holds still for reduced motion: the hit still lands, without the swing.
 */
export function HammerLab() {
  const boardRef = useRef<HTMLDivElement>(null);
  const [aim, setAim] = useState<{ x: number; y: number } | null>(null);
  const [swing, setSwing] = useState(0);
  const [depth, setDepth] = useState<number[]>(() => Array(NAILS).fill(0));
  const [hits, setHits] = useState<Hit[]>([]);
  const [muted, setMuted] = useState(false);
  const mutedRef = useRef(false);
  const hitId = useRef(0);
  const statusId = useId();

  const done = depth.filter((d) => d >= HITS).length;
  const allIn = done === NAILS;

  /** Swing at a point in board coordinates; `onNail` makes the thunk land harder. */
  const bonk = useCallback((x: number, y: number, onNail: boolean) => {
    setAim({ x, y });
    setSwing((n) => n + 1);
    const id = ++hitId.current;
    setHits((h) => [...h.slice(-4), { id, x, y, word: WORDS[id % WORDS.length], tilt: (id % 3) * 7 - 7 }]);
    setTimeout(() => setHits((h) => h.filter((x) => x.id !== id)), 600);
    if (!mutedRef.current) {
      blip([{ f: onNail ? 220 : 170, to: 70, dur: 0.18, type: "triangle", gain: onNail ? 0.2 : 0.12 }, { f: 1400, to: 2000, at: 0.03, dur: 0.07, gain: onNail ? 0.025 : 0.012 }]);
    }
  }, []);

  const local = (e: { clientX: number; clientY: number }) => {
    const r = boardRef.current!.getBoundingClientRect();
    return { x: e.clientX - r.left, y: e.clientY - r.top };
  };

  function onPointerDown(e: PointerEvent) {
    if (e.button !== 0) return;
    const p = local(e);
    // Nails handle their own hit (so keyboard presses work too); this swings at the board.
    if (!(e.target as HTMLElement).closest("[data-nail]")) bonk(p.x, p.y, false);
  }

  function hitNail(i: number, from?: { x: number; y: number }) {
    if (depth[i] >= HITS) return;
    const head = boardRef.current?.querySelector<HTMLElement>(`[data-nail="${i}"] [data-head]`);
    const r = boardRef.current!.getBoundingClientRect();
    const h = head?.getBoundingClientRect();
    const at = from ?? (h ? { x: h.left + h.width / 2 - r.left, y: h.top - r.top } : { x: 0, y: 0 });
    bonk(at.x, at.y, true);
    setDepth((d) => d.map((v, j) => (j === i ? v + 1 : v)));
  }

  const place = aim ? placeFor(aim.x, aim.y) : null;

  return (
    <div className="flex w-full flex-col items-center gap-4 text-ink">
      <style href="hammer-lab" precedence="default">{`
        @keyframes hammer-lab-swing {
          0% { transform: rotate(${REST}deg); }
          18% { transform: rotate(${STRIKE}deg); }
          36% { transform: rotate(${STRIKE}deg); }
          100% { transform: rotate(${REST}deg); }
        }
        @keyframes hammer-lab-word {
          from { opacity: 1; transform: translate(-50%, -10%) scale(0.6) rotate(var(--tilt)); }
          30% { opacity: 1; transform: translate(-50%, -60%) scale(1.1) rotate(var(--tilt)); }
          to { opacity: 0; transform: translate(-50%, -110%) scale(1) rotate(var(--tilt)); }
        }
        @keyframes hammer-lab-burst { from { opacity: 1; transform: translate(-50%, -50%) scale(0.6); } to { opacity: 0; transform: translate(-50%, -50%) scale(1.25); } }
        .hammer-lab-swing { animation: hammer-lab-swing 420ms cubic-bezier(0.3, 0, 0.3, 1) both; }
        .hammer-lab-word { animation: hammer-lab-word 600ms ease-out forwards; }
        .hammer-lab-burst { animation: hammer-lab-burst 260ms ease-out forwards; }
        @media (prefers-reduced-motion: reduce) {
          .hammer-lab-swing { animation: none; transform: rotate(${STRIKE}deg); }
          .hammer-lab-word, .hammer-lab-burst { animation: none; opacity: 0; }
        }
      `}</style>

      <div className="flex w-full max-w-sm items-center justify-between font-mono text-xs uppercase">
        <span>
          Nails in <span className="font-bold tabular-nums">{done}</span>/{NAILS}
        </span>
        <button
          type="button"
          aria-label={muted ? "Turn sound on" : "Turn sound off"}
          aria-pressed={!muted}
          onClick={() => setMuted((m) => (mutedRef.current = !m))}
          className="grid size-6 place-items-center text-ink-muted hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink"
        >
          {muted ? <VolumeX aria-hidden className="size-4" /> : <Volume2 aria-hidden className="size-4" />}
        </button>
      </div>

      <div
        ref={boardRef}
        role="group"
        aria-label="Hammer board. Click to swing the mallet; each nail is a button that takes three hits."
        aria-describedby={statusId}
        onPointerMove={(e) => e.pointerType === "mouse" && setAim(local(e))}
        onPointerLeave={(e) => e.pointerType === "mouse" && setAim(null)}
        onPointerDown={onPointerDown}
        className="relative h-64 w-full max-w-sm touch-none overflow-visible border-2 border-ink bg-violet-soft bg-dots shadow-brutal-lg select-none pointer-fine:cursor-none"
      >
        {/* The plank, with nails sticking up out of it. */}
        <div className="absolute inset-x-5 bottom-8 h-16 border-2 border-ink bg-orange shadow-brutal">
          <span aria-hidden className="absolute inset-x-3 top-4 h-0.5 bg-ink/25" />
          <span aria-hidden className="absolute inset-x-8 top-9 h-0.5 bg-ink/20" />
          <div className="absolute inset-x-4 -top-px flex -translate-y-full items-end justify-between">
            {depth.map((d, i) => {
              const inFully = d >= HITS;
              return (
                <button
                  key={i}
                  type="button"
                  data-nail={i}
                  aria-label={inFully ? `Nail ${i + 1}, all the way in` : `Nail ${i + 1}, ${HITS - d} ${HITS - d === 1 ? "hit" : "hits"} left`}
                  aria-disabled={inFully}
                  onClick={(e) => (e.detail === 0 ? hitNail(i) : hitNail(i, local(e)))}
                  className="group relative flex h-16 w-10 flex-col items-center justify-end focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-ink pointer-fine:cursor-none"
                >
                  <span data-head className={cn("block h-2.5 w-7 border-2 border-ink", inFully ? "bg-lime" : "bg-paper-raised")} />
                  {/* The shaft gets shorter with every hit. */}
                  <span
                    className="block w-2 border-x-2 border-ink bg-paper-sunken transition-[height] duration-100 ease-out motion-reduce:transition-none"
                    style={{ height: `${(HITS - d) * 12}px` }}
                  />
                </button>
              );
            })}
          </div>
        </div>

        {/* Impact: burst lines and a comic word. */}
        {hits.map((h) => (
          <span key={h.id} aria-hidden className="pointer-events-none absolute z-20" style={{ left: h.x, top: h.y }}>
            <svg viewBox="-60 -60 120 120" className="hammer-lab-burst absolute top-0 left-0 size-20">
              <g stroke="var(--ink)" strokeWidth="7" strokeLinecap="round">
                <line x1="0" y1="-26" x2="0" y2="-50" /><line x1="22" y1="-16" x2="40" y2="-32" /><line x1="-22" y1="-16" x2="-40" y2="-32" />
                <line x1="28" y1="6" x2="50" y2="8" /><line x1="-28" y1="6" x2="-50" y2="8" />
              </g>
            </svg>
            <span
              className="hammer-lab-word absolute top-0 left-0 border-2 border-ink bg-lime px-1.5 font-display text-sm whitespace-nowrap text-on-bright shadow-brutal-sm"
              style={{ "--tilt": `${h.tilt}deg` } as CSSProperties}
            >
              {h.word}
            </span>
          </span>
        ))}

        {/* With the cursor hidden, a small ring shows exactly where the head will land. */}
        {aim && (
          <span
            aria-hidden
            className="pointer-events-none absolute z-10 size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-ink bg-paper-raised"
            style={{ left: aim.x, top: aim.y }}
          />
        )}

        {/* The mallet, following the pointer (mouse), or jumping to the last hit (touch, keyboard). */}
        {place && (
          <div className="pointer-events-none absolute z-10" style={{ left: place.left, top: place.top, width: 200 * SIZE, height: 200 * SIZE }}>
            <div key={swing} className={cn("size-full", swing > 0 && "hammer-lab-swing")} style={{ transformOrigin: `${PIVOT.x * SIZE}px ${PIVOT.y * SIZE}px`, transform: `rotate(${REST}deg)` }}>
              <Mallet className="size-full" />
            </div>
          </div>
        )}

        {allIn && (
          <div className="absolute inset-x-0 top-6 z-30 flex justify-center">
            <div className="flex items-center gap-3 border-2 border-ink bg-paper-raised px-3 py-2 shadow-brutal">
              <span className="font-display">All in!</span>
              <Button size="sm" variant="secondary" onPointerDown={(e) => e.stopPropagation()} onClick={() => setDepth(Array(NAILS).fill(0))}>
                <RotateCcw aria-hidden /> Pull them out
              </Button>
            </div>
          </div>
        )}
      </div>

      <p id={statusId} aria-live="polite" className="sr-only">
        {allIn ? "All five nails are in." : `${done} of ${NAILS} nails in.`}
      </p>
    </div>
  );
}