Game Main Menu with Level Select

A main menu for a made-up lighthouse game, drawn inside a game window. The top is a night scene in inline SVG with the title over it. Below, the left column has a large Play button and two menu buttons, with a progress bar underneath. The right column is a grid of twelve levels: finished ones show how many of three lanterns were lit, one is selected and the rest are locked. Choosing a level updates the Play button, and the Settings button swaps the grid for sound switches and a difficulty picker. On phones the columns stack and the grid goes to three across.

Code

import { useState } from "react";

const LAST_UNLOCKED = 7;

// lanterns: how many of the three lanterns the player lit on that level.
const levels = [
  { n: 1, name: "Harbour Mouth", lanterns: 3 },
  { n: 2, name: "Gull Rock", lanterns: 3 },
  { n: 3, name: "Kelp Stairs", lanterns: 2 },
  { n: 4, name: "Saltmarsh", lanterns: 3 },
  { n: 5, name: "Fogbank", lanterns: 1 },
  { n: 6, name: "The Narrows", lanterns: 2 },
  { n: 7, name: "Wreck Reef", lanterns: 0 },
  { n: 8, name: "Storm Glass", lanterns: 0 },
  { n: 9, name: "Moon Tide", lanterns: 0 },
  { n: 10, name: "Cinder Shoal", lanterns: 0 },
  { n: 11, name: "Black Skerry", lanterns: 0 },
  { n: 12, name: "Last Light", lanterns: 0 },
];

const difficulties = ["Calm", "Steady", "Stormy"];

export default function GameMainMenuWithLevelSelect() {
  const [panel, setPanel] = useState<"levels" | "settings">("levels");
  const [selected, setSelected] = useState(LAST_UNLOCKED);
  const [music, setMusic] = useState(true);
  const [sound, setSound] = useState(true);
  const [shake, setShake] = useState(false);
  const [difficulty, setDifficulty] = useState("Steady");

  const level = levels.find((l) => l.n === selected) ?? levels[0];
  const lit = levels.reduce((sum, l) => sum + l.lanterns, 0);
  const focus = "focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950";
  const switches = [
    { label: "Music", on: music, set: setMusic },
    { label: "Sound effects", on: sound, set: setSound },
    { label: "Screen shake", on: shake, set: setShake },
  ];
  const wave = "q25 -10 50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0 t50 0";

  return (
    <div className="bg-slate-200 px-3 py-10 sm:px-6">
      <div className="mx-auto max-w-4xl overflow-hidden rounded-2xl border-4 border-slate-900 bg-slate-950 text-white shadow-2xl shadow-slate-500/50">
        {/* Window bar */}
        <div aria-hidden="true" className="flex h-9 items-center justify-between bg-slate-900 px-4 text-xs font-medium text-slate-400">
          <span>Tidewick</span>
          <span className="flex gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-slate-700" /><span className="h-2.5 w-2.5 rounded-sm bg-slate-700" /><span className="h-2.5 w-2.5 rounded-sm bg-amber-400" /></span>
        </div>

        {/* Title scene */}
        <div className="relative h-52 overflow-hidden bg-gradient-to-b from-indigo-950 to-sky-900 sm:h-60">
          <svg viewBox="0 0 800 220" preserveAspectRatio="xMaxYMax slice" aria-hidden="true" className="absolute inset-0 h-full w-full">
            {[[40, 30], [120, 62], [205, 24], [290, 80], [372, 38], [455, 66], [520, 20], [585, 52], [640, 28], [770, 44], [330, 120], [90, 110]].map(([x, y]) => (
              <circle key={`${x}-${y}`} cx={x} cy={y} r="1.6" className="fill-sky-200" opacity=".7" />
            ))}
            <path d="M720 97 0 24v120Z" className="fill-amber-200" opacity=".1" />
            <path d="M720 97 0 58v52Z" className="fill-amber-200" opacity=".12" />
            <circle cx="720" cy="96" r="30" className="fill-amber-200" opacity=".14" />
            <path d={`M0 186 ${wave} V220 H0Z`} className="fill-sky-800" />
            <path d="M610 220q34-52 78-54h64q34 4 48 54Z" className="fill-slate-900" />
            <path d="M698 168l8-64h28l8 64Z" className="fill-slate-100" />
            <path d="M703 128h34l2 16h-38Z" className="fill-rose-500" />
            <rect x="700" y="102" width="40" height="5" rx="1" className="fill-slate-800" />
            <rect x="707" y="89" width="26" height="13" className="fill-amber-300" />
            <path d="M702 90l18-17 18 17Z" className="fill-slate-800" />
            <path d={`M-25 200 ${wave} V220 H-25Z`} className="fill-sky-950" />
          </svg>
          <div className="relative p-6 sm:p-10">
            <h2 className="text-3xl font-black uppercase tracking-widest sm:text-6xl">Tidewick</h2>
            <p className="mt-2 text-sm font-medium text-sky-100 sm:text-base">Keep the light burning until dawn.</p>
          </div>
        </div>

        <div className="grid gap-8 bg-slate-950 p-5 sm:p-8 md:grid-cols-5">
          {/* Main menu */}
          <div className="flex flex-col gap-3 md:col-span-2">
            <button type="button" className={`flex h-16 items-center gap-4 rounded-xl border-b-4 border-amber-600 bg-amber-400 px-5 text-left text-slate-950 transition hover:bg-amber-300 active:translate-y-0.5 active:border-b-2 ${focus}`}>
              <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-6 w-6 flex-none"><path d="M6 3.8v12.4a.8.8 0 0 0 1.2.7l10-6.2a.8.8 0 0 0 0-1.4l-10-6.2A.8.8 0 0 0 6 3.8Z" /></svg>
              <span>
                <span className="block text-lg font-black uppercase leading-tight tracking-wide">Play</span>
                <span className="block text-xs font-semibold" aria-live="polite">Level {level.n}: {level.name}</span>
              </span>
            </button>
            {(["levels", "settings"] as const).map((key) => (
              <button
                key={key}
                type="button"
                aria-pressed={panel === key}
                onClick={() => setPanel(key)}
                className={`flex h-12 items-center justify-between rounded-xl border-b-4 px-5 text-sm font-bold uppercase tracking-wide transition active:translate-y-0.5 ${focus} ${panel === key ? "border-sky-900 bg-sky-800 text-white" : "border-slate-900 bg-slate-800 text-slate-200 hover:bg-slate-700"}`}
              >
                {key === "levels" ? "Level select" : "Settings"}
                <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4"><path d="m7.5 4.5 5.5 5.5-5.5 5.5" /></svg>
              </button>
            ))}
            <div className="mt-auto pt-3">
              <div className="flex items-baseline justify-between text-xs font-semibold text-slate-300">
                <span>Lanterns lit</span>
                <span className="tabular-nums">{lit} of {levels.length * 3}</span>
              </div>
              <div aria-hidden="true" className="mt-2 h-2 overflow-hidden rounded-full bg-slate-800">
                <div className="h-full rounded-full bg-amber-400" style={{ width: `${(lit / (levels.length * 3)) * 100}%` }} />
              </div>
            </div>
          </div>

          {/* Level select or settings */}
          <div className="md:col-span-3">
            {panel === "levels" ? (
              <>
                <h3 className="text-xs font-bold uppercase tracking-widest text-slate-300">Chapter one: The Sound</h3>
                <ul className="mt-3 grid grid-cols-3 gap-2.5 sm:grid-cols-4">
                  {levels.map((l) => {
                    const locked = l.n > LAST_UNLOCKED;
                    const active = l.n === selected;
                    return (
                      <li key={l.n}>
                        <button
                          type="button"
                          disabled={locked}
                          aria-pressed={locked ? undefined : active}
                          aria-label={locked ? `Level ${l.n}, ${l.name}, locked` : `Level ${l.n}, ${l.name}, ${l.lanterns} of 3 lanterns`}
                          onClick={() => setSelected(l.n)}
                          className={`flex h-20 w-full flex-col items-center justify-center gap-1.5 rounded-xl border-b-4 transition ${focus} ${locked ? "cursor-not-allowed border-slate-900 bg-slate-900 text-slate-500" : active ? "border-amber-600 bg-amber-400 text-slate-950" : "border-slate-900 bg-slate-800 text-white hover:bg-slate-700"}`}
                        >
                          {locked ? (
                            <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-6 w-6"><path fillRule="evenodd" clipRule="evenodd" d="M6.5 8V6.5a3.5 3.5 0 0 1 7 0V8h.5A1.5 1.5 0 0 1 15.5 9.5v6A1.5 1.5 0 0 1 14 17H6a1.5 1.5 0 0 1-1.5-1.5v-6A1.5 1.5 0 0 1 6 8h.5Zm2 0h3V6.5a1.5 1.5 0 0 0-3 0V8Z" /></svg>
                          ) : (
                            <>
                              <span className="text-2xl font-black leading-none tabular-nums">{l.n}</span>
                              <span aria-hidden="true" className="flex gap-1">
                                {[1, 2, 3].map((i) => (
                                  <span key={i} className={`h-2 w-2 rounded-full ${i <= l.lanterns ? (active ? "bg-slate-950" : "bg-amber-400") : active ? "bg-amber-600" : "bg-slate-600"}`} />
                                ))}
                              </span>
                            </>
                          )}
                        </button>
                      </li>
                    );
                  })}
                </ul>
              </>
            ) : (
              <>
                <h3 className="text-xs font-bold uppercase tracking-widest text-slate-300">Settings</h3>
                <ul className="mt-3 divide-y divide-slate-800 rounded-xl bg-slate-900 px-4">
                  {switches.map((s) => (
                    <li key={s.label} className="flex items-center justify-between py-2.5">
                      <span className="text-sm font-semibold">{s.label}</span>
                      <button type="button" role="switch" aria-checked={s.on} aria-label={s.label} onClick={() => s.set(!s.on)} className={`flex h-10 w-14 items-center justify-end rounded-full ${focus}`}>
                        <span className={`flex h-7 w-12 items-center rounded-full p-0.5 transition ${s.on ? "bg-amber-400" : "bg-slate-700"}`}>
                          <span className={`h-6 w-6 rounded-full transition ${s.on ? "translate-x-5 bg-slate-950" : "bg-slate-300"}`} />
                        </span>
                      </button>
                    </li>
                  ))}
                </ul>
                <p id="difficulty-label" className="mt-5 text-xs font-bold uppercase tracking-widest text-slate-300">Sea state</p>
                <div role="group" aria-labelledby="difficulty-label" className="mt-3 grid grid-cols-3 gap-2.5">
                  {difficulties.map((d) => (
                    <button key={d} type="button" aria-pressed={difficulty === d} onClick={() => setDifficulty(d)} className={`h-12 rounded-xl border-b-4 text-sm font-bold uppercase tracking-wide transition ${focus} ${difficulty === d ? "border-amber-600 bg-amber-400 text-slate-950" : "border-slate-900 bg-slate-800 text-slate-200 hover:bg-slate-700"}`}>
                      {d}
                    </button>
                  ))}
                </div>
              </>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/game-main-menu-with-level-select.json

When to use it

How to customize it

Accessibility

Questions

Can I build a game menu with Tailwind and React?

Yes. A menu is ordinary interface: buttons, a grid and some state. React holds which level is selected and which panel is open, and Tailwind handles the look. The game itself can run in a canvas that you show when Play is pressed.

How do the chunky buttons get their pressed look?

Each button has a thicker bottom border in a darker shade (`border-b-4`). On press, `active:translate-y-0.5` moves the button down slightly, and on the Play button the border also gets thinner, which reads as being pushed in.

How do I unlock the next level when one is finished?

Turn `LAST_UNLOCKED` into state, or work it out from saved progress, and raise it by one when the player completes the highest open level. Store it with the lantern counts so progress survives a reload.

How do I make the menu fill the whole screen?

Remove the outer padded wrapper and the window bar, drop `max-w-4xl`, the border and the rounding, and give the remaining container `min-h-screen`.

Can I use the artwork in my own game?

Yes. The scene is a few simple shapes written for this component. It is meant as a placeholder, so replace it with art that fits your own game.

Related: Habit Tracker Phone Screen with Tab Bar, Product Grid with Filter Chips, Button Set: Primary, Secondary, Ghost, Icon and Loading, Stat Tiles with Sparkline Trends.

More app and game ui or all free UI components. Start for free to have a full site built around it.

More in App UI

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap