DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

Button

Displays a button or a component that looks like a button

Playground

Installation

pnpm add @tessinaui/ui

Usage

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.

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.

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.

When to use which

  • 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.
  • Destructive is intent="error", not a variant — so you can have a quiet destructive action (variant="ghost" intent="error") as well as a loud one. Pair an irreversible one with a confirmation step.
  • A button does something; a link goes somewhere. If it only navigates, it is a link — render it as one (see below) rather than styling a button to look like it.
  • More than about four actions in a row belongs in a Toolbar or a DropdownMenu, not a row of buttons.

Labels name 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.

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:

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:

import { buttonVariants } from "@tessinaui/ui";

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

On this page

PlaygroundInstallationUsageExamplesDefaultVariantsSizesWith IconIcon OnlyLoadingOn a coloured surfaceSkeletonDisabledRoundedLink exampleAPI ReferencePropsVariantsVariant OptionsIntent OptionsSize OptionsRounded OptionsAccessibilityWhen to use whichAs a link