Button
Displays a button or a component that looks like a button
Playground
Installation
pnpm add @tessinaui/uiUsage
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:
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
fullWidth | boolean | false | Stretch to the width of the container |
loading | boolean | false | Busy: overlays a spinner, keeps the button's width and its focus, suppresses clicks and Enter/Space, sets aria-busy |
leadingIcon / trailingIcon | ReactNode | — | Decorative icon before/after the label |
render | ReactElement | — | 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
| Key | Result |
|---|---|
| Tab | Moves focus to the button |
| Enter | Activates it |
| Space | Activates 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-busyon the control. The spinner inside is decorative — a live region there would make the name read "Loading Save". disableduses the native attribute; an anchor host usesaria-disabledand 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);
xsandsmgrow on small screens and keep their compact density atmdand up. outlineborders 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
primaryper view. More than one and the hierarchy stops meaning anything. Everything else issecondary,outlineorghost, 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>