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/buttonExamples
Variants
Sizes and icons
States
Loading keeps the width; `asChild` renders a link with the button’s look.
Over footage
Glass is see-through in a browser and solid in the NUI.
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
| asChild | boolean | false | Render the child element (a link) with the button's look. |
| caps | boolean | — | The game voice — Barlow Condensed in capitals. Off for a label that must read as a sentence. |
| color | LegacyColor | — | DeprecatedThe old colour axis — use `variant`. |
| hotkey | string | — | The key that also presses it, shown as a keycap after the label: `"E"`, `"ENTER"`. |
| loading | boolean | false | Hides the label behind a spinner and blocks clicks; the width stays. |
| size | "sm" | "md" | "lg" | "xl" | "icon-sm" | "icon" | "icon-lg" | — | |
| sound | false | UiSound | select | 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. |
| variant | ButtonVariant | "default" | "destructive" | — |
Dependencies
npm
- @radix-ui/react-slot@^1.1.1
- class-variance-authority@^0.7.1
- lucide-react@^0.469.0
Registry
- @fastrp/key-hint
- @fastrp/ui-sounds
- @fastrp/utils
Source
components/ds/button.tsxShowHide
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 };