sveltelean Primitives
Versionv0.2.0 GitHub

Example

Variants
Documentation
<button type="button" data-slean="button">Save</button>
<button type="button" data-slean="button" data-variant="outline">Cancel</button>
<button type="button" data-slean="button" data-variant="soft">Duplicate</button>
<button type="button" data-slean="button" data-variant="ghost">Rename</button>
<button type="button" data-slean="button" data-variant="danger">Delete</button>
<a href="/docs" data-slean="button" data-variant="link">Documentation</a>
Tier 0: the two sources differ only by the stylesheet import. Every button here works with page JavaScript disabled; the link variant is an <a href>, because navigation is a link.

Sizes and states

Sizes and states
<button type="button" data-slean="button" data-size="sm">Small</button>
<button type="button" data-slean="button" data-size="lg">Large</button>
<button type="button" data-slean="button" disabled>Disabled</button>
<button type="button" data-slean="button" aria-disabled="true">Unavailable</button>
<button type="button" data-slean="button" aria-busy="true">Saving</button>
<button
	type="button"
	data-slean="button"
	data-variant="outline"
	data-icon-only
	aria-label="Close"
>
	<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
disabled removes the button from the tab order; aria-disabled keeps it reachable and announced as disabled, and the application ignores the activation itself. aria-busy adds the spinner and keeps focus where it is.

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.

npm install @svelte-lean/styles
app.css or +layout.svelte
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

PartElementdata-slean-partRequiredNotes
root<button type="button"> or <a href>–yesdata-slean="button" marks it for the styles and diagnostics. Navigation is an <a href> styled as a button.
label and iconany inline content–noFree 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> 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.

Keyboard

KeyWhenResult
Enter/Spacefocus on the buttonActivates (native)
Tab/Shift+TabanywhereMoves focus (native)

Platform features

FeatureBaselineOutside the target
<button>, :focus-visibleWidely availableNot applicable
Invoker commands (command, commandfor)See the Dialog and Popover contractsOnly 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:

TokenDefault (light)Applies to
--slean-space-20.5remgap
--slean-control-height-md2.25remmin-block-size, inline-size
--slean-space-10.25rempadding-block
--slean-space-41rempadding-inline
--slean-radius-md0.625remborder-radius
--slean-accentoklch(54% 0.19 258)background, color
--slean-accent-fgoklch(100% 0 0)color
--slean-text-sm0.875remfont-size
--slean-font-weight-medium500font-weight
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-bordervar(--slean-neutral-6)border-color
--slean-fgvar(--slean-neutral-12)color, background
--slean-mutedvar(--slean-neutral-3)background
--slean-accent-softoklch(95% 0.03 258)background
--slean-accent-soft-fgoklch(42% 0.17 258)color
--slean-dangeroklch(55% 0.2 25)background
--slean-danger-fgoklch(100% 0 0)color
--slean-radius-sm0.375remborder-radius
--slean-control-height-sm2remmin-block-size, inline-size
--slean-space-30.75rempadding-inline
--slean-control-height-lg2.75remmin-block-size, inline-size
--slean-space-51.25rempadding-inline
--slean-text-md1remfont-size
--slean-accent-hoveroklch(49% 0.19 258)background
--slean-muted-hovervar(--slean-neutral-4)background
--slean-danger-hoveroklch(50% 0.2 25)background
--slean-accent-activeoklch(45% 0.18 258)background
--slean-control-border-disabledvar(--slean-border)border-color
--slean-control-bg-disabledvar(--slean-muted)background
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-duration-indicator750msanimation

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.

save-button.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>

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

Source