Button
A button lets people trigger an action, such as submitting a form, saving changes, or opening a dialog. Providing a destination turns it into a link instead, so the same element can also be used for navigation. Its content can be a text label, an icon, or a combination of both, and it can show a loading state while an action is in progress.
Dependencies
When to use
Use a button to trigger an action that happens right there on the page, such as submitting a form, saving changes, opening a dialog, or deleting something. It suits any situation where people expect an immediate, in-page response as soon as they activate it. If activating it should instead take someone to a different page, see the alternatives below.
Install & usage
Pick a framework in the toolbar above and these snippets adapt.
npm install @eunomia/elements
import "@eunomia/elements/button.js";
<eun-button variant="primary">Confirm</eun-button>
<eun-button variant="secondary">Cancel</eun-button>
Importing the file registers <eun-button> as a custom element, with
no further setup needed. It works with any framework, or none, since
it's a standard web component.
npm install @eunomia/elements
<script type="module">
import "@eunomia/elements/button.js";
</script>
<eun-button variant="primary">Confirm</eun-button>
npm install @eunomia/elements
import "@eunomia/elements/button.js";
function ConfirmActions() {
return (
<>
<eun-button variant="primary">Confirm</eun-button>
<eun-button variant="secondary">Cancel</eun-button>
</>
);
}
npm install @eunomia/elements
"use client";
import "@eunomia/elements/button.js";
export function ConfirmActions() {
return (
<>
<eun-button variant="primary">Confirm</eun-button>
<eun-button variant="secondary">Cancel</eun-button>
</>
);
}
npm install @eunomia/elements
<script setup>
import "@eunomia/elements/button.js";
</script>
<template>
<eun-button variant="primary">Confirm</eun-button>
<eun-button variant="secondary">Cancel</eun-button>
</template>
npm install @eunomia/elements
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@eunomia/elements/button.js";
@Component({
selector: "app-confirm-actions",
template: `
<eun-button variant="primary">Confirm</eun-button>
<eun-button variant="secondary">Cancel</eun-button>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class ConfirmActionsComponent {}
Alternatives
eun-linkGuidance
- Use one
primarybutton per section, for the main action - Use
secondaryfor cancel/back actions alongside it - Reserve
criticalfor destructive actions (delete, remove) - Use
warningfor an action that deserves caution but isn't destructive (overwrite, proceed anyway) - Start labels with an action verb ("Save changes", not "Changes")
- Set
loadingduring an async action to prevent double-submits - Wire it to a target with
command/commandforinstead of a click handler whenever the button doesn't need to run other logic first, preferred over the Popover API's ownpopovertarget: see Invoker Commands API below
- Stacking several
primarybuttons side by side - Using
eun-buttonfor plain text navigation instead ofeun-link(see Alternatives above), unlesshrefis set for a link styled as a button - Overriding colors with inline styles instead of the
--button-*CSS variables - Disabling a button without explaining why elsewhere in the UI
Live testing
Properties
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
| aria-label | string | — | The accessible label for the button. When the button opens a link in a new tab, this automatically gets a note appended announcing it |
| loading-label | string | 'Loading…' | The accessible label announced while the button is loading |
| icon | EunomiaIconName | — | An optional leading icon, shown at the start of the button. Ignored once something is slotted into start, and overridden by the default icon from severity when both are set |
| severity | 'info' | 'success' | 'warning' | 'critical' | 'neutral' | — | The severity of the action this button leads to. When set, it supplies a default leading icon unless icon is also set or something is slotted into start. It has no effect on color, which is still controlled by variant |
| appearance | 'default' | 'fill' | 'outline' | 'flat' | 'raised' | 'ghost' | 'fill' | The button's surface style. Fill is a solid, colored background with light text. Default is a paler tint of the same color. Outline keeps the color as a border instead of a fill. Flat always renders a muted, low emphasis surface regardless of variant. Raised adds a drop shadow. Ghost shows no surface at rest, only gaining a border and a tinted background on hover, focus, or press |
| full | boolean | false | Whether the button stretches to fill 100% of the width and height available from its parent, instead of sizing to its content. Its content stays centered on both axes once stretched |
| active | boolean | false | Indicates whether the component is on disabled state or not. By default, set to false. |
Import the exact TypeScript type behind any property above, see
Properties
JS-only — no matching HTML attribute, set these from a script or a template binding.
| Name | Type | Default | Description |
|---|---|---|---|
| type | string | 'button' | The button's native type. Ignored when href is set |
| variant | 'primary' | 'secondary' | 'critical' | 'success' | 'warning' | 'light' | 'medium' | 'dark' | 'primary' | The color theme applied to the button. light/medium/dark are neutral grey tones with no semantic meaning, matching eun-tag's color scale, for a de-emphasized action next to a semantically-colored one |
| size | string | 'm' | The size of the button |
| rounded | boolean | false | Whether the button has fully rounded corners |
| slide | boolean | false | Whether the button uses a sliding open animation |
| href | string | — | The URL the button navigates to. When set, the button renders as a link instead of a button |
| target | '_blank' | '_self' | '_parent' | '_top' | — | Where to open the linked URL. Only used when href is set. Opening in a new tab automatically adds an external link icon and announces it to assistive technology |
| download | string | — | The filename used when the linked URL is downloaded instead of navigated to. Only used when href is set |
| loading | boolean | false | Whether the button shows a loading indicator. Like disabled, it blocks interaction, but keeps the original content in place so the button doesn't change size |
| command | string | — | The command sent to the element targeted by commandFor when the button is clicked, following the Invoker Commands API |
| commandFor | string | — | The id of the element that command is sent to |
Slots
| Name | Description |
|---|---|
| (default) | The button content |
| start | An icon or other content shown at the start of the button. Takes priority over the default icon from icon or severity. Leave empty to let one of those render instead |
| end | An icon or other content shown at the end of the button. Ignored when href and target are set to open in a new tab, since an external link indicator takes its place |
Events
| Name | Type | Description |
|---|---|---|
| click | Event | Fired when the button is activated by mouse, Enter, or Space |
| eunnavigate | NavigationEvent | Fired before following href, so a client-side router can intercept the navigation. Not fired for downloads, and cancelable |
Every event above follows the same naming convention, covered in
CSS custom properties
| Name | Description |
|---|---|
| --button-background | Sets the background color |
| --button-background-hover | Sets the background color on hover |
| --button-background-active | Sets the background color while pressed |
| --button-text | Sets the text color |
| --button-padding | Sets the button's padding |
| --button-border-radius | Sets the corner radius of the button |
| --button-focus-outline | Sets the color of the focus outline |
| --button-transition | Sets the duration of the button's transitions |
| --button-shadow | Sets a box shadow. None is applied by default |
| --button-shadow-hover | Sets the box shadow on hover, falling back to button-shadow when unset |
| --button-border-color | Sets the border color, overriding the color the active variant would otherwise use |
| --button-border-color-hover | Sets the border color on hover or focus, falling back to button-border-color when unset |
| --button-border-size | Sets the width of the border |
Variants
variant selects the color role: primary (default), secondary,
critical, success, or warning.
<eun-button>Primary</eun-button>
<eun-button variant="secondary">Secondary</eun-button>
<eun-button variant="critical">Critical</eun-button>
<eun-button variant="success">Success</eun-button>
<eun-button variant="warning">Warning</eun-button>
Part of the shared vocabulary covered in
Neutral variants
light, medium, and dark add no semantic meaning of their own. They use
the same grey scale Tag's color uses. Reach for one
when an action needs to sit next to a semantically-colored button without
competing with it, such as a secondary "Learn more" alongside a primary
CTA.
<eun-button>Confirm</eun-button>
<eun-button variant="light">Learn more</eun-button>
Invoker Commands API
Setting command and commandfor turns a button into an invoker: no
click handler needed, the target picks up the command itself. Prefer
this over wiring a click handler yourself, and over the Popover API's
own popovertarget, which only ever covers popover-attributed
targets. Modal and Drawer both understand show-modal, close, and
request-close out of the box.
This will permanently remove the item from your workspace.
<eun-modal
id="delete-modal"
heading="Delete item"
confirm-label="Delete"
confirm-variant="critical"
>
<p>This will permanently remove the item from your workspace.</p>
</eun-modal>
<eun-button command="show-modal" commandfor="delete-modal"
>Delete item</eun-button
>
A command name starting with -- is a custom command: nothing happens
by default, so the target listens for it and decides what it means.
<eun-button command="--print" commandfor="invoice">Print</eun-button>
document.getElementById("invoice").addEventListener("command", (event) => {
if (event.command === "--print") {
window.print();
}
});
See the
Appearances
appearance picks the surface treatment, independent of variant. It uses
the same vocabulary alert and other feedback
components use for default/fill/outline/flat, extended with two
action-only treatments, raised (a drop shadow) and ghost (no visible
surface at all until hover/focus/active). flat always renders the same muted,
low-emphasis surface regardless of variant, meant for low-priority
actions rather than reinforcing the brand color. ghost is similar, but
starts completely invisible, only appearing on hover/focus/active when
no variant is set (set one to color it instead). It's a good fit for a
secondary/dismiss action that shouldn't visually compete with a primary
one nearby, e.g. eun-modal's own default cancel button.
fill is the default look when appearance is left unset, a solid,
opaque variant-colored background. ghost has no background or border
at rest by design. Hover/focus it above to see it appear. The dashed
outline is added for this demo only, so its hit area stays visible at
rest, and isn't part of the real component (see the plain markup below).
<eun-button appearance="fill">Fill</eun-button>
<eun-button appearance="default">Default</eun-button>
<eun-button appearance="outline">Outline</eun-button>
<eun-button appearance="raised">Raised</eun-button>
<eun-button appearance="flat">Flat</eun-button>
<eun-button appearance="ghost">Ghost</eun-button>
Sizes
Five sizes are available through the size attribute: xs, s,
m (default), l, xl.
<eun-button size="xs">Extra small</eun-button>
<eun-button size="l">Large</eun-button>
Rounded
The boolean rounded attribute gives the button a pill shape.
<eun-button rounded>Rounded</eun-button>
Full width/height
The boolean full attribute stretches the button to 100% of the width
and height its own parent makes available, instead of sizing to its own
content. It's handy for two buttons side by side, one with an icon and
one without, that should share the exact same height. Content stays
centered on both axes regardless of how much extra space that adds.
Give each button its own sized wrapper (here, an equal flex: 1
column) rather than placing several full buttons directly as
siblings in one shared row, since each would then try to claim the
entire row for itself.
<div style="display: flex; gap: 8px; height: 80px;">
<div style="flex: 1;">
<eun-button full>Save</eun-button>
</div>
<div style="flex: 1;">
<eun-button full variant="light">
<eun-icon slot="start" name="close"></eun-icon>
Cancel
</eun-button>
</div>
</div>
With icons
The leading (start) icon can be set either through the icon property,
or by slotting a eun-icon (or any content) into start, a manual
override for anything the icon prop's plain string can't express. Both
produce the same result for a plain icon. Reach for the slot as soon as
it needs to be something else (e.g. combined with slide, below). The
trailing (end) icon has no property equivalent, so always slot it.
Setting only an icon in the default slot produces an icon-only button, so
take care to apply the accessibility rules in the Accessibility tab when
doing so.
import "@eunomia/elements/icon.js";
<eun-button icon="iconName">Label</eun-button>
<!-- equivalent, spelled out by hand -->
<eun-button>
<eun-icon slot="start" name="iconName"></eun-icon>
Label
</eun-button>
The icon module must be imported alongside the button, exactly like eun-loader below for the loading state.
Severity
severity (info, success, warning, critical, or neutral, the
same vocabulary as Alert/Tag)
sets the button's leading icon by default, once set and icon isn't (and
nothing is slotted into start). It has no effect on color, which stays
driven by variant instead. A full severity range doesn't have a
matching action color (no "info"/"neutral" button), so the two stay
independent rather than one implying the other.
<eun-button severity="info">Info</eun-button>
<eun-button severity="success">Success</eun-button>
<eun-button severity="warning">Warning</eun-button>
<eun-button severity="critical">Critical</eun-button>
<eun-button severity="neutral">Neutral</eun-button>
An explicit icon (prop or slotted) always takes precedence over severity's own default.
Icon-only
Slot just an icon with no visible label, and always pair it with an
aria-label describing the action. The icon itself carries no
accessible text (it's decorative, rendered aria-hidden), so without
one the button would announce nothing at all to a screen reader.
<eun-button aria-label="Delete" variant="critical">
<eun-icon name="delete"></eun-icon>
</eun-button>
Slide button
It shows the label on hover or focus and collapses to just the icon
otherwise, using only an icon slotted into start. It's implemented in
pure CSS, with no measurement and no JavaScript needed. The label's grid
track animates from 0fr to 1fr, so it always expands to exactly its
own natural content width, however long that is, with no hardcoded
width to tune.
<eun-button slide variant="critical">
<eun-icon slot="start" name="delete"></eun-icon>
Remove
</eun-button>
Loading
Setting loading blocks interaction the same way disabled does. The
button keeps its original content in place, kept invisible so its size
doesn't change, while a eun-loader spinner is layered on top.
import "@eunomia/elements/loader.js";
<eun-button loading>Save changes</eun-button>
eun-loader is used internally, but its module must still be imported alongside eun-button for the spinner to render, exactly like eun-icon for slotted icons above.
Links
Setting href renders the button as a native <a> internally
instead of a <button>, delegating navigation, focus, and activation
entirely to the browser. All the same visual props (variant,
size, rounded, ...) still apply.
<eun-button href="https://example.com">Visit website</eun-button>
When href is set, type is ignored (an <a> has no submit/reset mechanism) and the host is removed from the tab order, since the rendered <a> is the real, natively focusable element.
Opening in a new tab
Setting target="_blank" automatically adds rel="noopener noreferrer",
replaces the end slot with an external-link icon, and appends
"(opens in a new tab)" to the accessible name.
<eun-button href="https://example.com" target="_blank">
Visit website
</eun-button>
Disabled link
Native <a> elements have no disabled attribute, so a disabled
link button simply omits href instead. It keeps its usual visual
style, but is no longer exposed as a link to the browser or to
assistive technology, and gets aria-disabled="true" set for
clarity.
<eun-button href="https://example.com" disabled> Visit website </eun-button>
SPA navigation
A link button dispatches a cancelable eunnavigate event right
before following href, letting an SPA router intercept it for
client-side routing instead of a full page load. It's never dispatched
for download links, since a file download isn't a navigation.
document.addEventListener("eunnavigate", (event) => {
if (!event.href.startsWith("/")) {
return;
}
event.preventDefault();
myRouter.push(event.href);
});
Theming
Colors come from the active theme's --eun-color-primary-* /
--eun-color-secondary-* / --eun-color-critical-* / --eun-color-success-*
/ --eun-color-warning-* tokens (see
--button-* custom properties instead of the theme
tokens directly (full list in the API tab).
<eun-button
style="
--button-background: #b45309;
--button-background-hover: #92400e;
--button-focus-outline: #b45309;
"
>
Custom button
</eun-button>
Keyboard interactions
When focus is on the component:
| Key | Action |
|---|---|
Enter |
Activates the button |
Space |
Activates the button |
Focus indicator
A :focus-visible outline is always rendered via outline, not a
background/shadow change, so it isn't suppressed by any surface
appearance (raised, outline, flat…). Its color comes from a shade
picked specifically for contrast against the page background, one
step darker than the button's own fill rather than reusing the fill
color itself, so the ring stays visible instead of blending into the
button on secondary and other lighter variants. Override it with
--button-focus-outline if a custom theme needs a different color.
Aria attributes
aria-label/aria-labelledby: set when the visible label isn't sufficient on its ownaria-haspopup="true": when the button controls a menu, listbox, modal, or anything using the popover APIaria-expanded="true\|false": for buttons controlling an accordion or disclosurearia-pressed="true\|false": for toggle buttons- For a link button opening a new tab, "(opens in a new tab)" is announced automatically, so no manual wording is needed
- While
loading,aria-busy="true"is set and the loading indicator exposesrole="status"with an accessible label (loadingLabel, defaults to"Loading…")
Label accessibility
The accessible name is resolved in order of priority: aria-labelledby,
then aria-label, then the slotted text content, then title as a
last resort. When using aria-labelledby or aria-label, include the
visible label within the accessible one, since voice-command users rely
on that match.
Icon-only buttons
Add an aria-label repeating the intended action, since the icon alone
isn't announced. The title attribute doesn't appear on keyboard
navigation and doesn't scale with page zoom, so limit icon-only
buttons to recurring, easily identifiable actions. See the
Icon-only example in the Examples tab for the exact pattern.
Disabled state
A disabled button is not focusable and shouldn't carry a tooltip or other pointer-only affordance, since a keyboard user could never reach it to trigger that affordance in the first place. Avoid disabling a form's submit button. Prefer inline validation messaging instead, so keyboard and screen-reader users understand why the action is currently blocked rather than being met with an unreachable, unexplained control.
Slide button
The label stays in the accessible name at all times, even while
visually collapsed to just the icon. It's hidden with opacity,
which affects paint only, not the accessibility tree. A screen
reader always announces the full label (e.g. "Remove"), regardless
of whether the sighted, hover-revealed state is currently showing.