Game UI

Menu screen

The layout every in-game menu shares: tabs, wallet, list, checkout, hints.

stablev0.5.0 Chromium 103 ui-scale safe

Install

Copies the file into components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.
bunx shadcn@3 add @fastrp/menu-screen

Examples

A shop

Every slot filled: tabs, wallet, title, list, checkout, hints.

Open alone
$1.200$25.400

Kıyafet

Binco · Vespucci

Seçenekler

Toplam$120+ vergi
DöndürENTERSeçESCÇık

A prompt

A few numbered choices from a person or a thing, low on the left.

Open alone

Pedle etkileşim

Devam etmek için bir seçenek seç

1–9SeçESCÇık

API

<MenuScreen />

Every in-game menu, laid out the same way: sections top left, money top right, the list down the left column under the screen's name, the price and the way out bottom right — the world left clear in the middle for the character, the car or the room. The slots are the rules: a menu that needs something else has a slot missing, not a reason to place it by hand. Each piece arrives from the edge it lives on, the world dims behind the list, and TAB takes the cursor to the checkout. It fills its parent; the NUI page gives it the screen.

PropTypeDefaultNotes
asideReactNode—The right column, when a screen needs one: a detail, a preview, a second list.
checkoutReactNode—Bottom right, over the hints: a `CheckoutBar` or a single confirm.
hintsReactNode—The very foot, right: a `KeyHintBar separated`.
layout"full" | "prompt"full`full`: a shop, a garage, an editor — every slot. `prompt`: a few choices from a person or a thing (up to nine, numbered), anchored low on the left, with no tabs, wallet or checkout.
onClose(() => void)—ESC. In the game the client owns ESC and closes the page itself; pass this where the web page is the one listening (the gallery, a hub preview).
subtitleReactNode—Under the title, small: where in the menu this is.
tabsReactNode—Top left: the sections, as `MenuTabs`.
titleReactNode—The screen's name over the list: "KIYAFET".
walletReactNode—Top right: what the player has — `PriceTag`s.

Dependencies

npm

  • None

Registry

Source

components/ds/menu-screen.tsxShow
'use client';

import * as React from 'react';
import { useUiSound } from '@/components/ds/ui-sounds';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

export type MenuScreenProps = Omit<React.ComponentProps<'div'>, 'title'> & {
  /** Top left: the sections, as `MenuTabs`. */
  tabs?: React.ReactNode;
  /** Top right: what the player has — `PriceTag`s. */
  wallet?: React.ReactNode;
  /** The screen's name over the list: "KIYAFET". */
  title?: React.ReactNode;
  /** Under the title, small: where in the menu this is. */
  subtitle?: React.ReactNode;
  /** The left column: a `MenuList`, in a `MenuView` when it changes with the tabs. */
  children: React.ReactNode;
  /** The right column, when a screen needs one: a detail, a preview, a second list. */
  aside?: React.ReactNode;
  /** Bottom right, over the hints: a `CheckoutBar` or a single confirm. */
  checkout?: React.ReactNode;
  /** The very foot, right: a `KeyHintBar separated`. */
  hints?: React.ReactNode;
  /**
   * `full`: a shop, a garage, an editor — every slot. `prompt`: a few choices from a person or a
   * thing (up to nine, numbered), anchored low on the left, with no tabs, wallet or checkout.
   */
  layout?: 'full' | 'prompt';
  /**
   * ESC. In the game the client owns ESC and closes the page itself; pass this where the web
   * page is the one listening (the gallery, a hub preview).
   */
  onClose?: () => void;
};

/**
 * Every in-game menu, laid out the same way: sections top left, money top right, the list down
 * the left column under the screen's name, the price and the way out bottom right — the world
 * left clear in the middle for the character, the car or the room. The slots are the rules: a
 * menu that needs something else has a slot missing, not a reason to place it by hand.
 *
 * Each piece arrives from the edge it lives on, the world dims behind the list, and TAB takes the
 * cursor to the checkout. It fills its parent; the NUI page gives it the screen.
 */
function MenuScreen({
  tabs,
  wallet,
  title,
  subtitle,
  children,
  aside,
  checkout,
  hints,
  layout = 'full',
  onClose,
  className,
  ...props
}: MenuScreenProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const sound = useUiSound();

  // The menu arriving is a sound of its own; ESC below plays its leaving.
  React.useEffect(() => {
    sound('open');
  }, [sound]);

  React.useEffect(() => {
    const onKey = (event: KeyboardEvent) => {
      if (event.key === 'Escape' && onClose) {
        event.preventDefault();
        sound('close');
        onClose();
      } else if (event.key === 'Tab') {
        const confirm = ref.current?.querySelector<HTMLElement>('[data-slot="checkout-confirm"]');
        if (!confirm) return;
        // TAB is "go to checkout", not the browser's focus walk; again goes back to the list.
        event.preventDefault();
        if (document.activeElement === confirm) {
          ref.current
            ?.querySelector<HTMLElement>('[data-menu-row][data-active="true"], [data-menu-row]')
            ?.focus();
        } else {
          confirm.focus();
        }
        sound('navigate');
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onClose, sound]);

  if (layout === 'prompt') {
    return (
      <div
        ref={ref}
        data-slot="menu-screen"
        data-layout="prompt"
        className={cn('relative size-full select-none overflow-hidden text-foreground', className)}
        {...props}
      >
        <div
          aria-hidden
          className="pointer-events-none absolute inset-y-0 left-0 w-[40%] animate-scrim bg-gradient-to-r from-black/45 to-transparent"
        />
        <div className="absolute bottom-menu-inset left-menu-inset flex w-[26rem] flex-col">
          {title && (
            <div className="mb-4 animate-from-left">
              <h1 className="font-menu text-menu-heading text-foreground/70 text-hud">{title}</h1>
              {subtitle && (
                <p className="mt-2 font-menu text-menu-hint text-foreground/55 text-hud">
                  {subtitle}
                </p>
              )}
            </div>
          )}
          {children}
        </div>
        {hints && (
          <div className="absolute bottom-menu-inset right-menu-inset animate-from-bottom">
            {hints}
          </div>
        )}
      </div>
    );
  }

  return (
    <div
      ref={ref}
      data-slot="menu-screen"
      className={cn('relative size-full select-none overflow-hidden text-foreground', className)}
      {...props}
    >
      {/* The world dims behind the list, so it reads over a bright room. */}
      <div
        aria-hidden
        className="pointer-events-none absolute inset-y-0 left-0 w-[45%] animate-scrim bg-gradient-to-r from-black/55 via-black/20 to-transparent"
      />

      <div className="absolute inset-x-menu-inset top-menu-inset flex items-start justify-between gap-8">
        <div className="min-w-0 animate-from-top">{tabs}</div>
        {wallet && (
          <div className="flex shrink-0 items-center gap-menu-gap animate-from-right">{wallet}</div>
        )}
      </div>

      <div className="absolute bottom-menu-inset left-menu-inset top-[calc(var(--menu-inset)+5rem)] flex w-menu-column flex-col">
        {title && (
          <div className="mb-6 animate-from-left">
            <h1 className="font-menu text-menu-title text-foreground/60 text-hud">{title}</h1>
            {subtitle && (
              <p className="mt-2 font-menu text-menu-hint text-foreground/50 text-hud">
                {subtitle}
              </p>
            )}
          </div>
        )}
        <div className="min-h-0 flex-1 overflow-y-auto overflow-x-visible pr-3 scrollbar-none">
          {children}
        </div>
      </div>

      {aside && (
        <div className="absolute right-menu-inset top-[calc(var(--menu-inset)+5rem)] w-[24rem] animate-from-right [animation-delay:80ms]">
          {aside}
        </div>
      )}

      <div className="absolute bottom-menu-inset right-menu-inset flex w-[28rem] flex-col items-stretch gap-5">
        {checkout && <div className="animate-from-right [animation-delay:120ms]">{checkout}</div>}
        {hints && (
          <div className="flex justify-end animate-from-bottom [animation-delay:160ms]">
            {hints}
          </div>
        )}
      </div>
    </div>
  );
}

export { MenuScreen };