DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ButtonIconButtonLinkLabelBadgeStatusChipAvatarShortcutSpinnerSkeletonProgressMeter
AI agentsContributing
  1. Components
  2. Button

Button

Displays a button or a component that looks like a button

$ pnpm add @tessinaui/ui

When to use

  • Any single action on a page, form or dialog: Save, Continue, Cancel, Send.
  • One primary per view. More than one and the hierarchy stops meaning anything. Everything else is secondary, outline or ghost, chosen by how much attention it deserves.
  • A destructive action is intent="error", not a variant, so a quiet destructive action (variant="ghost" intent="error") sits beside a loud one. Pair an irreversible one with a confirmation step.
  • Short async work where a spinner on the button is feedback enough: set loading and the button keeps its width and its focus.
  • A label that names the action: "Save changes", "Delete account", "Send invite", not "OK", "Yes" or "Click here". A label that reads well out of context also reads well to a screen-reader user who arrived at it directly.

When not to use

  • To navigate. A button does something; a link goes somewhere. Use Link, or render the button as one (As a link), rather than styling a button to look like it.
  • For a state the user switches on and off. Use ToggleButton, which keeps a pressed state.
  • When one default action has close alternatives. Use SplitButton.
  • For more than about four actions in a row. Move them into a Toolbar or a DropdownMenu.
  • For an icon control with a notification badge or an outside label. Use IconButton.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Button } from "@tessinaui/ui";

Examples

Default

A basic button with the default primary variant.

Variants

Four visual styles — primary, secondary, ghost, and outline — alongside the semantic intent colors.

Sizes

Five text sizes from xs through xl (32 / 40 / 48 / 56 / 96 px), plus icon — a 48 px square that requires an accessible name (see Icon Only below). xs and sm keep a 44 px minimum touch target on phones.

With Icon

leadingIcon and trailingIcon wrap the icon in an aria-hidden span and size it from the button's own scale, so it stays decorative and the label alone names the control.

Icon Only

size="icon" is the square form. It requires aria-label or aria-labelledby — the icon is hidden from assistive technology, so without one the button announces nothing. TypeScript enforces it.

For a dedicated icon control with a badge and an optional external label, use IconButton.

Loading

loading overlays a spinner and hides the label with opacity, so the button keeps its width and its accessible name. It is deliberately not natively disabled: a disabled element loses focus the moment the attribute lands, which would throw the user's place away mid-click. Instead the button keeps focus and reports aria-busy + aria-disabled, with clicks and Enter/Space suppressed.

On a coloured surface

Wrap a region in <Surface> and every button inside inherits tone="on-color", deriving its colours from the ink the surface paints. No per-button prop.

Skeleton

ButtonSkeleton matches the real button's height and radius, read from the same scale, and announces "Loading" as a live region.

Disabled

The disabled state applies to every variant.

Rounded

Control the corner radius with the rounded prop.

Link example

render puts the button's appearance on another element. An anchor host keeps link semantics — see As a link below for what that means and when to reach for buttonVariants() instead.

RTL

Button has no dir prop — it follows the nearest dir ancestor, so leadingIcon sits on the right of the label under RTL and trailingIcon on the left. Glyphs are never flipped for you: a directional one takes rtl:rotate-180, as the arrow here does.

API Reference

Props

The Button component extends native HTML button attributes and includes:

PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost" | "outline""primary"The visual style variant
intent"none" | "error" | "warning" | "success" | "info""none"The semantic intent color
size"xs" | "sm" | "md" | "lg" | "xl" | "icon""md"The size of the button
rounded"full" | "xl" | "lg" | "md" | "sm" | "none""full"The corner treatment
tone"default" | "on-color"inherited from <Surface>High-contrast styling for a coloured or dark surface
fullWidthbooleanfalseStretch to the width of the container
loadingbooleanfalseBusy: overlays a spinner, keeps the button's width and its focus, suppresses clicks and Enter/Space, sets aria-busy
leadingIcon / trailingIconReactNode—Decorative icon before/after the label
renderReactElement—Render into another element — see As a link

All standard HTML button attributes are also supported (onClick, disabled, …).

type defaults to "button". A button inside a <form> therefore does not submit it unless you ask for type="submit" — the browser default is the opposite, and it is a common source of accidental submits.

size="icon" requires aria-label or aria-labelledby; TypeScript enforces it. The icon is hidden from assistive technology, so without a name the control announces nothing at all.

Variants

Variant Options

  • primary: Filled button for main actions
  • secondary: Gray button for alternative actions
  • ghost: Transparent button with no background
  • outline: Button with border, fills on hover and pressed states

Intent Options

Intent controls the semantic color of the button, independent of the variant style:

  • none: Default color scheme (purple for primary, gray for secondary)
  • error: Red color scheme for destructive or error actions
  • warning: Yellow color scheme with dark text for warning actions
  • success: Green color scheme for success or confirmation actions
  • info: Blue color scheme for informational actions

Size Options

  • xs: Extra small (32px height, 14px text)
  • sm: Small (40px height, 14px text)
  • md: Medium (48px height, 14px text) - default
  • lg: Large (56px height, 16px text)
  • xl: Extra large (96px height, 24px text)
  • icon: Square button for icons (48x48px)

Rounded Options

  • full: Fully rounded corners (pill shape) - default
  • xl: Extra-large rounded corners
  • lg: Large rounded corners
  • md: Medium rounded corners
  • sm: Small rounded corners
  • none: No rounded corners (sharp edges)

Accessibility

Follows the WAI-ARIA Button pattern.

Keyboard

KeyResult
TabMoves focus to the button
EnterActivates it
SpaceActivates it (a native <button>; an anchor host takes Enter only, as links do)

What is announced

  • The label. An icon-only button is named by aria-label / aria-labelledby, which the type system requires.
  • While loading: aria-busy on the control. The spinner inside is decorative — a live region there would make the name read "Loading Save".
  • disabled uses the native attribute; an anchor host uses aria-disabled and leaves the tab order, because <a disabled> means nothing.

VoiceOver + Safari, passed 2026-09-14. Tab reads "Save, button"; the loading button keeps its name and adds "busy, dimmed"; the disabled one is skipped; icon-only buttons read "Close, button" and "Favourite, button"; a toggle reads "Bold, toggle button". Pressing the toggle is silent, exactly as a native <button aria-pressed> is in VoiceOver + Safari. NVDA and JAWS are untested.

Visual

  • The focus ring is 2px at 3:1 against its offset, and the offset is painted with the page background so it reads in both themes.
  • Touch targets meet 44×44px on phones and tablets (WCAG 2.5.5); xs and sm grow on small screens and keep their compact density at md and up.
  • outline borders clear 3:1 against the page (WCAG 1.4.11) — on a transparent button the border is the control.
  • The press animation is disabled under prefers-reduced-motion.

As a link

Pass the element to render. An anchor host keeps link semantics — no role="button", activated by Enter (not Space), and it appears in the screen reader's list of links, because that is what it is:

example.tsx
import { Button } from "@tessinaui/ui";
import Link from "next/link";

<Button render={<Link href="/dashboard" />}>Dashboard</Button>

Disabling works too, without the attribute an anchor would ignore: a disabled link gets aria-disabled, leaves the tab order, and has its activation blocked. A loading link gets aria-disabled and aria-busy and has its activation blocked, but stays focusable — the same rule as a loading button, so focus is never thrown away mid-interaction. When both are set, disabled wins. The render element's own ref, onClick and onKeyDown are merged with the button's (the button's handler runs first; the element's is skipped if it called preventDefault()), and its children are replaced by the button's.

If you want the look without any button behaviour, use the exported buttonVariants() on a plain element instead:

example.tsx
import { buttonVariants } from "@tessinaui/ui";

<a href="/dashboard" className={buttonVariants({ variant: "secondary" })}>
  Dashboard
</a>
Scroll AreaIconButton

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsSizesWith IconIcon OnlyLoadingOn a coloured surfaceSkeletonDisabledRoundedLink exampleRTLAPI ReferencePropsVariantsVariant OptionsIntent OptionsSize OptionsRounded OptionsAccessibilityKeyboardWhat is announcedVisualAs a link