zeroUI

Title screen

A game title screen for React: the name big and glitching in, a menu moved through with the arrow keys, news in a corner, and the keys along the bottom.

Install

terminal
# once per project: where @zeroui items come frompnpm dlx shadcn@latest registry add @zeroui=https://zeroui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zeroui/title-screen

Preview

A courier's night

Night
shift

↑↓ ChooseEnter Confirmv0.4.2
title-screen.tsx
import { type KeyboardEvent, useRef, useState } from "react";import { Badge } from "@/components/zero/badge";import { ChevronRightIcon } from "@/components/zero/icons/chevron-right";import { Kbd } from "@/components/zero/kbd";/* * A title screen: the game's name big and tilted, glitching in, a column of large menu entries * moved through with the arrow keys (the chosen one lit with the accent and a marker), news in a * corner, and the keys along the bottom. Every entry is a button; the arrows move focus between * them, so the mouse, Tab and the keys all agree. Wire `onChoose` to your router or state. */const entries = [  {    id: "continue",    label: "Continue",    line: "Night 4 · North gate · 12,480 cr",  },  { id: "new", label: "New run", line: "Start a fresh shift" },  { id: "settings", label: "Settings", line: "Display, audio, controls" },  { id: "credits", label: "Credits", line: "Who made this" },  { id: "quit", label: "Quit", line: "Back to the desktop" },];export function TitleScreen({ onChoose }: { onChoose?: (id: string) => void }) {  const [active, setActive] = useState(0);  const list = useRef<HTMLUListElement>(null);  const move = (event: KeyboardEvent<HTMLUListElement>) => {    const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];    if (!step) return;    event.preventDefault();    const next = (active + step + entries.length) % entries.length;    setActive(next);    list.current?.querySelectorAll("button")[next]?.focus();  };  return (    <section      aria-label="Title screen"      className="@container relative grid min-h-[34rem] w-full max-w-6xl grid-rows-[1fr_auto] overflow-hidden border-(length:--zero-stroke) border-zero-ink"      style={{        // a backdrop: the accent low on the horizon, a far grid, the dark        background:          "radial-gradient(ellipse at 80% 100%, color-mix(in srgb, var(--color-zero-accent) 18%, transparent), transparent 55%), repeating-linear-gradient(0deg, color-mix(in srgb, var(--color-zero-text) 4%, transparent) 0 1px, transparent 1px 40px), linear-gradient(160deg, var(--color-zero-void), var(--color-zero-ground))",      }}    >      <div className="grid content-center gap-10 p-6 sm:p-10 @3xl:grid-cols-[1fr_18rem] @3xl:items-end">        <div className="grid gap-8">          <div className="grid gap-6">            <p className="font-black font-zero text-zero-accent text-zero-sm uppercase italic tracking-[0.2em]">              A courier's night            </p>            <h2 className="glitch origin-left rotate-(--zero-tilt) font-black font-zero text-[clamp(3rem,10cqi,6.5rem)] uppercase italic leading-[0.9]">              Night              <br />              shift            </h2>          </div>          <ul            ref={list}            onKeyDown={move}            aria-label="Main menu"            className="grid w-full max-w-sm gap-1"          >            {entries.map((entry, index) => (              <li key={entry.id}>                <button                  type="button"                  data-active={index === active ? "" : undefined}                  onFocus={() => setActive(index)}                  onPointerEnter={() => setActive(index)}                  onClick={() => onChoose?.(entry.id)}                  className="group grid w-full grid-cols-[1.25rem_1fr] items-center gap-x-2 px-2 py-1.5 text-start outline-none focus-visible:outline-solid focus-visible:outline-(length:--zero-stroke-thin) focus-visible:outline-zero-focus"                >                  <ChevronRightIcon className="invisible text-zero-accent group-data-active:visible" />                  <span className="font-black font-zero text-zero-text-2 text-zero-xl italic transition-colors duration-(--zero-dur-fast) group-data-active:text-zero-accent">                    {entry.label}                  </span>                  <span className="col-start-2 hidden font-medium font-zero-text text-zero-sm text-zero-text-3 group-data-active:block">                    {entry.line}                  </span>                </button>              </li>            ))}          </ul>        </div>        <aside          aria-label="News"          className="grid gap-2 border-zero-accent border-s-(length:--zero-stroke-thick) bg-[rgb(0_0_0/0.6)] p-4"        >          <Badge className="w-fit">New</Badge>          <p className="font-black font-zero text-zero-lg italic">            The canal docks open          </p>          <p className="font-medium font-zero-text text-zero-sm text-zero-text-2">            A new district, two new couriers, and night markets every third            shift.          </p>        </aside>      </div>      <footer className="flex flex-wrap items-center justify-between gap-3 bg-zero-void px-6 py-3 font-semibold font-zero-text text-zero-sm text-zero-text-3">        <span className="flex flex-wrap items-center gap-x-4 gap-y-1">          <span className="flex items-center gap-1.5">            <Kbd>↑</Kbd>            <Kbd>↓</Kbd> Choose          </span>          <span className="flex items-center gap-1.5">            <Kbd>Enter</Kbd> Confirm          </span>        </span>        <span>v0.4.2</span>      </footer>    </section>  );}

Behaviour

The menu is a column of buttons; the arrow keys move focus between them, so the mouse, Tab and the keys agree on which is chosen, lit with the accent and a marker, its line shown beneath. onChoose gets the entry's id.

The title sizes itself to the block's width (container units) and glitches in once. The backdrop is drawn: put your key art behind it.

Built from Badge, Kbd. It installs those with it.