Skip to content

Button

Generic button primitive. It names a role, never a look: what each role looks like is the brand's, and lives in layered CSS keyed off the attributes below. So there is no outline, ghost or link — those are results of a brand's definitions — and no size, because a size scale is a fixed set of appearance decisions a package cannot make for every brand.

import { Button } from "@evolve-storefront/ui/button";

Preview

PrimaryOpen in Storybook ↗

Usage

The action this view wants taken. At most one per region.

<Button variant="primary" type="submit" isPending={isPending}>
{t("placeOrder")}
</Button>;

Props

PropTypeDescription
variant?ButtonVariant
tone?ButtonTone
isIconOnly?boolean

The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that.

More

Not inferred from the children. A kebab menu is as often a glyph or an SVG as an <Icon>, and a visually-hidden label — the accessible thing to add — would break any :only-child predicate silently.

asChild?boolean
type?"button" | "submit" | "reset"
isPending?boolean

The action is running. The button keeps its label, so its width and accessible name hold, shows a spinner and is aria-busy. It stays focusable rather than disabled, so focus is not dropped mid-save; clicks and the form submit it would trigger are ignored. Not supported with asChild.

data-component?undefined

A button's identity is not a caller's to rename — see buttonProps. Typed away so a call site renaming it fails to compile rather than passing an attribute that is silently discarded.

Any other props go to the root element, like on a plain HTML element.

Examples

SecondaryA real alternative, equally available, not competing for the eye.
<Button variant="secondary" onClick={saveForLater}>
{t("saveForLater")}
</Button>;
Open in Storybook ↗
TertiaryReachable without adding weight — row actions, dismissals, dense UI.
<Button variant="tertiary" onClick={onDismiss}>
{t("dismiss")}
</Button>;
Open in Storybook ↗
Disabled
<Button type="submit" disabled={!canSubmit}>
{t("submit")}
</Button>;
Open in Storybook ↗
PendingThe action is running: a spinner over the label, which stays in place so the button keeps its width and name.
<Button type="submit" isPending={mutation.isPending}>
{t("continue")}
</Button>;
Open in Storybook ↗
As child (link)
<Button asChild>
<a href="/orders">View orders</a>
</Button>
Open in Storybook ↗
VariantsRank and tone are orthogonal, which is the point of splitting them: the confirm in a delete dialog is the loudest thing on it and the dangerous one, and a line-item delete is the same tone at a quieter rank.
<Button variant="primary">Place order</Button>
<Button variant="tertiary" tone="destructive">Remove</Button>
<Button variant="primary" tone="destructive">Delete account</Button>
Open in Storybook ↗
Icon onlyNo visible label, so nothing to be wide for.
<Button variant="tertiary" isIconOnly aria-label="More actions">
<Icon name="more" />
</Button>
Open in Storybook ↗
Trigger in a formA trigger's child inside a form opens its overlay and leaves the form alone.
<SheetTrigger asChild>
<Button variant="secondary">Search all addresses</Button>
</SheetTrigger>
Open in Storybook ↗