Button

Developer Experience (DX)

How developers use the Button, with one consistent, typed API that composes with links, routers and other components.

Next, let's look at how developers use the Button. It has one API, with the same names as every other component, and it's typed end to end.

import { Button } from "@/components/Button";

export function Settings() {
  return (
    <Button color="primary" onClick={save}>
      Save changes
    </Button>
  );
}

Consistent

Once you've learned the Button's API, you already know most of every other component's. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
variantPropThe emphasis, from solid down to link
colorPropThe intent, from primary to error
sizePropxs, sm, md, lg or xl
labelPropThe text content, when it's plain text
disabledPropThe component can't be used
loadingPropThe component is busy with an action
leading, trailingSlotsContent before and after the main content
clickEventThe component was activated

We recommend variant="solid", color="neutral" and size="md" as the defaults, while type always defaults to "button". We chose neutral so that a primary button is always a conscious decision, made once per view.

Type-safe

Props, events and slots are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like variant="primary".

PropTypeDefaultDescription
variant"solid" | "outline" | "soft" | "subtle" | "ghost" | "link""solid"How much attention the button asks for
color"primary" | "secondary" | "neutral" | "success" | "info" | "warning" | "error""neutral"What the action means
size"xs" | "sm" | "md" | "lg" | "xl""md"How big the button is
labelstringThe label, when it's plain text. The default slot takes precedence over it
iconOnlybooleanfalseShows only the leading icon, while the label still names the button
type"button" | "submit" | "reset""button"The native button type
hrefstringRenders a link that looks like a button
disabledbooleanfalseBlocks activation
focusableWhenDisabledbooleanfalseKeeps a disabled button in the tab order
loadingbooleanfalseBlocks activation and shows a spinner, while keeping focus and size
loadingLabelstring"Loading"Announced to screen readers when loading starts
EventPayloadDescription
clickMouseEventFires when the button is activated, but never while it's disabled or loading
SlotDescription
defaultThe label
leadingAn icon before the label, which is also the icon of an icon-only button
trailingAn icon after the label

We also export ButtonProps, which comes in handy for wrappers and data-driven UIs:

import type { ButtonProps } from "@/components/Button";

const actions: (ButtonProps & { id: string })[] = [
  { id: "cancel", label: "Cancel", variant: "ghost" },
  { id: "publish", label: "Publish", color: "primary" },
];

Composable

The root element is the <button> (or the <a>) itself, so everything you pass lands directly on it. That includes classes, styles, id, ARIA attributes, event listeners and native attributes like name, value, form and popovertarget. A ref to the Button reaches that same element, so there's no wrapper to reach through.

<Button type="submit" name="intent" value="publish" color="primary">Publish</Button>
<Button popoverTarget="filters" variant="outline">Filters</Button>

Passing an href renders an <a>. For links that go through your router, give the Button the link's href and your router's click handler, which leaves modified clicks, like Ctrl-click to open a new tab, to the browser. Routers that handle every link on the page only need the href:

import { useHref, useLinkClickHandler } from "react-router";

export function SettingsLink() {
  const href = useHref("/settings");
  const navigate = useLinkClickHandler("/settings");

  return (
    <Button href={href} onClick={navigate}>
      Settings
    </Button>
  );
}

Since the Button doesn't know anything about your router, it works with any of them.

With other components

  • Tooltips on icon-only buttons repeat the button's label. The hidden label is what names the button, and the tooltip simply shows that name.
  • Menus and popovers set aria-haspopup, aria-expanded and aria-controls on the Button, which passes them straight through.
  • The loading spinner is a Spinner. The Spinner draws itself, while the Button places it over the content, sizes it with --button--icon--size and announces loadingLabel, since the Spinner stays hidden from screen readers.
  • A group of buttons is simply a flex container with a gap. A toolbar, where the arrow keys move between buttons, is a separate pattern.

Controllable

The Button doesn't keep any state of its own. Every state is a prop or an attribute that you control, so all of them are always within reach:

StateHow you control it
Disableddisabled, and focusableWhenDisabled
Loadingloading, set when the action starts (see Loading)
Pressedaria-pressed, bound to whether it's on
Expandedaria-expanded, bound to whether its popup is open
FocusA ref to its element, and focus()

DX Checklist

RuleLevelScopeRequirementBasisCheck
button/shared-vocabularyMustComponentIt uses the shared names: variant, color, size, label, disabled, loading, leading, trailing and clickOpen ComponentsReview
button/typedMustComponentProps, events and slots are typed, with unions rather than stringOpen ComponentsType check
button/explicit-typeMustBothtype defaults to "button", and submit buttons say type="submit" explicitlyOpen ComponentsUnit test
button/root-elementMustComponentThe root element is the button itself, so attributes, listeners and refs land on itOpen ComponentsUnit test
button/statelessShouldComponentEvery state is a prop or an attribute that the parent controlsOpen ComponentsReview

FAQ

Copyright © 2026