Primitives Actions
Button
A native <button> marked with data-slean="button" for the optional stylesheet and for development diagnostics. The element provides focus, Enter and Space activation, form participation and the disabled state; the package ships no runtime for it. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<button>,:focus-visible,:disabled,aria-busy- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<button type="button">
On this page
Example
Sizes and states
Why this implementation exists
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; what it adds is 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.
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
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet and @svelte-lean/primitives when you want the typed contract; neither adds runtime JavaScript
for a button.
import '@svelte-lean/styles/tokens.css';
import '@svelte-lean/styles/base.css';
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.
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. |
Runtime profile
The runtime block reads the tier and the events from the contract and the bytes from the
native-only consumer fixture: a production Vite build of a page with button, dialog and popover
markup and the Vite plugin, whose module graph contains no @svelte-lean/core or @svelte-lean/primitives module. The primitives size script asserts the same for a
bundle that imports the root entry and a Tier 0 contract. Nothing is attached at hydration:
there is no listener, no state object and no observer for a button. The listeners the proof page counts belong to the Tier 1 and Tier 2 behaviors (tabs, menu, listbox,
combobox).
Accessibility contract
- A
<button>element, never<div role="button">and never an<a>withouthref. - Icon-only buttons carry
aria-label. Buttons that toggle something not driven bypopovertargetorcommandforcarryaria-expandedandaria-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.
Keyboard
| Key | When | Result |
|---|---|---|
| Enter/Space | focus on the button | Activates (native) |
| Tab/Shift+Tab | anywhere | Moves focus (native) |
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
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.
Before hydration
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.
Styling
button.css styles [data-slean="button"] with variants and sizes chosen
through data-variant and data-size, all inside the slean.components layer and wrapped in :where(), so any unlayered rule
of yours wins. The tokens it reads and the states it targets:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-2 | 0.5rem | gap |
--slean-control-height-md | 2.25rem | min-block-size, inline-size |
--slean-space-1 | 0.25rem | padding-block |
--slean-space-4 | 1rem | padding-inline |
--slean-radius-md | 0.625rem | border-radius |
--slean-accent | oklch(54% 0.19 258) | background, color |
--slean-accent-fg | oklch(100% 0 0) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-font-weight-medium | 500 | font-weight |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-border | var(--slean-neutral-6) | border-color |
--slean-fg | var(--slean-neutral-12) | color, background |
--slean-muted | var(--slean-neutral-3) | background |
--slean-accent-soft | oklch(95% 0.03 258) | background |
--slean-accent-soft-fg | oklch(42% 0.17 258) | color |
--slean-danger | oklch(55% 0.2 25) | background |
--slean-danger-fg | oklch(100% 0 0) | color |
--slean-radius-sm | 0.375rem | border-radius |
--slean-control-height-sm | 2rem | min-block-size, inline-size |
--slean-space-3 | 0.75rem | padding-inline |
--slean-control-height-lg | 2.75rem | min-block-size, inline-size |
--slean-space-5 | 1.25rem | padding-inline |
--slean-text-md | 1rem | font-size |
--slean-accent-hover | oklch(49% 0.19 258) | background |
--slean-muted-hover | var(--slean-neutral-4) | background |
--slean-danger-hover | oklch(50% 0.2 25) | background |
--slean-accent-active | oklch(45% 0.18 258) | 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 (outline, secondary, soft, ghost, danger, link); data-size (sm, lg); 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 so focus stays put. No adapter, store or
event of the package is involved.
<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>Compatibility notes
<button> and :focus-visible are Baseline widely available; the
button itself has no fallback case. Invoker commands (command, commandfor) are a property of the dialog and popover it opens and are documented on
those pages and on Browser support. Where the stylesheet
uses color-mix() for hover states, browsers without it keep the base color.
Note The WebKit-only switch attribute and the <div role="button"> pattern are not part of any contract; the package does not ship a keyboard shim for them.
Testing
apps/playground/tests/primitives/native.spec.tsPlaywright, with page JavaScript enabled and disabledpackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)fixtures/native-onlyconsumer build asserting that no behavior runtime ships (invariant A)packages/primitives/scripts/size.mjsthe native-only build that must contain no runtime
Source
packages/primitives/src/button/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/button/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/button.cssthe optional stylesheet