Skip to content

Button

Use a Button for anything that happens in the page — submitting a form, opening a dialog, starting a request. Its appearance is set by two independent props: variant is how loud the button is, tone is which color set it draws from. Everything else the component adds on top of Base UI is about states you would otherwise have to wire yourself: a loading state that blocks double submits without losing focus, and square icon-only sizing that holds a 24px hit target.

button/variants.tsx

Import

import { Button } from "@forte-ui/react";

Examples

Tones

variant and tone combine freely, and all sixteen pairs are defined. That means emphasis and meaning stay separate decisions: tone="danger" variant="solid" is the destructive action you want people to find, while tone="danger" variant="outline" is the same meaning at low emphasis — the right shape for a delete sitting among other row actions. The default is neutral: a view has many buttons and one call to action, so an ordinary button reads as a control and tone="primary" is kept for the action you want found — which is what keeps the accent legible as a signal.

button/tones.tsx

Sizes

size sets the height, horizontal padding and font size. The values come from the control tokens, so the ambient data-forte-density setting moves them too: size="sm" is 28px tall by default and exactly 24px under data-forte-density="compact", which is the SC 2.5.8 floor — the density scale deliberately stops there. fullWidth is separate from size — it stretches the button to its container, for stacked forms and mobile action bars.

button/sizes.tsx

Loading

loading shows a spinner, hides the label from paint while keeping its width so the button cannot resize under the pointer, sets aria-busy, and blocks activation. Use it for the span of one request, and pair it with a loadingLabel that names the operation.

The indicator is Spinner's ring, rendered decorative and at tone="current" — it takes the button's own text color, so it stays legible through every variant and tone, and it leaves the announcement to aria-busy and loadingLabel rather than opening a second live region for the same wait. Size it with --forte-button-spinner-size.

button/loading.tsx

No changes saved yet

Icon-only

iconOnly makes the button square and keeps it on the control height. The space around the icon is not a fixed padding: --forte-button-icon-padding is derived from the height and --forte-button-icon-size, so a sm icon button is the 28px the Sizes section promises and sits level with a labeled button, a Toggle or a ThemeToggle beside it, at every size and under every data-forte-density. The square is floored at --forte-target-min (24px) so a small icon button still meets WCAG SC 2.5.8, and it only grows when something bigger than the knob says is dropped in — a consumer-sized icon — keeping the same padding around it, so the icon never crowds the corners under data-forte-radius="pill", where the corner is the nearest point of the curve. It is a shape, not a fifth variant: every variant, tone and size works exactly as it does with a label. The icon itself should be aria-hidden — the button's name comes from aria-label.

You do not have to size the glyph. Button sizes a direct svg child of its content from --forte-button-icon-size, which tracks size one step above the label on the same scale — 14px, 16px, 18px. That is there because an icon set's own default is whatever that set picked, and here it decides geometry rather than just weight: lucide draws at 24px, which would push a 36px md button out past the controls beside it. Re-point the knob when you want a larger glyph — the padding follows it, so the square stays on the control height until the glyph outgrows it — or put a size class on the icon; only a direct child is touched, so a composed forte-ui component keeps sizing its own glyphs.

button/icon-only.tsx
solid
soft
outline
ghost

An Avatar works as the icon too — the usual shape of an account menu's trigger. A direct Avatar.Root child takes --forte-button-avatar-size, two thirds of the control height, and the padding derives from that diameter instead of the glyph's, so the square stays on the control height and sits level with a ThemeToggle beside it rather than growing around the avatar's own size. That prop is overridden inside an icon-only button — re-point the knob to change the diameter, and once it outgrows the height the square grows with it, as it does for any oversized icon.

button/avatar-trigger.tsx

Rendering as another element

render replaces the rendered tag, and nativeButton={false} tells Base UI the replacement is not a <button> so it supplies button semantics itself — role="button" plus Enter and Space activation. For an anchor that genuinely navigates, pass role="link" back so the element keeps the semantics its behavior actually matches.

button/as-link.tsx

Accessibility

Keyboard interactions
KeyBehavior
TabMoves focus to the button. A loading button stays in the tab order, so focus is never dropped mid-request.
EnterActivates the button. On a nativeButton={false} element, Base UI calls onClick on key down.
SpaceActivates the button on release, matching native button behavior.

The focus ring is an outline, not a box-shadow, so it follows the button's border radius and survives forced-colors mode, where box shadows are stripped.

Two disabled states behave differently, and the difference matters. A plain disabled button is removed from the tab order: a keyboard or screen reader user tabbing through the page never lands on it, so any explanation of why it is unavailable has to live somewhere they will actually reach. A loading button keeps focusableWhenDisabled on for the duration, which is what stops the browser from blurring it — focus stays put, and aria-busy and loadingLabel stay on the element the user is still on. You can pass focusableWhenDisabled explicitly to get the same reachability for an ordinary disabled button.

Theming

Every property below is declared on the button's own root element. That means setting one on :root, on a theme class or on a wrapper has no effect — the element's own declaration wins over an inherited value. Override them on the button itself, through the className prop (an unlayered rule beats the library's @layer forte.components, whatever its specificity) or an inline style. To move every button at once, re-point the global tokens in the Default column instead — --forte-color-*, --forte-gray-*, --forte-radius-*, --forte-control-* and --forte-font-* are only read here, never re-declared, so those do inherit from :root or a theme scope. State is exposed on data attributes — data-variant, data-tone, data-size, data-loading, data-icon-only, data-full-width, data-disabled — which is also what Tailwind arbitrary variants like data-[variant=solid]:… target.

Theming tokens for Button
PropertyControlsDefault
--forte-button-radiusCorner radiusvar(--forte-radius-control)
--forte-button-font-weightLabel weightvar(--forte-font-weight-medium)
--forte-button-gapSpace between icon and labelvar(--forte-control-gap)
--forte-button-border-widthBorder thickness (only outline gives it a color)1px
--forte-button-heightButton height; reassigned by sizevar(--forte-control-h-md)
--forte-button-padding-xHorizontal padding; reassigned by size. iconOnly replaces it with --forte-button-icon-padding on all four sides.var(--forte-control-px-md)
--forte-button-font-sizeLabel size; reassigned by sizevar(--forte-font-size-2)
--forte-button-icon-sizeSize of an svg a consumer drops in; reassigned by size. Applies to direct children of the content span only, so a composed forte-ui component keeps sizing its own glyphs.var(--forte-font-size-3)
--forte-button-spinner-sizeDiameter of the loading spinner1em
--forte-button-hover-liftHow far the button rises on hover. Multiplied by --forte-motion-ok, so a literal value still collapses under reduced motion.var(--forte-control-hover-lift, 0px)
--forte-button-hover-scaleHow far the button grows on hover; also multiplied by --forte-motion-ok.var(--forte-control-hover-scale, 1)
--forte-button-press-scaleHow far the button squashes while held.var(--forte-scale-press)
--forte-button-bgResting background; filled in by varianttransparent
--forte-button-bg-hoverHover backgroundtransparent
--forte-button-bg-activePressed backgroundtransparent
--forte-button-fgLabel and icon colorvar(--forte-color-foreground)
--forte-button-border-colorBorder colortransparent
--forte-button-tone-solidTone's solid fillvar(--forte-gray-12)
--forte-button-tone-solid-hoverTone's solid fill on hovervar(--forte-gray-11)
--forte-button-tone-solid-activeTone's solid fill when pressedvar(--forte-gray-10)
--forte-button-tone-on-solidText color on the solid fillvar(--forte-gray-1)
--forte-button-tone-softTone's tinted fill, and the hover wash for outline and ghostvar(--forte-gray-3)
--forte-button-tone-soft-hoverTinted fill on hovervar(--forte-gray-4)
--forte-button-tone-soft-activeTinted fill when pressedvar(--forte-gray-5)
--forte-button-tone-textLabel color for soft, outline and ghostvar(--forte-color-foreground)
--forte-button-tone-borderBorder color for outlinevar(--forte-color-border)
--forte-button-icon-paddingSpace between the icon and the button's edge (iconOnly only). Derived from the height and --forte-button-icon-size so the square lands on the control height; set a length to pad a larger icon instead.max( 0px, calc( (var(--forte-button-height) - var(--forte-button-icon-only-content)) / 2 - var(--forte-button-border-width) ) )
--forte-button-avatar-sizeDiameter of an Avatar composed as the icon (iconOnly only). Two thirds of the control height, so it follows size and density.calc(var(--forte-button-height) * 2 / 3)

The two groups are layered: variant decides which of the --forte-button-tone-* values become the background, text and border slots, and tone decides what those values are. To restyle a whole tone, override the tone properties; to restyle one button, override the slots directly.

.checkout-cta {
  --forte-button-radius: var(--forte-radius-full);
  --forte-button-tone-solid: var(--forte-color-secondary);
  --forte-button-tone-on-solid: var(--forte-color-on-secondary);
}

API reference

Props for Button
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
focusableWhenDisabledbooleantrue while `loading`, otherwise falseKeep the button focusable while it is disabled. A native `disabled` button is blurred by the browser, which drops focus to `<body>` and loses the user's place in the tab order — and takes `aria-busy` and `loadingLabel` out of earshot with it. Left unset, this turns itself on for the duration of `loading` so the busy state is actually announced; pass it explicitly to override.
fullWidthbooleanfalseStretch the button to fill the width of its container.
iconOnlybooleanfalseRender as a square button sized for a single icon. Enforces the 24px minimum hit target from WCAG SC 2.5.8. Always pair with `aria-label`.
loadingbooleanfalseShow a busy indicator and block interaction. The label keeps its space so the button cannot resize mid-interaction.
loadingLabelstringLoadingAnnounced to assistive technology while `loading` is true. Without it, a screen reader user gets no signal that anything is happening.
sizeButtonSizemdSize of the button. Actual dimensions also follow the ambient `data-forte-density` setting.
toneButtonToneneutralWhich semantic color set the button draws from. Combines freely with `variant` — `tone="danger" variant="outline"` is a low-emphasis destructive action. `neutral` by default: a view has many buttons and one call to action, so an ordinary button reads as a control and `tone="primary"` is kept for the action you want found.
variantButtonVariantsolidHow much visual weight the button carries.

The table above is generated from the component's TypeScript source. Button also forwards everything Base UI's Button accepts — render, nativeButton, disabled — along with the usual <button> attributes such as type, form, onClick and any aria-* prop.