Component

Button

The call to action: square, in the accent, in small caps.

stablev0.3.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/button

Examples

Variants

Open alone

Sizes and icons

Open alone

States

Loading keeps the width; `asChild` renders a link with the button’s look.

Open alone
Buton görünümlü bağlantı

Over footage

Glass is see-through in a browser and solid in the NUI.

Open alone

API

<Button />

A button, in the game voice: condensed capitals, a soft corner, and a light that sweeps across the filled ones on hover. `type` defaults to `button`, so one inside a form never submits it by accident. `hotkey` only labels the key — binding it is the app's job. It ticks under the mouse and sounds on a press, through data attributes the sound provider listens for — no hook, so it still renders on the server. The label never selects.

PropTypeDefaultNotes
asChildbooleanfalseRender the child element (a link) with the button's look.
capsboolean—The game voice — Barlow Condensed in capitals. Off for a label that must read as a sentence.
colorLegacyColor—DeprecatedThe old colour axis — use `variant`.
hotkeystring—The key that also presses it, shown as a keycap after the label: `"E"`, `"ENTER"`.
loadingbooleanfalseHides the label behind a spinner and blocks clicks; the width stays.
size"sm" | "md" | "lg" | "xl" | "icon-sm" | "icon" | "icon-lg"—
soundfalse | UiSoundselectWhat a press sounds like, through `UiSoundsProvider`: `select` by default, `back` for a cancel, `false` for none. A purchase stays `select` — `success` is the server's answer, which the app plays when it comes.
variantButtonVariant | "default" | "destructive"—

Dependencies

npm

  • @radix-ui/react-slot@^1.1.1
  • class-variance-authority@^0.7.1
  • lucide-react@^0.469.0

Source

components/ds/button.tsxShow
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import type * as React from 'react';
import { Kbd } from '@/components/ds/key-hint';
import type { UiSound } from '@/components/ds/ui-sounds';
import { cn } from '@/lib/utils';

// @fast-ds [email protected]

const buttonVariants = cva(
  'relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-md transition-[background-color,border-color,color,box-shadow,transform] duration-fast ease-brand active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-selection focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 aria-busy:opacity-100 [&_svg]:pointer-events-none [&_svg]:shrink-0',
  {
    variants: {
      variant: {
        /** The one call to action on a surface, in the city's accent, with a light along its top. */
        primary:
          'sheen bg-primary text-primary-foreground shadow-[inset_0_1px_0_hsl(0_0%_100%/0.3),0_0.5rem_1.5rem_-0.75rem_hsl(var(--primary)/0.7)] hover:bg-primary-hover',
        /** White on dark: the menu's confirm, when the accent is already busy on screen. */
        inverse: 'sheen bg-foreground text-background hover:bg-foreground/90',
        secondary:
          'border border-foreground/10 bg-foreground/[0.08] text-foreground hover:border-foreground/20 hover:bg-foreground/[0.14]',
        /** Over footage or a busy backdrop. Solid in the FiveM NUI, where nothing may blur. */
        glass: 'glass text-foreground hover:bg-foreground/10',
        outline:
          'border border-foreground/20 bg-transparent text-foreground hover:border-foreground/40 hover:bg-foreground/[0.05]',
        /** The accent as a frame: a second action beside a primary one. */
        'outline-primary':
          'border border-primary/60 bg-transparent text-brand hover:border-primary hover:bg-primary/10',
        ghost: 'text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground',
        danger: 'bg-danger text-danger-foreground hover:bg-danger/85',
        success: 'bg-success text-success-foreground hover:bg-success/85',
        link: 'h-auto px-0 text-brand underline-offset-4 hover:underline active:scale-100',
      },
      size: {
        sm: 'h-8 px-3 text-sm [&_svg]:size-3.5',
        md: 'h-10 px-5 text-base [&_svg]:size-4',
        lg: 'h-12 px-6 text-lg [&_svg]:size-5',
        xl: 'h-14 px-8 text-xl [&_svg]:size-5',
        'icon-sm': 'size-8 [&_svg]:size-3.5',
        icon: 'size-10 [&_svg]:size-4',
        'icon-lg': 'size-12 [&_svg]:size-5',
      },
      /**
       * The game voice — Barlow Condensed in capitals. Off for a label that must read as a
       * sentence.
       */
      caps: {
        true: 'font-display tracking-[0.04em]',
        false: 'font-sans font-semibold normal-case tracking-normal',
      },
    },
    defaultVariants: { variant: 'primary', size: 'md', caps: true },
  },
);

type ButtonVariant = NonNullable<VariantProps<typeof buttonVariants>['variant']>;

/**
 * The hub's and the game's old colour axis. Kept so a copied call site still compiles and renders
 * the same; write `variant` instead.
 */
type LegacyColor = 'primary' | 'gray' | 'dark' | 'danger' | 'success' | 'black' | 'default';

const LEGACY_COLOR: Record<LegacyColor, ButtonVariant | undefined> = {
  primary: 'primary',
  gray: 'secondary',
  dark: 'secondary',
  black: 'secondary',
  danger: 'danger',
  success: 'success',
  default: undefined,
};

/** The label goes transparent while loading; the spinner takes the colour the label had. */
const SPINNER_TONE: Partial<Record<ButtonVariant, string>> = {
  primary: 'text-primary-foreground',
  inverse: 'text-background',
  danger: 'text-danger-foreground',
  success: 'text-success-foreground',
  'outline-primary': 'text-brand',
  link: 'text-brand',
};

/** Filled variants carry their hotkey as an outline in the label colour, not a lit keycap. */
const FILLED = new Set<ButtonVariant>(['primary', 'inverse', 'danger', 'success']);

/** shadcn's names, accepted for the same reason. */
const LEGACY_VARIANT: Record<string, ButtonVariant> = { default: 'primary', destructive: 'danger' };

function resolveVariant(
  variant: ButtonProps['variant'],
  color: LegacyColor | undefined,
): ButtonVariant | undefined {
  const named = variant ? (LEGACY_VARIANT[variant] ?? (variant as ButtonVariant)) : undefined;
  if (!color || color === 'default') return named;
  if (named === 'outline') return color === 'primary' ? 'outline-primary' : 'outline';
  if (named && named !== 'primary') return named;
  return LEGACY_COLOR[color];
}

export type ButtonProps = Omit<React.ComponentProps<'button'>, 'color'> &
  Omit<VariantProps<typeof buttonVariants>, 'variant'> & {
    variant?: ButtonVariant | 'default' | 'destructive';
    /** Render the child element (a link) with the button's look. */
    asChild?: boolean;
    /** Hides the label behind a spinner and blocks clicks; the width stays. */
    loading?: boolean;
    /** The key that also presses it, shown as a keycap after the label: `"E"`, `"ENTER"`. */
    hotkey?: string;
    /**
     * What a press sounds like, through `UiSoundsProvider`: `select` by default, `back` for a
     * cancel, `false` for none. A purchase stays `select` — `success` is the server's answer, which
     * the app plays when it comes.
     */
    sound?: UiSound | false;
    /** @deprecated The old colour axis — use `variant`. */
    color?: LegacyColor;
  };

/**
 * A button, in the game voice: condensed capitals, a soft corner, and a light that sweeps across
 * the filled ones on hover. `type` defaults to `button`, so one inside a form never submits it by
 * accident. `hotkey` only labels the key — binding it is the app's job.
 *
 * It ticks under the mouse and sounds on a press, through data attributes the sound provider
 * listens for — no hook, so it still renders on the server. The label never selects.
 */
function Button({
  className,
  variant,
  size,
  caps,
  asChild = false,
  loading = false,
  hotkey,
  sound = 'select',
  color,
  type = 'button',
  disabled,
  children,
  ...props
}: ButtonProps) {
  const Comp = asChild ? Slot : 'button';
  const resolved = resolveVariant(variant, color) ?? 'primary';

  return (
    <Comp
      data-slot="button"
      className={cn(
        buttonVariants({ variant: resolved, size, caps }),
        loading && '!text-transparent',
        className,
      )}
      type={asChild ? undefined : type}
      disabled={asChild ? undefined : disabled || loading}
      aria-busy={loading || undefined}
      aria-keyshortcuts={hotkey}
      data-sfx={sound || undefined}
      data-sfx-hover={sound && resolved !== 'link' ? 'navigate' : undefined}
      {...props}
    >
      {loading && (
        <span
          className={cn(
            'absolute inset-0 flex items-center justify-center',
            SPINNER_TONE[resolved] ?? 'text-foreground',
          )}
        >
          <Loader2 className="animate-spin" />
        </span>
      )}
      <Slottable>{children}</Slottable>
      {hotkey && (
        <Kbd
          size="sm"
          variant={FILLED.has(resolved) ? 'bare' : 'cap'}
          className={cn(
            '-mr-1 ml-1',
            FILLED.has(resolved) && 'border border-current text-current opacity-70',
          )}
        >
          {hotkey}
        </Kbd>
      )}
    </Comp>
  );
}

export { Button, buttonVariants };