Developer Experience (DX)
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:
| Name | Kind | What it means |
|---|---|---|
variant | Prop | The emphasis, from solid down to link |
color | Prop | The intent, from primary to error |
size | Prop | xs, sm, md, lg or xl |
label | Prop | The text content, when it's plain text |
disabled | Prop | The component can't be used |
loading | Prop | The component is busy with an action |
leading, trailing | Slots | Content before and after the main content |
click | Event | The 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".
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
label | string | The label, when it's plain text. The default slot takes precedence over it | |
iconOnly | boolean | false | Shows only the leading icon, while the label still names the button |
type | "button" | "submit" | "reset" | "button" | The native button type |
href | string | Renders a link that looks like a button | |
disabled | boolean | false | Blocks activation |
focusableWhenDisabled | boolean | false | Keeps a disabled button in the tab order |
loading | boolean | false | Blocks activation and shows a spinner, while keeping focus and size |
loadingLabel | string | "Loading" | Announced to screen readers when loading starts |
| Event | Payload | Description |
|---|---|---|
click | MouseEvent | Fires when the button is activated, but never while it's disabled or loading |
| Slot | Description |
|---|---|
default | The label |
leading | An icon before the label, which is also the icon of an icon-only button |
trailing | An 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>
Links and routers
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-expandedandaria-controlson 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--sizeand announcesloadingLabel, 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:
| State | How you control it |
|---|---|
| Disabled | disabled, and focusableWhenDisabled |
| Loading | loading, set when the action starts (see Loading) |
| Pressed | aria-pressed, bound to whether it's on |
| Expanded | aria-expanded, bound to whether its popup is open |
| Focus | A ref to its element, and focus() |
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
button/shared-vocabulary | Must | Component | It uses the shared names: variant, color, size, label, disabled, loading, leading, trailing and click | Open Components | Review |
button/typed | Must | Component | Props, events and slots are typed, with unions rather than string | Open Components | Type check |
button/explicit-type | Must | Both | type defaults to "button", and submit buttons say type="submit" explicitly | Open Components | Unit test |
button/root-element | Must | Component | The root element is the button itself, so attributes, listeners and refs land on it | Open Components | Unit test |
button/stateless | Should | Component | Every state is a prop or an attribute that the parent controls | Open Components | Review |
FAQ
<button type="submit"> does everything it does, and more.<button> or <a href>, so even before the page hydrates, links navigate, submit buttons submit and disabled buttons stay disabled. Only the parts that need JavaScript have to wait for it, which are your click handlers and the blocking of focusable disabled and loading buttons.