Eunomia v1.1.0
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

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

eun-icon · if using an icon eun-loader · if loading
Overview API Examples Accessibility
Confirm Cancel Delete Disabled

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 color="primary">Confirm</eun-button>
<eun-button color="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.

Alternatives

You want… Prefers
A text-styled link for page navigation eun-link

Guidance

  • Use one primary button per section, for the main action
  • Use secondary for cancel/back actions alongside it
  • Reserve critical for destructive actions (delete, remove)
  • Use warning for an action that deserves caution but isn't destructive (overwrite, proceed anyway)
  • Start labels with an action verb ("Save changes", not "Changes")
  • Set loading during an async action to prevent double-submits
  • Wire it to a target with command/commandfor instead of a click handler whenever the button doesn't need to run other logic first, preferred over the Popover API's own popovertarget: see Invoker Commands API below
  • Stacking several primary buttons side by side
  • Using eun-button for plain text navigation instead of eun-link (see Alternatives above), unless href is 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

NameTypeDefaultDescription
fixed-colorbooleanfalseKeeps a light or dark button white or black in every theme. Without it, they follow the grey scale, which flips with the active theme. Has no effect on the other colors
aria-labelstring—The accessible label for the button. When the button opens a link in a new tab, this automatically gets a note appended announcing it. Ignored with a slotted anchor, which takes it instead
loading-labelstring—The accessible label announced while the button is loading. Defaults to the localized string for the current locale (English: "Loading…")
iconEunomiaIconName—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 color
appearance'soft' | 'fill' | 'outline' | 'flat' | 'raised' | 'ghost''fill'The button's surface style. Fill is a solid, colored background with light text. Soft 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 color. Raised adds a drop shadow. Ghost shows no surface at rest, only gaining a border and a tinted background on hover, focus, or press
fullbooleanfalseWhether the button stretches to fill 100% of the width available from its parent, instead of sizing to its content. Its content stays centered once stretched
activebooleanfalseIndicates whether the component is on disabled state or not. By default, set to false.

Import the exact TypeScript type behind any property above, see Types.

Properties

JS-only — no matching HTML attribute, set these from a script or a template binding.

NameTypeDefaultDescription
typestring'button'The button's native type. Ignored when href is set
color'primary' | 'secondary' | 'critical' | 'success' | 'warning' | 'light' | 'medium' | 'dark''primary'The color 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
fixedColorbooleanfalseKeeps a light or dark button white or black in every theme. Without it, they follow the grey scale, which flips with the active theme. Has no effect on the other colors
sizestring'm'The size of the button
roundedbooleanfalseWhether the button has fully rounded corners
slidebooleanfalseWhether the button uses a sliding open animation
hrefstring—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, in the current locale. A slotted anchor opening in a new tab gets the same icon and announcement
localestring—The locale used to resolve the "opens in a new tab" announcement. Defaults to the one set with setDefaultLocale, English otherwise
downloadstring—The filename used when the linked URL is downloaded instead of navigated to. Only used when href is set
loadingbooleanfalseWhether 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
commandstring—The command sent to the element targeted by commandFor when the button is clicked, following the Invoker Commands API
commandForstring—The id of the element that command is sent to
hostTabStop—Whether the host itself is a tab stop while enabled. `false` for a component whose focusable controls live inside its shadow root.
resolvedLocale—This instance's own `locale` when set, the shared default locale otherwise. Used for `Intl` formatting, so dates and numbers follow the same locale as dictionary.
dictionary—This instance's locale-resolved string dictionary — see the class doc for the resolution order.

Slots

NameDescription
(default)The button content
startAn 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
endAn icon or other content shown at the end of the button. Ignored when the button opens a link in a new tab, through href and target or a slotted anchor, since an external link indicator takes its place

Events

NameTypeDescription
clickFired when the button is activated by mouse, Enter, or Space
eunnavigateNavigationEventFired 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 Events.

CSS custom properties

NameDescription
--button-backgroundSets the background color
--button-background-hoverSets the background color on hover
--button-background-activeSets the background color while pressed
--button-textSets the text color
--button-paddingSets the button's padding, for every size
--button-padding-blockSets the vertical padding, as one length applied to both sides. Ignored when --button-padding is set
--button-padding-inlineSets the horizontal padding, as one length applied to both sides. Ignored when --button-padding is set
--button-border-radiusSets the corner radius of the button, for every size and over rounded
--button-focus-outlineSets the color of the focus outline
--button-transitionSets the duration of the button's transitions
--button-shadowSets a box shadow. None is applied by default
--button-shadow-hoverSets the box shadow on hover, falling back to button-shadow when unset
--button-border-colorSets the border color, overriding the color the active color would otherwise use
--button-border-color-hoverSets the border color on hover or focus, falling back to button-border-color when unset
--button-border-sizeSets the width of the border. Defaults to 1px for outline and 1.5px otherwise
--button-font-sizeSets the font size, for every size
--button-font-weightSets the font weight
--button-line-heightSets the line height, for every size. Also sizes the icons
--button-widthSets the button's width, such as 100% to stretch it like the full attribute does. Works in every browser, from a container query or a media query alike. Set it on the button itself, since nested components inherit it too

Variants

Colors

color selects the color role: primary (default), secondary, critical, success, or warning.

Primary Secondary Critical Success Warning
<eun-button>Primary</eun-button>
<eun-button color="secondary">Secondary</eun-button>
<eun-button color="critical">Critical</eun-button>
<eun-button color="success">Success</eun-button>
<eun-button color="warning">Warning</eun-button>

Part of the shared vocabulary, alongside every other component that reuses it.

Neutral colors

light, medium, and dark add no semantic meaning of their own. They use the same neutral colors 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.

Confirm Learn more Learn more Learn more
<eun-button>Confirm</eun-button>
<eun-button color="light">Learn more</eun-button>

light and dark are absolute colors: white and black, whatever the appearance. fill is solid white with black text (solid black with white text for dark), and outline is a white border with white text (black for dark). soft is as solid as fill, since white and black have no pale wash, and flat is a faint wash of the color itself. Pick them for a background of the opposite tone.

Default Fill Outline Flat
Default Fill Outline Flat
<eun-button color="light" fixed-color appearance="outline">Outline</eun-button>
<eun-button color="dark" fixed-color appearance="fill">Fill</eun-button>

Keeping light and dark in every theme

By default, light and dark follow the grey scale, which flips with the active theme: a light button is white in the light theme and dark in the dark theme. Set fixed-color to pin it to white (or black) in every theme, without overriding any CSS variable. Toggle the theme in the toolbar above to compare: the left column follows the theme, the right one does not. fixed-color has no effect on the other colors.

Follows the theme Fill Outline
Fixed Fill Outline
<eun-button color="light" fixed-color>Light</eun-button>
<eun-button color="dark" fixed-color appearance="outline">Dark</eun-button>

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 color 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.

Info Success Warning Critical Neutral
<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.

Appearances

appearance picks the surface treatment, independent of color. It uses the same vocabulary alert and other feedback components use for 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 color, 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 color 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 Soft Outline Raised Flat Ghost

fill is the default look when appearance is left unset, a solid, opaque color-colored background. soft is a pale tint of the same color, the look feedback components call default, renamed here since it isn't the button's default. 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="soft">Soft</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.

Extra small Small Medium Large Extra large
<eun-button size="xs">Extra small</eun-button>
<eun-button size="l">Large</eun-button>

Each size sets its own font size and line height. To match a typography that none of them reaches, set --button-font-size, --button-line-height and --button-font-weight, which apply on top of every size and keep working with href and with a slotted link. The line height also sizes the icons.

Custom typography
<eun-button
  style="
    --button-font-size: 12px;
    --button-line-height: 16px;
    --button-font-weight: 500;
  "
>
  Custom typography
</eun-button>

Rounded

The boolean rounded attribute gives the button a pill shape.

Rounded
<eun-button rounded>Rounded</eun-button>

Full width

The button never stretches to fill a flex or grid parent's default (stretch) sizing on its own, unlike a plain native <button>: it always sizes to its own content, in every container, until full (or --button-width, below) asks for the opposite explicitly. slide is the one exception, since its own hover-reveal animation needs that same stretch to have room to expand into.

The boolean full attribute stretches the button to 100% of the width its own parent makes available, instead of sizing to its own content, and keeps the content centered. It's handy for a call to action that should span a card or a form, especially on small screens.

Save
<eun-button full>Save</eun-button>

Full width in a container query

A plain attribute can't be toggled from CSS at a given width. To stretch a button only in a narrow container, set the --button-width custom property to 100% from a @container rule instead. It gives the same result as the full attribute in every browser, and the responsive behavior stays in pure CSS, with no script and no attribute to toggle.

.actions {
  container-type: inline-size;
}

@container (width < 600px) {
  .actions eun-button {
    --button-width: 100%;
  }
}

--button-width takes any width, such as a fixed 200px, and keeps the content centered. Set it on the button itself rather than on a container, since the buttons inside nested components would inherit it too. A width or min-width set on the button from a class or a media query works the same way.

Content

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.

Icon prop Slotted icon Right icon
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.

A slotted eun-icon or Material Symbols glyph always picks up the button's text color automatically. A raw, hand-drawn <svg> does too, unless its root already sets its own fill (e.g. fill="none" on a stroke-only outline icon): that explicit attribute is always left alone, and its children keep whichever fill they declare themselves, so an outline icon slotted straight in doesn't turn into a solid blob.

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" color="critical">
  <eun-icon name="delete"></eun-icon>
</eun-button>

Behavior

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.

Save changes
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.

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.

Remove
<eun-button slide color="critical">
  <eun-icon slot="start" name="delete"></eun-icon>
  Remove
</eun-button>

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 (color, size, rounded, ...) still apply.

Visit website
<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. That wording follows locale, or the default set with setDefaultLocale, so a French page announces "(ouvre dans un nouvel onglet)".

Visit website
<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.

Visit website
<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.

The same router bridge covers every link-like component, Link and Card included: see the SPA navigation guide for a ready-to-use bridge per framework.

Search engines and server rendering

With href, the button's <a> only exists in the shadow root, so a crawler that doesn't run JavaScript finds no link in the server HTML. For an internal call to action that should be indexed, leave href unset and place a native anchor as a direct child instead. It's stretched over the whole button, so every pixel stays clickable, while the accessible name remains the anchor's own text. The anchor is also what takes focus, so an aria-label goes on it. One left on eun-button is ignored in this mode, and a console warning names it.

<eun-button color="primary">
  <a href="/guides/server-rendering/">Read the guide</a>
</eun-button>

The anchor owns navigation, so no eunnavigate is fired for it. Since a link has no native disabled state, disabled and loading mark the anchor aria-disabled="true", take it out of the tab order and block its clicks. target and rel are yours to write on the anchor. See the Server-side rendering guide for the full picture.

When the anchor opens in a new tab, the button replaces the end slot with the same external-link icon as with href, and announces the new tab through the anchor's aria-description, in the button's locale. The anchor's text and aria-label stay as you wrote them, and an aria-description of your own is kept as is.

<eun-button color="primary">
  <a href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
    Visit open-wc
  </a>
</eun-button>

The aria-description is only a fallback: it's added once the element upgrades, and some screen readers only read a description after a pause, or leave it out of their links list. To announce the new tab everywhere, pages without JavaScript included, write the wording inside the anchor with the eun-new-tab-hint class. It becomes part of the anchor's accessible name and of the server-rendered HTML, links-ssr.css hides it visually, and the button then adds no aria-description, so the new tab is never announced twice.

import Link from "next/link";

<eun-button>
  <Link href="https://open-wc.org" target="_blank" rel="noopener noreferrer">
    Visit open-wc
    <span className="eun-new-tab-hint"> (opens in a new tab)</span>
  </Link>
</eun-button>;

The class is hidden by links-ssr.css, so load that stylesheet, as described in the Server-side rendering guide, or the wording shows up as plain text.

Integration

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.

Delete item
<eun-modal
  closeable
  close-on-backdrop
  id="delete-modal"
  heading="Delete item"
  confirm-label="Delete"
  confirm-color="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 Invoker Commands API guide for how the attributes work and current browser support.

Server-side rendering

The button's box lives in its shadow root, so a server-rendered button is plain inline text until the element upgrades, then becomes a button all at once, shifting the layout around it. Link button-ssr.css in the page's <head> to draw the final box from the first paint:

<link
  rel="stylesheet"
  href="node_modules/@eunomia/elements/dist/ssr/button-ssr.css"
/>

A bundler that resolves package exports (Vite, webpack, Turbopack, or a Sass @use/@import) can reach the same file through the package specifier instead:

@use "@eunomia/elements/button-ssr.css";

It covers every size, color and appearance, rounded, full, disabled, the --button-* custom properties and the room of the icon set by icon or severity. Hover and focus styles, the loading indicator and the slide animation only arrive with the upgrade, so a slide button shows its label until then.

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 Theming). Switch the toolbar above and every demo on this page repaints immediately. For one-off overrides, target the component's own --button-* custom properties instead of the theme tokens directly (full list in the API tab).

Custom button
<eun-button
  style="
    --button-background: #b45309;
    --button-background-hover: #92400e;
    --button-focus-outline: #b45309;
  "
>
  Custom button
</eun-button>

The custom properties that change colors, borders, shadows and corners, at a glance. The API tab lists every property of the component.

Variable Sets
--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-border-radius Sets the corner radius of the button
--button-focus-outline Sets the color of the focus outline
--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 color 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, 1px for outline and 1.5px otherwise

Keyboard and focus

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 colors. Override it with --button-focus-outline if a custom theme needs a different color.

Semantics and screen readers

Aria attributes

  • aria-label / aria-labelledby: set when the visible label isn't sufficient on its own
  • aria-haspopup="true": when the button controls a menu, listbox, modal, or anything using the popover API
  • aria-expanded="true\|false": for buttons controlling an accordion or disclosure
  • aria-pressed="true\|false": for toggle buttons
  • For a link button opening a new tab, "(opens in a new tab)" is announced automatically in the button's locale, so no manual wording is needed. With a slotted anchor, it goes on the anchor's aria-description
  • While loading, aria-busy="true" is set and the loading indicator exposes role="status" with an accessible label (loadingLabel, by default "Loading…" in the active locale)

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.