Component
Button
Buttons trigger an action — submitting a form, opening a dialog, or confirming a destructive change. Each button pairs an intent variant with a support treatment and a size, all driven by the --components-button-color-* tokens.
Playground
Variant
Support
Size
State
Left icon
Right icon
Label
Preview
JSX
<Button
variant="primary"
support="default"
size="sm"
showLeftIcon
showRightIcon
>
Button
</Button>Props
The full ButtonProps API. Color, typography, and border are driven by the variant and support props — never via className.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'primary' | 'error' | 'warning' | 'success' | 'information' | 'icon-primary' | 'icon-error' | 'icon-warning' | 'icon-success' | 'icon-info' | 'primary' | Button intent — controls the --components-button-color-* tokens. The icon-* variants render a square, label-less button (require aria-label) and reuse the matching colour. Use 'error' only for destructive actions. |
support | 'default' | 'subtle' | 'ghost' | 'outline' | 'default' | Surface treatment: filled ('default'), tinted fill ('subtle'), transparent ('ghost'), or bordered ('outline'). |
size | 'sm' | 'md' | 'lg' | 'sm' | Height + typography scale: 36px / 44px / 56px. Avoid 'lg' inside cards or sidebars. |
state | 'default' | 'loading' | 'disabled' | 'default' | 'loading' shows a spinner and sets aria-busy; 'disabled' greys the control and removes interaction. |
label | string | 'Button' | Convenience text label. Used when no children are passed. |
children | ReactNode | — | Label content. Takes precedence over label. |
showLeftIcon | boolean | true | Show the leading icon slot (defaults to a plus icon). |
showRightIcon | boolean | true | Show the trailing icon slot (defaults to a plus icon). |
leftIcon | ReactNode | — | Custom leading icon. Inherits the label color via fill-current. |
rightIcon | ReactNode | — | Custom trailing icon. Inherits the label color via fill-current. |
loadingLabel | string | 'Loading' | Label shown while state="loading". Falls back to the normal label. |
type | 'button' | 'submit' | 'reset' | 'button' | Native button type. Always set 'submit' for the submit trigger in a form. |
onClick | () => void | — | Click handler. Ignored while loading or disabled. |
className | string | — | Layout-only classes (margin, width, positioning). Never pass color, typography, or border styles. |
Usage guidelines
When to use
- Trigger an action — submit a form, open a dialog, confirm a destructive change, or start a download.
- Lead with the 'default' support for the primary action and step down to 'subtle' / 'ghost' / 'outline' for secondary ones.
- Reserve variant="error" for actions that delete, revoke, or permanently modify data — not for visual emphasis.
- Set type="submit" on the submit trigger inside a form, and add aria-label when the button shows only an icon.
When not to use
- Don't use a Button to navigate to a new URL — use a link (<a> / Next.js <Link>) instead.
- Don't apply color, typography, or border styles via className — drive them through the variant and support props.
- Don't use size="lg" inside a card or sidebar — keep contained contexts to 'sm' or 'md'.
- Don't stack two primary 'default' buttons side by side — only one action should read as primary.
nexus/components/button.tsx
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { AddPlusOutlineIcon, ProgressActivityOutlineIcon } from "nexus/icons";
/** The five colour intents that own the `--components-button-color-*` tokens. */
export type ButtonColorVariant =
| "primary"
| "error"
| "warning"
| "success"
| "information";
/** Icon-only intents — square button, no label, reusing the matching colour. */
export type ButtonIconVariant =
| "icon-primary"
| "icon-error"
| "icon-warning"
| "icon-success"
| "icon-info";
export type ButtonVariant = ButtonColorVariant | ButtonIconVariant;
export type ButtonSupport = "default" | "subtle" | "ghost" | "outline";
export type ButtonSize = "sm" | "md" | "lg";
export type ButtonState = "default" | "loading" | "disabled";
/**
* Showcase-only: force the *visual* of an interaction state so a static preview
* (e.g. the docs playground) can demonstrate `:hover` / `:focus-visible` without
* real pointer/keyboard interaction. Not a real interaction prop — leave it unset
* in application code and let the browser drive hover/focus naturally.
*/
export type ButtonPreviewState = "hover" | "focused";
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/** Button intent — controls the component color tokens. */
variant?: ButtonVariant;
/** Surface treatment: filled, subtle fill, transparent, or bordered. */
support?: ButtonSupport;
/** Height + typography scale. */
size?: ButtonSize;
/** `loading` shows a spinner + busy state; `disabled` greys the control. */
state?: ButtonState;
/**
* Showcase-only: statically force the `hover`/`focused` visual for previews.
* Real apps should omit this and let the browser drive `:hover`/`:focus-visible`.
*/
previewState?: ButtonPreviewState;
/** Convenience text label (alternative to `children`). */
label?: string;
/** Label content — takes precedence over `label`. */
children?: ReactNode;
/** Render the leading icon slot. */
showLeftIcon?: boolean;
/** Render the trailing icon slot. */
showRightIcon?: boolean;
/** Custom leading icon (defaults to a plus icon). */
leftIcon?: ReactNode;
/** Custom trailing icon (defaults to a plus icon). */
rightIcon?: ReactNode;
/** Label shown while `state="loading"`. */
loadingLabel?: string;
/** Layout-only classes (margin, width, positioning). */
className?: string;
}
interface SupportStyle {
/**
* Border + text colour (no background). Composed with exactly one background
* field below. Split out so the showcase `previewState="hover"` can swap the
* resting background for the hover one — appended bg classes can't reliably win
* (CSS source order, not class-list order, decides), so the bg must be *chosen*
* here, not layered. Every field is a static literal (Tailwind only emits what
* it sees verbatim) referencing a `--components-button-color-*` Component token.
*/
frame: string;
/** Resting background. Literal. */
rest: string;
/** Hover background, `hover:`-prefixed — the real interactive style. Literal. */
hover: string;
/** Same hover background, un-prefixed — forced by `previewState="hover"`. Literal. */
hoverForced: string;
}
const STYLES: Record<ButtonColorVariant, Record<ButtonSupport, SupportStyle>> = {
primary: {
default: {
frame: "border-transparent text-[var(--components-button-color-primary-text-white)]",
rest: "bg-[var(--components-button-color-primary-background)]",
hover: "hover:bg-[var(--components-button-color-primary-background-hover)]",
hoverForced: "bg-[var(--components-button-color-primary-background-hover)]",
},
subtle: {
frame: "border-transparent text-[var(--components-button-color-primary-text-primary)]",
rest: "bg-[var(--components-button-color-primary-background-subtle)]",
hover: "hover:bg-[var(--components-button-color-primary-background-medium)]",
hoverForced: "bg-[var(--components-button-color-primary-background-medium)]",
},
ghost: {
frame: "border-transparent text-[var(--components-button-color-primary-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-primary-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-primary-background-subtle)]",
},
outline: {
frame: "border-[color:var(--components-button-color-primary-border-default)] text-[var(--components-button-color-primary-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-primary-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-primary-background-subtle)]",
},
},
error: {
default: {
frame: "border-transparent text-[var(--components-button-color-error-text-white)]",
rest: "bg-[var(--components-button-color-error-background)]",
hover: "hover:bg-[var(--components-button-color-error-background-hover)]",
hoverForced: "bg-[var(--components-button-color-error-background-hover)]",
},
subtle: {
frame: "border-transparent text-[var(--components-button-color-error-text-primary)]",
rest: "bg-[var(--components-button-color-error-background-subtle)]",
hover: "hover:bg-[var(--components-button-color-error-background-medium)]",
hoverForced: "bg-[var(--components-button-color-error-background-medium)]",
},
ghost: {
frame: "border-transparent text-[var(--components-button-color-error-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-error-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-error-background-subtle)]",
},
outline: {
frame: "border-[color:var(--components-button-color-error-border-default)] text-[var(--components-button-color-error-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-error-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-error-background-subtle)]",
},
},
warning: {
default: {
frame: "border-transparent text-[var(--components-button-color-warning-text-white)]",
rest: "bg-[var(--components-button-color-warning-background)]",
hover: "hover:bg-[var(--components-button-color-warning-background-hover)]",
hoverForced: "bg-[var(--components-button-color-warning-background-hover)]",
},
subtle: {
frame: "border-transparent text-[var(--components-button-color-warning-text-primary)]",
rest: "bg-[var(--components-button-color-warning-background-subtle)]",
hover: "hover:bg-[var(--components-button-color-warning-background-medium)]",
hoverForced: "bg-[var(--components-button-color-warning-background-medium)]",
},
ghost: {
frame: "border-transparent text-[var(--components-button-color-warning-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-warning-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-warning-background-subtle)]",
},
outline: {
frame: "border-[color:var(--components-button-color-warning-border-default)] text-[var(--components-button-color-warning-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-warning-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-warning-background-subtle)]",
},
},
success: {
default: {
frame: "border-transparent text-[var(--components-button-color-success-text-white)]",
rest: "bg-[var(--components-button-color-success-background)]",
hover: "hover:bg-[var(--components-button-color-success-background-hover)]",
hoverForced: "bg-[var(--components-button-color-success-background-hover)]",
},
subtle: {
frame: "border-transparent text-[var(--components-button-color-success-text-primary)]",
rest: "bg-[var(--components-button-color-success-background-subtle)]",
hover: "hover:bg-[var(--components-button-color-success-background-medium)]",
hoverForced: "bg-[var(--components-button-color-success-background-medium)]",
},
ghost: {
frame: "border-transparent text-[var(--components-button-color-success-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-success-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-success-background-subtle)]",
},
outline: {
frame: "border-[color:var(--components-button-color-success-border-default)] text-[var(--components-button-color-success-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-success-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-success-background-subtle)]",
},
},
information: {
default: {
frame: "border-transparent text-[var(--components-button-color-information-text-white)]",
rest: "bg-[var(--components-button-color-information-background)]",
hover: "hover:bg-[var(--components-button-color-information-background-hover)]",
hoverForced: "bg-[var(--components-button-color-information-background-hover)]",
},
subtle: {
frame: "border-transparent text-[var(--components-button-color-information-text-primary)]",
rest: "bg-[var(--components-button-color-information-background-subtle)]",
hover: "hover:bg-[var(--components-button-color-information-background-medium)]",
hoverForced: "bg-[var(--components-button-color-information-background-medium)]",
},
ghost: {
frame: "border-transparent text-[var(--components-button-color-information-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-information-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-information-background-subtle)]",
},
outline: {
frame: "border-[color:var(--components-button-color-information-border-default)] text-[var(--components-button-color-information-text-primary)]",
rest: "bg-transparent",
hover: "hover:bg-[var(--components-button-color-information-background-subtle)]",
hoverForced: "bg-[var(--components-button-color-information-background-subtle)]",
},
},
};
interface FocusStyle {
/** `focus-visible:`-prefixed ring — the real interactive style. Literal. */
ring: string;
/** Same ring, un-prefixed — forced by `previewState="focused"`. Literal. */
ringForced: string;
}
/**
* Component focus rings (foundations → Effects). `sm`/`md` use the 4px-spread
* tokens; `lg` uses the 6px-spread `-lg` tokens (see §6 Effects in tokens.css).
* Primary maps to the `default` ring utility; information maps to `info`.
*/
const FOCUS: Record<ButtonColorVariant, { sm: FocusStyle; lg: FocusStyle }> = {
primary: {
sm: { ring: "focus-visible:shadow-btn-default", ringForced: "shadow-btn-default" },
lg: { ring: "focus-visible:shadow-btn-lg", ringForced: "shadow-btn-lg" },
},
error: {
sm: { ring: "focus-visible:shadow-btn-error", ringForced: "shadow-btn-error" },
lg: { ring: "focus-visible:shadow-btn-error-lg", ringForced: "shadow-btn-error-lg" },
},
warning: {
sm: { ring: "focus-visible:shadow-btn-warning", ringForced: "shadow-btn-warning" },
lg: { ring: "focus-visible:shadow-btn-warning-lg", ringForced: "shadow-btn-warning-lg" },
},
success: {
sm: { ring: "focus-visible:shadow-btn-success", ringForced: "shadow-btn-success" },
lg: { ring: "focus-visible:shadow-btn-success-lg", ringForced: "shadow-btn-success-lg" },
},
information: {
sm: { ring: "focus-visible:shadow-btn-info", ringForced: "shadow-btn-info" },
lg: { ring: "focus-visible:shadow-btn-info-lg", ringForced: "shadow-btn-info-lg" },
},
};
interface SizeStyle {
/** Padding + gap; height is derived from padding + label line-height. */
box: string;
/** Label typography utility. */
text: string;
/** Icon pixel size (matches the label line-height). */
icon: number;
/** Focus-ring spread bucket. */
ring: "sm" | "lg";
/** Hug width, clamped between the Figma min/max width constraints. */
width: string;
}
const SIZES: Record<ButtonSize, SizeStyle> = {
// 20px label + 2×8 padding = 36px tall
sm: {
box: "px-12 py-8 gap-8",
text: "typography-b2-semibold-14",
icon: 20,
ring: "sm",
width: "min-w-[56px] max-w-[272px]",
},
// 20px label + 2×12 padding = 44px tall
md: {
box: "px-16 py-12 gap-8",
text: "typography-b2-semibold-14",
icon: 20,
ring: "sm",
width: "min-w-[60px] max-w-[272px]",
},
// 22px label + 2×16 padding = 56px tall
lg: {
box: "px-20 py-16 gap-8",
text: "typography-b1-semibold-16",
icon: 22,
ring: "lg",
width: "min-w-[60px] max-w-[280px]",
},
};
/**
* Icon-only variants reuse the colour tokens of their base intent (there are no
* separate `icon-*` colour tokens). Note `icon-info` → `information`.
*/
const ICON_TO_COLOR: Record<ButtonIconVariant, ButtonColorVariant> = {
"icon-primary": "primary",
"icon-error": "error",
"icon-warning": "warning",
"icon-success": "success",
"icon-info": "information",
};
/**
* Icon-only buttons render a different icon size than the labelled variants
* (Figma "Icon Button" spec: 20 / 24 / 24px vs. the labelled 20 / 20 / 22px).
*/
const ICON_ONLY_SIZE: Record<ButtonSize, number> = {
sm: 20,
md: 24,
lg: 24,
};
/** Fixed square box for icon-only buttons (icon size + 2×pad = 36 / 44 / 56px). */
const ICON_BOX: Record<ButtonSize, string> = {
sm: "size-36 p-8",
md: "size-[44px] p-10",
lg: "size-[56px] p-16",
};
/** Uniform disabled treatment — greyed fill + muted text/icon, no hover/focus. */
const DISABLED =
"border-transparent bg-[var(--components-button-color-primary-background-disabled)] text-[var(--components-button-color-primary-text-disabled)] [&_svg_path]:fill-[var(--components-button-color-primary-icon-disabled)] cursor-not-allowed";
const cx = (...classes: (string | false | undefined)[]) =>
classes.filter(Boolean).join(" ");
/**
* Nexus Button — the primary action control. Five colour intents × four support
* styles × three sizes, plus five icon-only intents (`icon-primary` …
* `icon-info`), wired exclusively to the `--components-button-color-*` tokens
* (instruction/nexus-design-system.md §6.4). Heights are derived from padding +
* label line-height so they stay on the spacing scale; icons inherit the label
* color via `fill-current`. Icon-only variants render a square, label-less
* button and require an `aria-label` (falls back to `label`).
*/
export const Button = ({
variant = "primary",
support = "default",
size = "sm",
state = "default",
previewState,
label = "Button",
children,
showLeftIcon = true,
showRightIcon = true,
leftIcon,
rightIcon,
loadingLabel = "Loading",
className,
disabled,
onClick,
type = "button",
"aria-label": ariaLabel,
...rest
}: ButtonProps) => {
const s = SIZES[size];
const isLoading = state === "loading";
const isDisabled = disabled || state === "disabled";
const isIcon = variant.startsWith("icon-");
const colorVariant: ButtonColorVariant = isIcon
? ICON_TO_COLOR[variant as ButtonIconVariant]
: (variant as ButtonColorVariant);
const content = children ?? label;
const text = isLoading ? (loadingLabel ?? content) : content;
// Interactive color classes. `previewState` (showcase-only) statically forces a
// hover/focus visual; otherwise hover/focus are driven by the real CSS states.
const sup = STYLES[colorVariant][support];
const focus = FOCUS[colorVariant][s.ring];
const interactive =
previewState === "hover"
? cx(sup.frame, sup.hoverForced)
: previewState === "focused"
? cx(sup.frame, sup.rest, focus.ringForced)
: cx(sup.frame, sup.rest, sup.hover, focus.ring);
const iconSize = isIcon ? ICON_ONLY_SIZE[size] : s.icon;
const renderIcon = (custom: ReactNode, fallbackKey: string) =>
custom ?? <AddPlusOutlineIcon key={fallbackKey} width={iconSize} height={iconSize} />;
const spinner = (
<ProgressActivityOutlineIcon
width={iconSize}
height={iconSize}
className="animate-spin"
aria-hidden
/>
);
return (
<button
type={type}
disabled={isDisabled}
aria-busy={isLoading || undefined}
aria-label={ariaLabel ?? (isIcon ? label : undefined)}
onClick={isLoading ? undefined : onClick}
className={cx(
// Structure. Coarse pointers get a >=44px hit area without enlarging the
// desktop (sm) height — matches the system-wide touch-target rule.
"inline-flex items-center justify-center rounded-4 border-[length:var(--stroke-1)] pointer-coarse:min-h-[44px] transition-colors",
// Icons inherit the label color (currentColor) — overrides the path's fill attr.
"[&_svg]:shrink-0 [&_svg_path]:fill-current",
// Focus ring replaces the global outline with the design-system shadow.
"focus-visible:outline-none",
isIcon ? ICON_BOX[size] : cx(s.box, s.width),
s.text,
isDisabled ? DISABLED : interactive,
isLoading && "pointer-events-none",
className,
)}
{...rest}
>
{isIcon ? (
isLoading ? spinner : renderIcon(leftIcon ?? rightIcon, "icon")
) : (
<>
{isLoading ? spinner : showLeftIcon && renderIcon(leftIcon, "left")}
{text != null && (
<span className="min-w-[45px] max-w-[200px] truncate">{text}</span>
)}
{!isLoading && showRightIcon && renderIcon(rightIcon, "right")}
</>
)}
</button>
);
};