# Button

> Native <button> with command and commandfor, styling hooks, and when a button is a link.

Page: https://svelte-lean.vercel.app/primitives/button · Markdown: https://svelte-lean.vercel.app/primitives/button.md · Index: https://svelte-lean.vercel.app/llms.txt

A native button with variants, sizes and loading and disabled states. Use it for any action on the current page; navigation is a link.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
</script>

<button type="button" data-slean="button">Save changes</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral">Cancel</button>
```

Native buttons with the optional stylesheet. Works with JavaScript disabled.

## Examples

### Looks

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';

	const variants = ['contained', 'outlined', 'text'];
	const colors = ['primary', 'secondary', 'error', 'warning', 'info', 'success', 'neutral'];
</script>

{#each variants as variant}
	<div class="row">
		{#each colors as color}
			<button type="button" data-slean="button" data-variant={variant} data-color={color}>
				{color}
			</button>
		{/each}
	</div>
{/each}
```

The variant is the fill only (contained is the default); the color is its own attribute, on the button or an ancestor.

### Sizes

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
</script>

<button type="button" data-slean="button" data-size="small">Small</button>
<button type="button" data-slean="button">Medium</button>
<button type="button" data-slean="button" data-size="large">Large</button>

<button type="button" data-slean="button" data-variant="outlined" data-size="small">Small</button>
<button type="button" data-slean="button" data-variant="text" data-size="large">Large</button>
```

### With an icon

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
</script>

<button type="button" data-slean="button">
	<span data-slean-part="start-icon" aria-hidden="true">
		<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
			<path d="M12 5v14M5 12h14" />
		</svg>
	</span>
	New project
</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral">
	Download
	<span data-slean-part="end-icon" aria-hidden="true">
		<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
			<path d="M12 4v11M7 10l5 5 5-5M5 20h14" />
		</svg>
	</span>
</button>
<button type="button" data-slean="button" data-variant="text" data-color="error">
	<span data-slean-part="start-icon" aria-hidden="true">
		<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
			<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" />
		</svg>
	</span>
	Delete
</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-icon-only aria-label="Close">
	<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
		<path d="M18 6 6 18M6 6l12 12" />
	</svg>
</button>
```

The start-icon and end-icon parts are sized and spaced by the stylesheet; an icon-only button needs aria-label.

### Full width

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
</script>

<button type="button" data-slean="button" data-full-width>Sign in</button>
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" data-full-width>
	Create an account
</button>
```

### Loading

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/input.css';

	let busy = $state(false);
	let saved = $state('');

	async function onsubmit(event: SubmitEvent) {
		event.preventDefault();
		if (busy) return;
		busy = true;
		saved = '';
		await new Promise((resolve) => setTimeout(resolve, 1200)); // the request
		busy = false;
		saved = 'Saved.';
	}
</script>

<form class="row" {onsubmit}>
	<input data-slean="input" name="project" value="Quarterly report" aria-label="Project name" />
	<!-- aria-busy keeps the button focusable while the request runs; disabled would drop focus. -->
	<button data-slean="button" aria-busy={busy || undefined}>Save</button>
	<span role="status">{saved}</span>
</form>

<style>
	.row {
		display: flex;
		align-items: center;
		gap: 0.5rem;
	}
</style>
```

The ring turns while the request runs; focus stays on the button.

### Icon buttons with hints

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/hint.css';
</script>

<!-- data-slean-hint shows the text on hover and keyboard focus; aria-label stays the name. -->
<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-icon-only aria-label="Duplicate" data-slean-hint="Duplicate">
	<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round">
		<rect x="8" y="8" width="12" height="12" rx="2" />
		<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
	</svg>
</button>
<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-icon-only aria-label="Share" data-slean-hint="Share">
	<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
		<path d="M12 15V3M7 8l5-5 5 5M5 13v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
	</svg>
</button>
<button type="button" data-slean="button" data-variant="text" data-color="neutral" data-icon-only aria-label="Delete" data-slean-hint="Delete">
	<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
		<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" />
	</svg>
</button>
```

Hover or focus a button to see its hint.

### Disabled and unavailable

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
</script>

<!-- disabled: out of the tab order, no events. -->
<button type="button" data-slean="button" disabled>Disabled</button>
<!-- aria-disabled: still focusable and announced as disabled; the application ignores the click. -->
<button type="button" data-slean="button" data-variant="outlined" data-color="neutral" aria-disabled="true">Unavailable</button>
```

Tab through: only the aria-disabled button takes focus.

### As a link

```svelte
<script lang="ts">
	import '@svelte-lean/styles/button.css';
</script>

<!-- Navigation is a link: it keeps the address, middle click and "open in new tab". -->
<a href="/docs/installation" data-slean="button">Get started</a>
<a href="/primitives" data-slean="button" data-variant="outlined" data-color="neutral">Browse primitives</a>
```

Navigation stays a link; the stylesheet gives it the button's look.

## Usage

Plain markup with a stylesheet: install `@svelte-lean/styles`. No JavaScript runs.

```bash
npm install @svelte-lean/styles
```

`stylesheets`

```ts
import '@svelte-lean/styles/button.css';
```

Use a button for an action on the current page: submit, open, toggle, delete. Use an `<a href>` for navigation and give it `data-slean="button"` when it should look like one.

Inside a `<form>` the default type is `submit`; write `type="button"` for anything that does not submit. While an action runs, set `aria-busy="true"` rather than `disabled`, so focus stays put.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<button type="button"> or <a href>` | – | yes | data-slean="button" marks it for the styles and diagnostics. Navigation is an \<a href\> styled as a button. |
| label and icon | `any inline content` | – | no | Free markup. Icon-only buttons carry aria-label and data-icon-only. |
| start icon | `<svg> or <span> around one` | `start-icon` | no | An icon before or after the label (MUI startIcon and endIcon); sized and spaced by the stylesheet. |
| end icon | `<svg> or <span> around one` | `end-icon` | no | An icon after the label. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Enter`/`Space` | focus on the button | Activates (native) |
| `Tab`/`Shift+Tab` | anywhere | Moves focus (native) |

## Styling

`button.css` styles `[data-slean="button"]` with variants and sizes chosen through `data-variant` and `data-size`, inside the `slean.components` layer and wrapped in `:where()`, so any unlayered rule of yours wins. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | gap |
| `--slean-control-height-md` | `2.25rem` | min-block-size, inline-size |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | padding-block, gap, margin-inline-start, margin-inline-end |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline |
| `--slean-button-radius` | `var(--slean-radius-md)` | border-radius |
| `--slean-color-main` | `var(--slean-primary-main)` | background, border-color |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | color |
| `--slean-button-font` | `var(--slean-typography-button-weight) var(--slean-typography-button-size) / var(--slean-typography-button-line-height) sans-serif` | font |
| `--slean-button-transform` | `var(--slean-typography-button-transform)` | text-transform |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-color-border` | `color-mix(in oklch, var(--slean-primary-main) 50%, transparent)` | border-color |
| `--slean-color-text` | `var(--slean-primary-main)` | color |
| `--slean-control-height-sm` | `2rem` | min-block-size, inline-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |
| `--slean-control-height-lg` | `2.75rem` | min-block-size, inline-size |
| `--slean-space-5` | `calc(var(--slean-spacing) * 5)` | padding-inline |
| `--slean-text-md` | `1rem` | font-size |
| `--slean-color-dark` | `var(--slean-primary-dark)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | background |
| `--slean-control-border-disabled` | `var(--slean-border)` | border-color |
| `--slean-control-bg-disabled` | `var(--slean-muted)` | background |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-duration-indicator` | `750ms` | animation |

State selectors the stylesheet targets, all from the platform or ARIA: `:active`, `:disabled`, `:hover`, `[aria-busy="true"]`, `[aria-disabled="true"]`.

Variant attributes: `data-variant` (`outlined`, `text`); `data-size` (`small`, `large`); `data-full-width`; `data-icon-only`.

## Controlled integration

There is nothing to control: a button has no state beyond what the platform holds. The application attaches its own click handler and, while an action runs, sets `aria-busy` rather than `disabled`. No adapter, store or event of the package is involved.

`save-button.svelte`

```svelte
<script lang="ts">
	let busy = $state(false);

	async function save() {
		busy = true;
		try {
			await fetch('/api/save', { method: 'POST' });
		} finally {
			busy = false;
		}
	}
</script>

<!-- aria-busy keeps the button focusable while the request runs; disabled would drop focus. -->
<button type="button" data-slean="button" aria-busy={busy || undefined} onclick={save}>
	Save
</button>
```

## Under the hood

Runtime and size

- **Tier**: 0 · Native
- **Behavior JS**: 0 B brotli · 0 B gzip · `fixtures/results.json (native-only)` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<button>`, `:focus-visible`, `:disabled`, `aria-busy`
- **Shared listeners**: none
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `<button type="button">`

Tier 0: no behavior module and nothing attached at hydration: no listener, no state object, no observer. The runtime block reads the bytes from the native-only consumer fixture, a production Vite build of a page with button, dialog and popover markup whose module graph contains no `@svelte-lean/core` or `@svelte-lean/primitives` module.

Why this implementation

The platform’s button is complete: it is focusable, Enter and Space activate it, it takes part in forms, and it exposes `:disabled`, `:active` and `:focus-visible`. A `<div role="button">` would have to re-create every one of those in JavaScript and would still miss form participation.

Svelte Lean therefore adds nothing to the behavior. It adds a stylesheet with variants and states, a contract that says when to use `disabled` and when `aria-disabled`, and the rule that navigation is a link. `data-slean="button"` marks the element for the stylesheet and for development diagnostics.

### The browser owns

- focusability and the tab order
- Enter and Space activation
- form submission and reset (type="submit", type="reset")
- :disabled, :active, :focus-visible and :hover states
- the accessible role and name

### Svelte Lean owns

- button.css: variants, sizes, icon-only, busy and disabled presentation
- the contract and its types
- documentation

Accessibility

- A `<button>` element, never `<div role="button">` and never an `<a>` without `href`.
- Icon-only buttons carry `aria-label`. Buttons that toggle something not driven by `popovertarget` or `commandfor` carry `aria-expanded` and `aria-controls`; those two attributes establish the relationship natively.
- `disabled`: not focusable, receives no events, excluded from form submission. `aria-disabled="true"`: focusable and announced as disabled; the application must ignore the activation. Use it only when the control must stay reachable by keyboard.
- A busy button (`aria-busy="true"`) stays focusable so focus is not lost while an action runs.
- Focus rules, text direction and the focus ring are native; the styles use logical properties.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<button>, :focus-visible` | Widely available | Not applicable |
| `Invoker commands (command, commandfor)` | See the Dialog and Popover contracts | Only relevant when the button opens a dialog or popover |

Without JavaScript

Fully functional for form submission and reset. Only application click handlers are missing, which is true of any button on any page. The playground’s native page is exercised with page JavaScript disabled.

Server rendering and hydration

Plain HTML. The server renders the button, its type, its disabled state and its label; nothing is generated on the client and no id is required.

The button is interactive as soon as the document is parsed. The delayed-hydration test holds every script for several seconds and asserts that the native page’s dialog invoker, a button, opens the dialog before any script has loaded.

Compatibility

`<button>` and `:focus-visible` are Baseline widely available; the button itself has no fallback case. Invoker commands (`command`, `commandfor`) belong to the dialog and popover a button opens and are documented on those pages. Where the stylesheet uses `color-mix()` for hover states, browsers without it keep the base color. The WebKit-only `switch` attribute and the `<div role="button">` pattern are not part of any contract; the package ships no keyboard shim for them.

Tests and source

- `apps/playground/tests/primitives/native.spec.ts` Playwright, with page JavaScript enabled and disabled
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)
- `fixtures/native-only` consumer build asserting that no behavior runtime ships (invariant A)
- `packages/primitives/scripts/size.mjs` the native-only build that must contain no runtime

- `packages/primitives/src/button/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/button/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/styles/css/button.css` the optional stylesheet
