Skip to main content

Button

Primary action element. Five variants, three sizes, icon support, loading state, press bloom on every click. Optionally swap to an MD3-style ripple via enableRipple.

Quick start
import { FilledButton, OutlinedButton } from "mates-ui";
import { SaveIcon } from "mates-icons";

FilledButton("Save", { icon: SaveIcon(), size: "md" });

// MD3 ripple instead of the default soft bloom:
FilledButton("Save", { enableRipple: true });

// Popup trigger support:
OutlinedButton("Open", {
  panel: PopupList({ items: [{ id: "a", label: "Option A" }] }),
  popup: { position: "bottom-start" },
});

Variants

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton, HybridButton, TextButton, ElevatedButton, TonalButton, DangerButton } from 'mates-ui';

const App = () => () => html`
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Filled")}
${OutlinedButton("Outlined")}
${HybridButton("Hybrid")}
${TextButton("Text")}
${ElevatedButton("Elevated")}
${TonalButton("Tonal")}
${DangerButton("Danger")}
</x-row>
`;

renderApp(App, document.getElementById('app'));

Sizes

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton, TextButton } from 'mates-ui';

const App = () => () => html`
<x-col gap="var(--md-space-3)">
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Filled", { size: "sm" })}
${OutlinedButton("Outlined", { size: "sm" })}
${TextButton("Ghost", { size: "sm" })}
</x-row>
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Filled", { size: "md" })}
${OutlinedButton("Outlined", { size: "md" })}
${TextButton("Ghost", { size: "md" })}
</x-row>
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Filled", { size: "lg" })}
${OutlinedButton("Outlined", { size: "lg" })}
${TextButton("Ghost", { size: "lg" })}
</x-row>
</x-col>
`;

renderApp(App, document.getElementById('app'));

With icons

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton, TextButton, TonalButton, DangerButton } from 'mates-ui';
import { SaveIcon, DownloadIcon, ShareIcon, DeleteIcon, ArrowForwardIcon, AddIcon, CloudUploadIcon, ContentCopyIcon } from 'mates-icons';

const App = () => () => html`
<x-col gap="var(--md-space-3)">
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Save", { icon: SaveIcon() })}
${OutlinedButton("Download", { icon: DownloadIcon() })}
${TextButton("Share", { icon: ShareIcon() })}
${DangerButton("Delete", { icon: DeleteIcon() })}
${OutlinedButton("Next", { trailingIcon: ArrowForwardIcon() })}
</x-row>
<x-row gap="var(--md-space-2)" wrap>
${OutlinedButton("Add item", { icon: AddIcon(), size: "sm" })}
${TonalButton("Publish", { icon: CloudUploadIcon() })}
${TextButton("Duplicate", { icon: ContentCopyIcon() })}
</x-row>
</x-col>
`;

renderApp(App, document.getElementById('app'));

Loading state

import { html, renderApp } from 'mates';
import { atom } from 'mates';
import { FilledButton, OutlinedButton, TonalButton, DangerButton } from 'mates-ui';
import { RefreshIcon } from 'mates-icons';

const App = () => {
const loading = atom(false);
return () => html`
<x-col gap="var(--md-space-4)">
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Saving…", { loading: true })}
${OutlinedButton("Uploading…", { loading: true })}
${TonalButton("Processing", { loading: true })}
${DangerButton("Deleting…", { loading: true })}
</x-row>
<x-row gap="var(--md-space-3)" align="center">
${FilledButton("Click to load", {
icon: RefreshIcon(),
loading: loading(),
on: { click: () => { loading.set(true); setTimeout(() => loading.set(false), 2000); } },
})}
</x-row>
</x-col>
`;
};

renderApp(App, document.getElementById('app'));

Disabled

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton, TextButton, DangerButton } from 'mates-ui';

const App = () => () => html`
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Filled", { disabled: true })}
${OutlinedButton("Outlined", { disabled: true })}
${TextButton("Ghost", { disabled: true })}
${DangerButton("Danger", { disabled: true })}
</x-row>
`;

renderApp(App, document.getElementById('app'));

Full width

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton } from 'mates-ui';

const App = () => () => html`
<x-col gap="var(--md-space-3)" style="max-width:24rem;width:100%;">
${FilledButton("Full width filled", { fullWidth: true })}
${OutlinedButton("Full width outlined", { fullWidth: true })}
</x-col>
`;

renderApp(App, document.getElementById('app'));

Shapes

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton, TextButton, DangerButton, TonalButton } from 'mates-ui';

const App = () => () => html`
<x-col gap="var(--md-space-4)">
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Rounded", { shape: "rounded" })}
${OutlinedButton("Rounded", { shape: "rounded" })}
${TextButton("Rounded", { shape: "rounded" })}
</x-row>
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Square", { shape: "square" })}
${OutlinedButton("Square", { shape: "square" })}
${TextButton("Square", { shape: "square" })}
</x-row>
<x-row gap="var(--md-space-2)" wrap>
${FilledButton("Pill")}
${OutlinedButton("Pill")}
${TextButton("Pill")}
</x-row>
</x-col>
`;

renderApp(App, document.getElementById('app'));

on & onClick

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton, TextButton, Alert } from 'mates-ui';

const App = () => () => html`
<x-row gap="var(--md-space-3)" wrap>
${FilledButton("onClick", {
onClick: () => Alert("clicked via onClick!"),
})}
${OutlinedButton("on.click", {
on: { click: () => Alert("clicked via on.click!") },
})}
${TextButton("mouseenter", {
on: { mouseenter: () => Alert("mouse entered!") },
})}
</x-row>
`;

renderApp(App, document.getElementById('app'));

style & classes

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton } from 'mates-ui';

const App = () => () => html`
<style>
.my-btn-wide [data-name="label"] { letter-spacing: 0.08em; font-weight: 800; }
</style>
<x-row gap="var(--md-space-3)" wrap>
${FilledButton("Custom radius", {
style: { borderRadius: "var(--md-radius-sm)" },
})}
${FilledButton("Custom color", {
style: { background: "#7c3aed", borderColor: "#7c3aed" },
})}
${OutlinedButton("Wide padding", {
style: { padding: "0 var(--md-space-8)" },
})}
${FilledButton("Bold label", {
classes: "my-btn-wide",
})}
</x-row>
`;

renderApp(App, document.getElementById('app'));

attr

import { html, renderApp } from 'mates';
import { FilledButton } from 'mates-ui';

const App = () => () => html`
<x-row gap="var(--md-space-3)" wrap>
${FilledButton("With aria-label", {
attr: { "aria-label": "Save document", "data-action": "save" },
})}
${FilledButton("Submit", {
attr: { type: "submit", "data-testid": "submit-btn" },
})}
</x-row>
`;

renderApp(App, document.getElementById('app'));

id

import { html, renderApp } from 'mates';
import { FilledButton } from 'mates-ui';

const App = () => () => html`
${FilledButton("Save", { id: "demo-save" })}
`;

renderApp(App, document.getElementById('app'));

classes

import { html, renderApp } from 'mates';
import { FilledButton } from 'mates-ui';

const App = () => () => html`
${FilledButton("Full width", { classes: ["full-width"] })}
`;

renderApp(App, document.getElementById('app'));

data

import { html, renderApp } from 'mates';
import { FilledButton } from 'mates-ui';

const App = () => () => html`
${FilledButton("Save", {
data: { testId: "save-btn", action: "save" },
})}
`;

renderApp(App, document.getElementById('app'));

class

import { html, renderApp } from 'mates';
import { FilledButton, OutlinedButton } from 'mates-ui';

const App = () => () => html`
<x-row gap="var(--md-space-3)" wrap>
${FilledButton("Single class", { classes: "my-btn" })}
${OutlinedButton("Multi", { classes: "my-btn my-btn--lg" })}
${FilledButton("Another", { classes: "cls-a cls-b" })}
</x-row>
`;

renderApp(App, document.getElementById('app'));

Popup support

import { html, renderApp } from 'mates';
import { atom } from 'mates';
import { OutlinedButton, PopupList, Text } from 'mates-ui';

const App = () => {
const choice = atom(null);
return () => html`
<x-row gap="var(--md-space-3)" align="center">
${OutlinedButton("File ▾", {
panel: PopupList({
items: [
{ id: "new", label: "New" },
{ id: "open", label: "Open…" },
{ type: "divider", label: "" },
{ id: "save", label: "Save" },
],
onSelect: (_ids, item) => choice.set(item?.id ?? null),
}),
popup: { position: "bottom-start" },
})}
${choice() ? Text(`Selected: ${choice()}`, { type: "body-sm", color: "muted" }) : html``}
</x-row>
`;
};

renderApp(App, document.getElementById('app'));

Button group

import { html, renderApp } from 'mates';
import { atom } from 'mates';
import { ButtonGroup } from 'mates-ui';
import { ViewListIcon, GridViewIcon, TableRowsIcon, FormatBoldIcon, FormatItalicIcon, FormatUnderlinedIcon, FormatAlignLeftIcon, FormatAlignCenterIcon, FormatAlignRightIcon } from 'mates-icons';

const App = () => {
const view = atom("grid");
const range = atom("week");
const format = atom([]);
const align = atom("c");
return () => html`
<x-col gap="var(--md-space-5)">
${ButtonGroup(
[
{ label: "List", value: "list", icon: ViewListIcon() },
{ label: "Grid", value: "grid", icon: GridViewIcon() },
{ label: "Table", value: "table", icon: TableRowsIcon(), disabled: true },
],
{ value: view(), onChange: (v) => view.set(v), ariaLabel: "View mode" },
)}
${ButtonGroup(
[
{ label: "Day", value: "day" }, { label: "Week", value: "week" },
{ label: "Month", value: "month" }, { label: "Year", value: "year" },
],
{ variant: "filled", value: range(), onChange: (v) => range.set(v), ariaLabel: "Range" },
)}
${ButtonGroup(
[
{ label: "Bold", value: "bold", icon: FormatBoldIcon() },
{ label: "Italic", value: "italic", icon: FormatItalicIcon() },
{ label: "Underline", value: "under", icon: FormatUnderlinedIcon() },
],
{ mode: "multi", values: format(), onChange: (v) => format.set(v), ariaLabel: "Formatting" },
)}
${ButtonGroup(

Button

import { Button, ButtonGroup } from "mates-ui";
Prop Type Default Description
attr AttrMap HTML attributes on the root element.
classes string | string[] Extra root classes — joined and appended after built-in classes.
classes string Extra root class name(s) as a space-separated string.
data DataAttrMap Root `data-*` map (`testId` → `data-testid`). `testId` prop / `attr` win over `data`.
disabled boolean false Disables interaction and applies disabled visual state.
enableBloom boolean true Enables the soft bloom press effect (default press animation). Ignored when enableRipple is true.
enableRipple boolean true When true, replaces the default soft bloom press effect with a Material Design 3-style ripple — a sharp-edged circle that grows from the contact point, with hover/focus state-layer and keyboard (Enter / Space) support.
fullWidth boolean false Stretches button to 100% container width.
href string When set, renders an <a> instead of a <button>.
icon IconInput Leading icon — an icon name string or SVG TemplateResult (e.g. "save" or SaveIcon()).
id string Root `id` shorthand (`attr.id` wins). Form native-control `id` props are unchanged.
isActive boolean false Toggles the active/selected visual state (e.g. for nav buttons).
label* string | TemplateResult Button label content.
loading boolean false Replaces icon with a spinner; disables interaction.
on OnEventMap DOM event handlers on the root element.
onClick (e: MouseEvent) => void Shorthand for `on.click` — called when the button is clicked.
panel TemplateResult Popup panel content. When provided with `popup`, the button becomes a popup trigger.
popup PopupPlacement | PopupOptions Popup options passed to mates popup hook (e.g. { position: "bottom-start" }).
shape "sm" | "md" | "lg" | "pill" "md" Border-radius preset.
size "sm" | "md" | "lg" "md" Button height and horizontal padding.
style StyleMap Inline CSS on the root element.
testId string Overrides the default `data-testid` on the root. Wins over `data.testId`.
trailingIcon IconInput Trailing icon — an icon name string or SVG TemplateResult (e.g. "arrow_forward" or ArrowForwardIcon()).
type "button" | "submit" | "reset" "button" Native <button> type attribute.
variant "filled" | "outlined" | "hybrid" | "ghost" | "elevated" | "tonal" | "danger" | "nav" "filled" Visual style variant.

ButtonGroup

Prop Type Default Description
ariaLabel string Accessible group label.
items* ButtonGroupItem[] Array of button descriptors ({ label?, value, icon?, disabled? }).
mode "single" | "multi" "single" Single-select or multi-select mode.
onChange (v: string | string[]) => void Called on selection change.
orientation "horizontal" | "vertical" "horizontal" Layout direction.
size "sm" | "md" | "lg" "md" Shared size for all items.
value string Selected value (single mode).
values string Selected values (multi mode).
variant "outlined" | "filled" | "ghost" "outlined" Shared visual variant for all items.

Variant shorthands

Prop Type Default Description
DangerButton(label, opts?) TemplateResult Button with variant='danger'.
ElevatedButton(label, opts?) TemplateResult Button with variant='elevated'.
FilledButton(label, opts?) TemplateResult Button with variant='filled'. Accepts all ButtonOptions except variant.
HybridButton(label, opts?) TemplateResult Button with variant='hybrid'.
NavButton(label, opts?) TemplateResult Button with variant='nav' (nav-link style, no background until active).
OutlinedButton(label, opts?) TemplateResult Button with variant='outlined'.
TextButton(label, opts?) TemplateResult Button with variant='ghost' (text-only, no background or border).
TonalButton(label, opts?) TemplateResult Button with variant='tonal'.

CSS tokens

Token Role
--md-color-on-primary Filled label / icon
--md-color-outline Outlined border
--md-color-primary Filled background
--md-elevation-1 Filled resting shadow
--md-press-bloom-blur Bloom circle blur radius (default bloom)
--md-press-bloom-opacity Bloom circle peak opacity (default bloom)
--md-press-bloom-size Bloom circle size multiplier (default bloom)
--md-press-scale Element scale-down on press (default bloom)
--md-ripple-color Ripple circle color when enableRipple is true (default currentColor)
--md-ripple-hover-opacity Hover state-layer opacity when enableRipple is true (default 0.08)
--md-ripple-press-opacity Ripple circle opacity when enableRipple is true (default 0.12)
--md-shape-full Pill border-radius
--md-state-hover-opacity Hover state layer
--md-state-pressed-opacity Press state layer

Caveats

  • icon-only usage: always provide aria-label via attr: { 'aria-label': '…' } — the label is visually hidden but required for accessibility.
  • loading: true disables the button and sets aria-busy. Do not rely on loading state alone to prevent double-submits — guard at the data layer too.
  • ButtonGroup value/values must be controlled externally; the group is stateless.