Actions
Button
Starts an action, toggles one, or links with the same look. Six weights, four sizes and a loading state.
- Built on
<button><a>- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.8 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { Button } from "@/components/equip/button";
export default function Demo() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, justifyContent: "center" }}>
<Button variant="accent">Save changes</Button>
<Button>Publish</Button>
<Button variant="outline">Cancel</Button>
</div>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px;justify-content:center">
<button type="button" class="eq-button" data-variant="accent" data-size="m">
<span class="eq-button-label">Save changes</span>
</button>
<button type="button" class="eq-button" data-variant="solid" data-size="m">
<span class="eq-button-label">Publish</span>
</button>
<button type="button" class="eq-button" data-variant="outline" data-size="m">
<span class="eq-button-label">Cancel</span>
</button>
</div>Examples
Variants
One accent per screen. Solid for the default action, outline and ghost for everything quieter.
Code
import { Button } from "@/components/equip/button";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<Button variant="accent">Accent</Button>
<Button variant="solid">Solid</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Delete project</Button>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px">
<button type="button" class="eq-button" data-variant="accent" data-size="m">
<span class="eq-button-label">Accent</span>
</button>
<button type="button" class="eq-button" data-variant="solid" data-size="m">
<span class="eq-button-label">Solid</span>
</button>
<button type="button" class="eq-button" data-variant="outline" data-size="m">
<span class="eq-button-label">Outline</span>
</button>
<button type="button" class="eq-button" data-variant="ghost" data-size="m">
<span class="eq-button-label">Ghost</span>
</button>
<button type="button" class="eq-button" data-variant="danger" data-size="m">
<span class="eq-button-label">Delete project</span>
</button>
</div>Sizes
Code
import { Button } from "@/components/equip/button";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Button size="s" variant="outline">Small</Button>
<Button size="m" variant="outline">Medium</Button>
<Button size="l" variant="outline">Large</Button>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button type="button" class="eq-button" data-variant="outline" data-size="s">
<span class="eq-button-label">Small</span>
</button>
<button type="button" class="eq-button" data-variant="outline" data-size="m">
<span class="eq-button-label">Medium</span>
</button>
<button type="button" class="eq-button" data-variant="outline" data-size="l">
<span class="eq-button-label">Large</span>
</button>
</div>Loading and disabled
Loading sets aria-busy and aria-disabled instead of disabled, so the button keeps keyboard focus but does nothing until the work is done.
Code
import { Button } from "@/components/equip/button";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<Button loading>Saving</Button>
<Button variant="outline" loading>Saving</Button>
<Button disabled>Unavailable</Button>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px">
<button type="button" aria-disabled="true" class="eq-button" data-variant="solid" data-size="m" aria-busy="true">
<span class="eq-button-spinner" aria-hidden="true"></span>
<span class="eq-button-label">Saving</span>
</button>
<button type="button" aria-disabled="true" class="eq-button" data-variant="outline" data-size="m" aria-busy="true">
<span class="eq-button-spinner" aria-hidden="true"></span>
<span class="eq-button-label">Saving</span>
</button>
<button type="button" class="eq-button" data-variant="solid" data-size="m" disabled>
<span class="eq-button-label">Unavailable</span>
</button>
</div>Pressed
pressed sets aria-pressed, so the label stays put and assistive tech hears the button as on or off.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
export default function Example() {
const [muted, setMuted] = useState(true);
const [pinned, setPinned] = useState(false);
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<Button variant="outline" pressed={muted} onClick={() => setMuted((v) => !v)}>
Mute alerts
</Button>
<Button variant="ghost" pressed={pinned} onClick={() => setPinned((v) => !v)}>
Pin to sidebar
</Button>
</div>
);
}As a link
With href it renders a real a element that looks the same; disabled drops the href and sets aria-disabled, so it cannot be followed.
Code
import { Button } from "@/components/equip/button";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Button href="#pricing" variant="accent">See pricing</Button>
<Button href="#docs" variant="outline">Read the docs</Button>
<Button href="#changelog" variant="ghost" size="s">Changelog</Button>
<Button href="#beta" variant="outline" disabled>Join the beta</Button>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<a href="#pricing" class="eq-button" data-variant="accent" data-size="m">
<span class="eq-button-label">See pricing</span>
</a>
<a href="#docs" class="eq-button" data-variant="outline" data-size="m">
<span class="eq-button-label">Read the docs</span>
</a>
<a href="#changelog" class="eq-button" data-variant="ghost" data-size="s">
<span class="eq-button-label">Changelog</span>
</a>
<a role="link" aria-disabled="true" class="eq-button" data-variant="outline" data-size="m">
<span class="eq-button-label">Join the beta</span>
</a>
</div>Icon only
square makes an icon-only button as wide as it is tall at every size; the aria-label is its name.
Code
import { Button } from "@/components/equip/button";
const gear = (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="2.25" />
<path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.06 1.06M11.34 11.34l1.06 1.06M3.6 12.4l1.06-1.06M11.34 4.66l1.06-1.06" />
</svg>
);
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Button square size="s" variant="ghost" aria-label="Settings" lead={gear} />
<Button square variant="outline" aria-label="Settings" lead={gear} />
<Button square size="l" aria-label="Settings" lead={gear} />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button aria-label="Settings" type="button" class="eq-button" data-variant="ghost" data-size="s" data-square="true">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="2.25" />
<path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.06 1.06M11.34 11.34l1.06 1.06M3.6 12.4l1.06-1.06M11.34 4.66l1.06-1.06" />
</svg>
</button>
<button aria-label="Settings" type="button" class="eq-button" data-variant="outline" data-size="m" data-square="true">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="2.25" />
<path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.06 1.06M11.34 11.34l1.06 1.06M3.6 12.4l1.06-1.06M11.34 4.66l1.06-1.06" />
</svg>
</button>
<button aria-label="Settings" type="button" class="eq-button" data-variant="solid" data-size="l" data-square="true">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="2.25" />
<path d="M8 1.75v1.5M8 12.75v1.5M1.75 8h1.5M12.75 8h1.5M3.6 3.6l1.06 1.06M11.34 11.34l1.06 1.06M3.6 12.4l1.06-1.06M11.34 4.66l1.06-1.06" />
</svg>
</button>
</div>Dense rows
Extra small is the 24px minimum target; quiet keeps the action in secondary ink so the row's own text leads.
Code
import { Button } from "@/components/equip/button";
export default function Example() {
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center" }}>
<Button size="xs" variant="outline">Retry</Button>
<Button size="xs" variant="quiet">Mark as read</Button>
<Button
size="xs"
variant="quiet"
square
aria-label="Remove invoice-march.pdf"
lead={
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
}
/>
<Button size="s" variant="quiet">Dismiss all</Button>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:flex;flex-wrap:wrap;gap:4px;align-items:center">
<button type="button" class="eq-button" data-variant="outline" data-size="xs">
<span class="eq-button-label">Retry</span>
</button>
<button type="button" class="eq-button" data-variant="quiet" data-size="xs">
<span class="eq-button-label">Mark as read</span>
</button>
<button aria-label="Remove invoice-march.pdf" type="button" class="eq-button" data-variant="quiet" data-size="xs" data-square="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
<button type="button" class="eq-button" data-variant="quiet" data-size="s">
<span class="eq-button-label">Dismiss all</span>
</button>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
variant | "solid" | "accent" | "outline" | "ghost" | "quiet" | "danger" | "solid" | Visual weight. Use accent for the one primary action on a screen. Quiet is a ghost in secondary ink, for row actions that should not compete with the text. |
size | "xs" | "s" | "m" | "l" | "m" | Control height: 24, 28, 34 or 42px. Use xs only inside a dense row, such as a file list or a reaction bar. |
loading | boolean | false | Shows a spinner and sets aria-busy and aria-disabled. It keeps focus but does not act: no click, no submit, no navigation. |
href | string | none | Renders a real link with the same look. disabled then drops the href and sets aria-disabled. |
lead | ReactNode | none | Content before the label, usually an icon. |
trail | ReactNode | none | Content after the label, such as a shortcut. |
square | boolean | false | For an icon-only button: as wide as it is tall at every size. Give it an aria-label. |
pressed | boolean | none | Makes it a toggle button. Sets aria-pressed and, when true, the solid pressed look of a toggle group item. |
...rest | ComponentProps<"button"> | ComponentProps<"a"> | none | Every native button attribute, including type, disabled and form; with href, every native link attribute, such as target and rel. |
Anatomy
| root | .eq-button | The native button, or an a when href is given. |
|---|---|---|
| label | .eq-button-label | |
| spinner | .eq-button-spinner | Replaces lead while loading. |
Keyboard
| EnterSpace | Activates the button. |
|---|---|
| Enter | Follows the link, when href is given. |
Notes
- Defaults to type="button", so it never submits a form by accident. Pass type="submit" when you mean it.
- Loading keeps the button focusable: it sets aria-disabled and aria-busy rather than disabled, drops onClick and turns a submit button into type="button" until it is done.
- A form with a single text field still submits on Enter while its only submit button is loading, so guard the submit handler too.
- aria-disabled="true" passed by you is honoured the same way: the disabled look, still focusable, onClick dropped.
- Pass href to navigate rather than act: it renders a real a, so middle click, open in new tab and the link role all work.
- A disabled link has no href and aria-disabled="true", so it leaves the tab order like a disabled button; a loading link stays focusable.
- Renders on the server with no client JavaScript. Pass onClick from a client component as you would to a native button.
- For a toggle, keep the label the same and pass pressed rather than swapping the variant; assistive tech then hears on or off.
- The pressed look is the solid fill whatever the variant, so pair pressed with outline or ghost: a solid button looks the same pressed or not.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/button.tsx132 lines
import type { ComponentProps, ReactNode } from "react";
import "./button.css";
type ButtonOwnProps = {
/** Visual weight. Use `accent` for the one primary action on a screen; `quiet` is a ghost in secondary ink. */
variant?: "solid" | "accent" | "outline" | "ghost" | "quiet" | "danger";
/** Control height. `xs` is 24px, for an action inside a dense row. */
size?: "xs" | "s" | "m" | "l";
/** Shows a spinner and sets aria-busy and aria-disabled. It stays focusable but does not act. */
loading?: boolean;
/** Icon or other content placed before the label. */
lead?: ReactNode;
/** Icon, shortcut or other content placed after the label. */
trail?: ReactNode;
/** Icon only: as wide as it is tall. Give it an aria-label. */
square?: boolean;
};
export type ButtonAsButtonProps = ComponentProps<"button"> &
ButtonOwnProps & {
/** Leave out for a button. Given, the part renders a link. */
href?: undefined;
/** Makes it a toggle button: sets aria-pressed and draws the pressed look when true. */
pressed?: boolean;
};
export type ButtonAsLinkProps = Omit<ComponentProps<"a">, "href"> &
ButtonOwnProps & {
/** Renders a real link that looks like the button. */
href: string;
/** A link has no disabled attribute: this drops the href and sets aria-disabled. */
disabled?: boolean;
pressed?: never;
type?: never;
};
export type ButtonProps = ButtonAsButtonProps | ButtonAsLinkProps;
function isOn(value: unknown) {
return value === true || value === "true";
}
export function Button(props: ButtonAsLinkProps): ReactNode;
export function Button(props: ButtonAsButtonProps): ReactNode;
export function Button(props: ButtonProps) {
const { variant = "solid", size = "m", loading = false, lead, trail, square, className, children } = props;
const look = {
className: ["eq-button", className].filter(Boolean).join(" "),
"data-variant": variant,
"data-size": size,
"data-square": square || undefined,
"aria-busy": loading || undefined,
};
const inside = (
<>
{loading ? <span className="eq-button-spinner" aria-hidden="true" /> : lead}
{children != null && <span className="eq-button-label">{children}</span>}
{trail}
</>
);
if (props.href !== undefined) {
const {
variant: _v,
size: _s,
loading: _l,
lead: _le,
trail: _t,
square: _sq,
pressed: _p,
type: _ty,
className: _c,
children: _ch,
href,
disabled,
onClick,
...rest
} = props;
// A link that cannot be followed loses its href, so neither a click nor Enter navigates.
const blocked = disabled || loading || isOn(rest["aria-disabled"]);
return (
<a
{...rest}
{...(blocked ? { role: "link", "aria-disabled": true } : { href, onClick })}
// Loading keeps the place in the tab order it had; disabled leaves it, like a disabled button.
{...(loading && !disabled ? { tabIndex: rest.tabIndex ?? 0 } : {})}
{...look}
>
{inside}
</a>
);
}
const {
variant: _v,
size: _s,
loading: _l,
lead: _le,
trail: _t,
square: _sq,
href: _h,
className: _c,
children: _ch,
pressed,
disabled,
type = "button",
onClick,
formAction,
popoverTarget,
popoverTargetAction,
// Invoker commands, for engines and React types that have them.
command,
commandfor,
...rest
} = props as ButtonAsButtonProps & { command?: string; commandfor?: string };
// aria-disabled from the caller or from loading keeps focus where it is; the button just does nothing.
const blocked = loading || isOn(rest["aria-disabled"]);
return (
<button
{...rest}
// Only when given, so an aria-pressed passed in rest still stands.
{...(pressed === undefined ? {} : { "aria-pressed": pressed })}
{...(blocked
? { type: "button" as const, "aria-disabled": true }
: { type, onClick, formAction, popoverTarget, popoverTargetAction, command, commandfor })}
{...look}
disabled={disabled}
>
{inside}
</button>
);
}components/equip/button.css164 lines
@layer equip {
.eq-button {
--_bg: var(--eq-solid);
box-sizing: border-box;
--_ink: var(--eq-solid-ink);
--_line: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--eq-space-2);
height: var(--eq-h-m);
padding: 0 var(--eq-space-3);
border: 1px solid var(--_line);
border-radius: var(--eq-radius-m);
background: var(--_bg);
color: var(--_ink);
font: var(--eq-weight-strong) var(--eq-text-m) / 1 var(--eq-font);
letter-spacing: -0.005em;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
user-select: none;
transition:
background-color var(--eq-dur) var(--eq-ease),
border-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-button[data-size="s"] {
height: var(--eq-h-s);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
font-size: var(--eq-text-s);
gap: var(--eq-space-1);
}
.eq-button[data-size="l"] {
height: var(--eq-h-l);
padding-inline: var(--eq-space-4);
font-size: var(--eq-text-l);
}
/* Extra small: the 24px minimum target, for an action inside a dense row such as a file, a reaction or a notification. */
.eq-button[data-size="xs"] {
height: 24px;
padding-inline: 6px;
border-radius: var(--eq-radius-s);
font-size: var(--eq-text-xs);
gap: var(--eq-space-1);
}
/* Icon only: as wide as it is tall, at every size. */
.eq-button[data-square] {
width: var(--eq-h-m);
padding-inline: 0;
}
.eq-button[data-square][data-size="xs"] {
width: 24px;
}
.eq-button[data-square][data-size="s"] {
width: var(--eq-h-s);
}
.eq-button[data-square][data-size="l"] {
width: var(--eq-h-l);
}
.eq-button[data-variant="accent"] {
--_bg: var(--eq-accent);
--_ink: var(--eq-accent-ink);
/* The accent has a weak edge on a pale surface, so the ink draws one. */
--_line: color-mix(in srgb, var(--eq-accent-ink) 55%, var(--eq-accent));
}
.eq-button[data-variant="outline"] {
--_bg: transparent;
--_ink: var(--eq-ink);
--_line: var(--eq-line-strong);
}
.eq-button[data-variant="ghost"] {
--_bg: transparent;
--_ink: var(--eq-ink);
}
/* A ghost in secondary ink, for actions that should not compete with the text beside them. */
.eq-button[data-variant="quiet"] {
--_bg: transparent;
--_ink: var(--eq-ink-2);
}
.eq-button[data-variant="danger"] {
--_bg: var(--eq-danger);
--_ink: var(--eq-solid-ink);
}
/* Pressed is the shared "on" state, as on a toggle group item: the solid fill, whatever the variant. */
.eq-button[aria-pressed="true"] {
--_bg: var(--eq-solid);
--_ink: var(--eq-solid-ink);
--_line: transparent;
}
@media (hover: hover) {
.eq-button:hover:not(:disabled, [aria-disabled="true"]) {
background: color-mix(in srgb, var(--_bg) 88%, var(--_ink));
}
.eq-button:is([data-variant="outline"], [data-variant="ghost"], [data-variant="quiet"]):hover:not(:disabled, [aria-disabled="true"], [aria-pressed="true"]) {
background: var(--eq-tint);
}
}
.eq-button:active:not(:disabled, [aria-disabled="true"]) {
scale: var(--eq-press);
}
.eq-button:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
/* aria-disabled is disabled that keeps focus: loading, a link that cannot be followed, or set by you. */
.eq-button:is(:disabled, [aria-disabled="true"]) {
opacity: 0.45;
cursor: not-allowed;
}
.eq-button[aria-busy="true"] {
opacity: 0.75;
cursor: progress;
}
.eq-button > svg {
flex: none;
width: 1.15em;
height: 1.15em;
}
.eq-button-spinner {
flex: none;
width: 1em;
height: 1em;
border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
border-top-color: currentColor;
border-radius: var(--eq-radius-full);
animation: eq-button-spin 700ms linear infinite;
}
@keyframes eq-button-spin {
to {
rotate: 1turn;
}
}
@media (prefers-reduced-motion: reduce) {
.eq-button-spinner {
animation-duration: 2.4s;
}
}
}