EmButton
The primary action control — four variants, three sizes, and a loading state that spins in place without the button changing size.
Usage
EmButton is a shell component: fixed anatomy, so everything is a prop and the default slot is the label. variant picks the role the button plays in a layout, and size picks the type scale it sits on.
The four variants are a hierarchy, not a palette. Use exactly one primary per view — it is the thing you want the reader to do. secondary is the outlined alternative for a second, equally valid action; tertiary is text-only, for actions that should stay out of the way; destructive is for the ones that delete something.
Anatomy
<script setup lang="ts">
import { EmButton } from '@paper/emerald'
</script>
<template>
<EmButton />
</template>Composed on v0
EmButton renders v0’s Button compound — Button.Root, Button.Content and Button.Loading — and adds nothing to its behavior.
The division is worth knowing because it explains the loading state. Button.Loading is a slot-only shell that renders no element of its own, so Emerald owns the absolutely-positioned wrapper inside it and the spinner within that. Button.Content stays in the flow the whole time. The result is that a loading button keeps the exact width its label gave it — the label is still laid out, just covered — so a toolbar does not reflow the moment someone clicks Save.
Button.Root is also what emits the data-disabled and data-loading attributes that every rule in Emerald’s stylesheet hangs off. Emerald never writes a state class; it styles the attributes v0 already publishes.
Examples
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'tertiary' | 'destructive' | 'primary' | Visual role in the action hierarchy |
size | 'sm' | 'md' | 'lg' | 'md' | Type and padding step |
disabled | boolean | false | Action unavailable. Sets the native disabled attribute — blocks activation and removes the button from the tab order |
loading | boolean | false | Action in flight. Sets aria-busy and data-loading and covers the label with a spinner. Does not block activation |
ariaLabel | string | — | Accessible name. Required for icon-only buttons |
name | string | — | Form field name; renders a hidden input when set |
namespace | string | — | Which v0 Button instance to bind to. Only needed when nesting |
The default slot is the label. There are no named slots.
Accessibility
Button.Root renders a native <button>, so activation by Enter and Space, focus order, and the implicit button role all come from the platform rather than from JavaScript.
Naming
The accessible name is the default slot’s text. When there is no text — an icon-only button — you must pass ariaLabel, because the glyph inside is aria-hidden and contributes nothing. Never solve it by labelling the icon instead: that names the image, not the control, and leaves the button itself anonymous.
Disabled and loading
The two states are announced differently, and only one of them takes the control out of play:
| State | Attributes set | Focusable | Activation | Announced as |
|---|---|---|---|---|
disabled | disabled, aria-disabled, data-disabled | No | Blocked | Disabled — present but unavailable |
loading | aria-busy, data-loading | Yes | Still fires | Busy, with the name and role unchanged |
aria-busy is what tells assistive technology the control is working, and Button.Root sets it as soon as loading goes true. What it does not do is announce the outcome. If the wait is more than momentary, say what happened when it ends — put the result in an aria-live region, or move focus to it. A spinner and a busy flag both describe the wait, not the answer.
Because loading leaves the button operable, a screen-reader or keyboard user can activate it again mid-request exactly as a mouse user can. Guard the handler, or add disabled.
A disabled button is unfocusable, which means a keyboard user tabbing through the form never encounters it and gets no explanation for why the action is missing. When the reason matters, prefer leaving the button enabled and reporting the problem on activation.
Focus
Every variant defines its own :focus-visible treatment against its own background — an outline for primary, secondary and destructive, and an inset ring for tertiary, which has no fill to outline against. The indicator only appears for keyboard focus, so a mouse click does not leave a ring behind.