Form · Button

Button

Single-press action surface. One doctrinal event (commit-action) with intent bound to the consumer's prop — drives the perceptual signature (sound + haptic) per the canon. 6 visual variants, 8 colors (3 hierarchy + 5 evaluative intents auto-cascading), 5 sizes, optional loading state with a built-in spinner part, iconOnly square aspect, block stretch. Native button keyboard (Enter / Space). Field-aware: disabled OR-merges with the enclosing Field. Supports Radix-style asChild composition via the child snippet.

parts{compiled.parts.order.length} events{events.length} variants{variants.length} sizes{sizes.length} apg button ↗ presses{pressCount}
trace {#if trace.length === 0} click the button to fire commit-action {:else} {#each trace.slice(0, 3) as entry} {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} {fmtTime(entry.at)} {/each} {/if} intent {intent} presses {pressCount}
{#if tab === 'live'}

Controls

Props grouped by the layer that owns them: soma for intent / disabled / loading / native button attrs; eidos for variant / size / rounded / block / iconOnly. Semantic events live in the { e.preventDefault(); tab = 'sema'; }}>Sema tab.

soma Semantics
soma Behavior + native
eidos Visual treatment
eidos Icon slots · when iconOnly is on, the start icon is the visible glyph and the label becomes sr-only
Demo content · children literal · stays sr-only when iconOnly
soma headless · ARIA + commit-action + loading state svelte
{somaSnippet}
eidos visual · adds variant, size, rounded, block, iconOnly, spinnerPlacement svelte
{eidosSnippet}
{/if} {#if tab === 'gallery'}

Variants × colors

Every eidos variant across all 8 color roles. Hierarchy (primary / secondary / neutral) rides the color prop; the evaluative palette (affirm / fulfill / risk / threat / loss) rides intent, which wins on data-color.

{#each variants as v}
{v} {#each gallery as c} {/each}
{/each}
{/if} {#if tab === 'api'}

API reference

Single-part component: the root IS the <button>. The Spinner is rendered automatically when loading=true — consumers can override the glyph via the spinner snippet prop on the root, or compose Button.Spinner explicitly inside a child snippet for total control.

Button (root)
PropTypeDefaultDescription
idstringautoStable identifier.
intent'neutral' | 'affirm' | 'fulfill' | 'risk' | 'threat' | 'loss''neutral'Doctrinal consequence intent. Drives sema perceptual signature; non-neutral wins over color.
color'primary' | 'secondary' | 'neutral'— (resolves to 'primary' when intent='neutral')Hierarchical override. Only applies when intent='neutral'.
type'button' | 'submit' | 'reset''button'Native button type.
disabled field-ORbooleanfalseOR-merges with Field.Provider.
loadingbooleanfalseWhile true, gates click handler, sets aria-busy, renders Spinner part.
loadingTextstring—Replace children visibly while loading (children remain sr-only).
spinnerPlacement eidos'start' | 'end''start'Where to render the spinner.
aria-labelstring—Accessible name. Required when iconOnly.
onPress{`({ intent }) => void`}—Read-only feedback fired alongside commit-action. For business logic use native onclick.
variant eidos'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain''solid'Visual treatment.
size eidosResponsiveProp<'xs' | 'sm' | 'md' | 'lg' | 'xl'>'md'T-shirt scale.
rounded eidosResponsiveProp<'sm' | 'md' | 'lg' | 'xl' | 'full'>inherits from sizeCorner radius override.
block eidosbooleanfalseStretch to fill parent inline-size.
iconOnly eidosbooleanfalseSquare aspect, padding-inline collapsed.
spinner eidosSnippet—Override the default spinner glyph.
child{`Snippet<[{ loading, disabled, props }]>`}—Slot composition (Radix asChild pattern). Spread props into your own element.
…native button attrsPrimitiveButtonAttributes—onclick, form, formaction, autofocus, etc.
Button.Spinner
PropTypeDefaultDescription
idstringauto—
childrenSnippetdefault ring SVGOverride the spinner glyph.
…native span attrsPrimitiveSpanAttributes——
Reference parity
FeatureRadixbits-uishadcnChakra v3MantineMUIEidos (this)
Component exists—✓ (thin)✓✓✓✓✓
variant count——67836
color palette——implicitpalette tokencolorcolor8 (3 hierarchy + 5 intents)
sizes——46535
loading state———✓✓✓✓
loadingText———✓——✓
spinnerPlacement———✓——✓
iconOnly square——'icon' size———✓
block / fullWidth————✓✓✓
rounded scale————radius—✓
asChild / Slot✓✓✓✓——✓
commit-action semantic event——————✓
per-intent perceptual signature——————✓
{/if} {#if tab === 'morfo'}

morfo · declarative contract

2 parts (Provider + optional Spinner), 1 event (commit-action with intent fromProp). Sequence pre — perceptual signal lands at click, before the consumer's onclick. Source: src/uix/morfo/components/button.ts.

FieldValue
name"{buttonMorfo.name}"
kebab"{buttonMorfo.kebab}"
scope[{buttonMorfo.scope.map((s) => `"${s}"`).join(', ')}]
apg{buttonMorfo.apg}
parts.length{buttonMorfo.parts.length}
events.length{buttonMorfo.events?.length ?? 0}
Parts
{#each partsList as part (part.kebab)} {/each}
PartMarkerElementRoleArchetypeStatesOptional
{part.kebab} [{part.marker}] <{part.defaultElement}> {part.role ?? '—'} {part.archetype ?? '—'} {part.states.length ? part.states.join(' | ') : '—'} {part.optional ? 'yes' : 'no'}
{#each buttonMorfo.parts as rawPart (rawPart.kebab)} {@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string; part?: string }; severity?: string }>; keyboard?: ReadonlyArray<{ key: string; action: string }> }} {@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []} {@const ariaAttrs = partAny.aria ?? []} {@const keyboard = partAny.keyboard ?? []} {#if dataAttrs.length || ariaAttrs.length || keyboard.length}
{partAny.kebab}
{#if dataAttrs.length}
{#each dataAttrs as attr (attr.attr)} {/each}
data-attrValuesSource kind
{attr.attr} {attr.values ? attr.values.join(' | ') : '—'} {'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}
{/if} {#if ariaAttrs.length}
{#each ariaAttrs as a (a.attr)} {/each}
aria-attrSource kindConditionSeverity
{a.attr} {a.value.kind} {a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}${a.condition.part ? ` (${a.condition.part})` : ''}` : 'always'} {a.severity ?? 'required'}
{/if} {#if keyboard.length}
{#each keyboard as kb (kb.key)} {/each}
keyaction
{kb.key === ' ' ? 'Space' : kb.key} {kb.action}
{/if} {/if} {/each}
Events declaration

commit-action fires with sequence pre so the perceptual signal lands at the moment of the click; the consumer's onclick may then run async work without blocking. Intent is bound to the intent prop; supported intents are the full commit-friendly set (neutral / affirm / fulfill / risk / threat / loss).

{#each events as action (action.name)} {@const sem = action.semantic} {@const intentDecl = 'intent' in sem ? sem.intent : undefined} {@const intentStr = typeof intentDecl === 'string' ? intentDecl : intentDecl ? `fromProp:${(intentDecl as { fromProp?: string }).fromProp ?? '?'} (default ${(intentDecl as { default?: string }).default ?? '?'})` : '—'} {/each}
namefamilyverbsequenceintenttargetprewritecommit
{action.name} {sem.family} {sem.verb ?? '—'} {sem.sequence ?? 'pre'} {intentStr} {action.target} {action.prewrite.length ? action.prewrite.map((p) => `${p.attr}=${p.value}`).join(', ') : '—'} {action.commit ? `${action.commit.attr}=${action.commit.value}` : '—'}
{/if} {#if tab === 'sema'}

sema · events + perceptual signature

One event: commit-action from the commit family. Each press fires it with sequence pre — the perceptual signal lands at the click moment, before any async work in the consumer's onclick. Intent is per- button via fromProp:intent and modulates the non-visual signature (sound timbre + haptic curve) via SEMA_MAP.intents.

{#each events as action (action.name)} {@const sem = action.semantic} {@const intentDecl = 'intent' in sem ? sem.intent : undefined} {@const effectiveIntent = typeof intentDecl === 'string' ? intentDecl : intentDecl ? intent : undefined} {/each}
NameFamilyVerbSequenceIntentPlay
{action.name} {sem.family} {sem.verb ?? '—'} {sem.sequence ?? 'pre'} {effectiveIntent ?? '—'} (live)
{/if} {#if tab === 'recipe'}

Eidos recipe

Source: src/uix/eidos/components/button/button.css. Recipe tokens declared in src/uix/eidos/lib/recipes/base.ts under the button key. Six variant slices map the resolved palette to bg / fg / border / hover-* private vars; the size cascade overrides height / padding / gap / font-size / icon-size; the per-color cascade re-binds the palette block (8 system tokens).

SelectorSourceConcern
[data-button]morfoBase layout: inline-flex, gap, height, padding, border, radius, transitions.
[data-button][data-size='xs' | 'sm' | 'md' | 'lg' | 'xl']eidosSize token cascade.
[data-button][data-color='X']morfoPer-color palette cascade (8 system tokens).
[data-button][data-variant='solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain']eidosVariant slice maps palette → bg/fg/border/hover.
[data-button][data-rounded='X']eidosCorner radius override.
[data-button][data-block]eidosdisplay: flex + inline-size: 100%.
[data-button][data-icon-only]eidosSquare aspect, padding-inline: 0.
[data-button][data-loading]morfoCursor progress + pointer-events lock.
[data-button][data-disabled]morfoOpacity + not-allowed cursor.
[data-button]:hover / :active / :focus-visibleeidosInteraction states.
[data-button-spinner]morfoRotating ring SVG container.
.eidos-button-spinner-ringeidosCSS @keyframes rotate animation.
.eidos-button-bodyeidosInline-flex wrapper for children + gap.
{/if} {#if tab === 'a11y'}

Accessibility

Follows the APG button pattern. Renders a native <button> — gets the full native keyboard + focus model. iconOnly consumers MUST set aria-label. While loading, the button exposes aria-busy='true' so screen readers announce the deferred state.

Keyboard
KeyAction
EnterActivate (native).
SpaceActivate (native).
Tab / Shift+TabMove focus to next / previous focusable.
ARIA contract
PartAttributeValue
providertype{`{type}`} (default "button" — explicit to avoid implicit submit in forms)
providerdata-state"idle" | "loading"
providerdata-colorresolved color (intent wins when evaluative)
providerdata-loading"true" when loading
providerdata-disabled"true" when disabled
provideraria-busy"true" when loading
provideraria-disabled"true" when disabled
provideraria-label{`{aria-label}`} when set (required for iconOnly)
spinneraria-hidden"true" (decorative — aria-busy on provider conveys state to AT)
{/if}