Button
A clickable action: five variants across six semantic tones.
Live demo
Button triggers an action or navigates. Visual treatment (variant) and semantic color (tone) are independent axes: any of the five variants (solid, outline, ghost, subtle, link) can carry any of the six tones (accent, neutral, danger, success, warn, info).
It renders a native <button> by default and an <a> when given an href, so the same component covers both actions and links. Icon slots, an icon-only square form, a loading state with an inline accessible spinner, and a full-width block mode round out the surface.
When to use
How this component composes with the rest of the set.
Props
15 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
submit and reset act on the button's form whether it renders in light DOM or behind a shadow root, where a native button would have no form at all. Ignored when href is set. | ||||
submit or reset button acts on, for a button that sits outside it: a dialog footer, a sticky action bar. Same as the native form attribute. | ||||
<a>. Dropped (with aria-disabled) when also disabled. | ||||
aria-disabled and removes href. | ||||
aria-busy, and blocks interaction. | ||||
block or an explicit width): start left-aligns the label and icons for a list-row button, end right-aligns them. | ||||
aria-pressed. Set it (pressed / pressed="true") for on, pressed="false" for off, and omit it entirely for a plain button. Controlled: flip it in your own click handler; the button reflects state, it does not self-toggle. | ||||
false to keep the control out of sequential focus navigation. A component rendering a native control makes it tab-reachable whether or not the app wants it: an app driving selection from its own keyboard cursor otherwise gets two focus rings that diverge, and Enter activates through both, firing one keypress twice. The host's own tabindex cannot express this, because focus lands on the inner control rather than the host. | ||||
:focus-visible ring (forced-colors outline included) without moving DOM focus. For a menu driven from the app's own input layer, usually alongside focusable={false}: focus never lands, so :focus-visible never paints, and selected is the wrong state (it means current within a set). Controlled: move it in your own input handler. Changes no ARIA. | ||||
aria-current; distinct from pressed's on/off toggle. aria-selected is only valid on tab / option / row roles, so a button carries aria-current instead. Set it (selected / selected="true") for selected, selected="false" for not, and omit it entirely for a button with no selection semantics. Controlled: flip it in your own click handler; the button reflects state, it does not self-toggle. |
Appearance
Variants
solid
Filled with the tone color; the primary, highest-emphasis treatment.
outline
Transparent fill with a tone-colored border and text.
ghost
No chrome until hover, then a neutral state tint; tone-colored text.
subtle
A soft tone-tinted background with tone-colored text.
link
Looks like a link: no box, no padding, underline on hover.
Sizes
xs
Densest, for compact toolbar pills.
sm
Compact.
md
Default.
lg
Large.
States
hover
Pointer over the button: overlay paints the hover tint.
active
Button pressed: overlay paints the press tint.
pressed
Toggle engaged: aria-pressed="true" paints the press overlay persistently, so an on toggle reads as sunken across every variant.
selected
Current within a set: aria-current="true" paints the selected overlay persistently, so a chosen pill reads as active across every variant.
focus-visible
Keyboard focus: a token-colored ring, plus a transparent outline that becomes real in forced-colors mode.
cursor
App-owned keyboard cursor: cursor puts data-cursor on the control, which shares the focus-visible rule, so it draws the same ring and the same forced-colors outline while DOM focus stays where it is.
disabled
Non-interactive: muted fill and ink, overlay suppressed.
loading
Busy: content hidden behind the centered spinner, pointer set to progress.
Anatomy
The named parts that make up the component. Created from script it renders a shadow root, where a ::part() handle is reachable from your own stylesheet and the class beside it is internal. Upgraded over server-rendered markup it renders light DOM, where the class is the handle.
root
The button or anchor element carrying the variant, tone, and size classes.
overlay
The pseudo-element behind the content that paints hover and active state tints.
icon
An icon slot rendered before or after the label (icon-start / icon-end).
label
The text content wrapper, hidden while loading so the spinner takes its place.
spinner
The inline accessible spinner shown while loading, drawn in currentColor.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent--accent-2--accent-2-bg--accent-2-fg--accent-2-text--accent-3--accent-3-bg--accent-3-fg--accent-3-text--accent-4--accent-4-bg--accent-4-fg--accent-4-text--accent-bg--accent-fg--accent-text--black--black-bg--black-fg--black-text--blue--blue-bg--blue-fg--blue-text--border-normal--border-thick--border-thin--brown--brown-bg--brown-fg--brown-text--cyan--cyan-bg--cyan-fg--cyan-text--danger--danger-bg--danger-fg--danger-text--duration-fast--duration-slow--ease-standard--fg-disabled--font-sans--gray--gray-bg--gray-fg--gray-text--green--green-bg--green-fg--green-text--info--info-bg--info-fg--info-text--leading-tight--neutral--neutral-bg--neutral-fg--neutral-text--orange--orange-bg--orange-fg--orange-text--pink--pink-bg--pink-fg--pink-text--purple--purple-bg--purple-fg--purple-text--radius-full--radius-md--radius-sm--red--red-bg--red-fg--red-text--ring--space-0--space-1--space-2--space-3--space-4--space-5--state-disabled--state-hover--state-press--state-selected--success--success-bg--success-fg--success-text--text-body--text-lg--text-sm--text-xs--warn--warn-bg--warn-fg--warn-text--weight-medium--white--white-bg--white-fg--white-text--yellow--yellow-bg--yellow-fg--yellow-textSlots
The button label.
An icon rendered before the label.
An icon rendered after the label.
Accessibility
Code
Variants and tones
The five variants and the six tones are independent. Mix freely.
<xtyle-button variant="solid" tone="accent">Save changes</xtyle-button>
<xtyle-button variant="outline" tone="neutral">Cancel</xtyle-button>
<xtyle-button variant="subtle" tone="danger" icon-only aria-label="Delete">
<svg slot="icon-start" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 12H7L6 9Z" />
</svg>
</xtyle-button>
<xtyle-button variant="solid" tone="success" loading>Saving…</xtyle-button>
<xtyle-button variant="link" tone="info" href="/docs">Read the docs</xtyle-button><script lang="ts">
import { Button } from "@xtyle/svelte";
</script>
<Button variant="solid" tone="accent" onclick={() => save()}>Save changes</Button>
<Button variant="outline" tone="neutral">Cancel</Button>
<Button variant="subtle" tone="danger" iconOnly ariaLabel="Delete">
{#snippet iconStart()}
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 12H7L6 9Z" />
</svg>
{/snippet}
</Button>
<Button variant="solid" tone="success" loading>Saving…</Button>
<Button variant="link" tone="info" href="/docs">Read the docs</Button>---
import { Button } from "@xtyle/astro";
---
<Button variant="solid" tone="accent">Save changes</Button>
<Button variant="outline" tone="neutral">Cancel</Button>
<Button variant="subtle" tone="danger" iconOnly aria-label="Delete">
<svg slot="icon-start" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 12H7L6 9Z" />
</svg>
</Button>
<Button variant="link" tone="info" href="/docs">Read the docs</Button>List-row button
A full-width action row that runs a callback: block gives it the width, align="start" left-aligns the label, and a trailing icon reads as a chevron.
<xtyle-button block align="start" variant="ghost" tone="neutral">
<svg slot="icon-start" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M4 4h16v4H4V4Zm0 6h16v4H4v-4Zm0 6h10v4H4v-4Z" />
</svg>
Recent projects
<svg slot="icon-end" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="m9 6 6 6-6 6-1.4-1.4L11.2 12 7.6 8.4 9 6Z" />
</svg>
</xtyle-button><Button block align="start" variant="ghost" tone="neutral" onclick={() => openRecent()}>
{#snippet iconStart()}
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M4 4h16v4H4V4Zm0 6h16v4H4v-4Zm0 6h10v4H4v-4Z" />
</svg>
{/snippet}
Recent projects
{#snippet iconEnd()}
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="m9 6 6 6-6 6-1.4-1.4L11.2 12 7.6 8.4 9 6Z" />
</svg>
{/snippet}
</Button><Button block align="start" variant="ghost" tone="neutral">
<svg slot="icon-start" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M4 4h16v4H4V4Zm0 6h16v4H4v-4Zm0 6h10v4H4v-4Z" />
</svg>
Recent projects
<svg slot="icon-end" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path fill="currentColor" d="m9 6 6 6-6 6-1.4-1.4L11.2 12 7.6 8.4 9 6Z" />
</svg>
</Button>A button outside its form
The footer actions sit outside the form they act on, as in a dialog whose body scrolls. form names the form by id, and submit runs its validation before it submits.
<form id="profile">
<xtyle-field label="Display name" name="display-name" required></xtyle-field>
</form>
<xtyle-cluster gap="2" justify="end">
<xtyle-button type="reset" form="profile" variant="ghost" tone="neutral">Reset</xtyle-button>
<xtyle-button type="submit" form="profile">Save</xtyle-button>
</xtyle-cluster><script lang="ts">
import { Button, Cluster, Field } from "@xtyle/svelte";
</script>
<form id="profile" onsubmit={save}>
<Field label="Display name" name="display-name" required />
</form>
<Cluster gap={2} justify="end">
<Button type="reset" form="profile" variant="ghost" tone="neutral">Reset</Button>
<Button type="submit" form="profile">Save</Button>
</Cluster>---
import { Button, Cluster, Field } from "@xtyle/astro";
---
<form id="profile">
<Field label="Display name" name="display-name" required />
</form>
<Cluster gap={2} justify="end">
<Button type="reset" form="profile" variant="ghost" tone="neutral">Reset</Button>
<Button type="submit" form="profile">Save</Button>
</Cluster>An app-owned keyboard cursor
A menu driven from the app's own input layer rather than DOM focus: focusable={false} keeps the browser's tab stop out of it, and cursor on the current row draws the focus ring there. input stands in for the app's own action bindings.
<xtyle-stack gap="2" id="pause-menu">
<xtyle-button block variant="ghost" tone="neutral" focusable="false" cursor>Resume</xtyle-button>
<xtyle-button block variant="ghost" tone="neutral" focusable="false">Options</xtyle-button>
<xtyle-button block variant="ghost" tone="neutral" focusable="false">Quit</xtyle-button>
</xtyle-stack>
<script>
const rows = [...document.querySelectorAll("#pause-menu xtyle-button")];
let at = 0;
function moveCursor(step) {
rows[at].cursor = false;
at = (at + step + rows.length) % rows.length;
rows[at].cursor = true;
}
input.on("menu-up", () => moveCursor(-1));
input.on("menu-down", () => moveCursor(1));
input.on("confirm", () => rows[at].click());
</script><script lang="ts">
import { Button, Stack } from "@xtyle/svelte";
const rows = ["Resume", "Options", "Quit"];
let at = $state(0);
input.on("menu-up", () => (at = (at - 1 + rows.length) % rows.length));
input.on("menu-down", () => (at = (at + 1) % rows.length));
</script>
<Stack gap={2}>
{#each rows as row, i}
<Button block variant="ghost" tone="neutral" focusable={false} cursor={i === at}>{row}</Button>
{/each}
</Stack>---
import { Button, Stack } from "@xtyle/astro";
---
<Stack gap={2}>
<Button block variant="ghost" tone="neutral" focusable={false} cursor>Resume</Button>
<Button block variant="ghost" tone="neutral" focusable={false}>Options</Button>
<Button block variant="ghost" tone="neutral" focusable={false}>Quit</Button>
</Stack>