Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Button

A button runs an action, or in link mode follows a link, when pressed. A native <button> sits underneath (an <a href> in link mode), so form submission, navigation and semantics stay native. UniAria adds one press event that behaves the same for mouse, pen, touch, keyboard and screen-reader clicks, and hover, pressed and focus-visible states for your CSS.

It follows the WAI-ARIA Button pattern.

Buttons
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The buttons' look is @uniaria/styles' default. */
:root {
  font:
    15px/1.5 system-ui,
    sans-serif;
}
body {
  display: grid;
  gap: 1rem;
  margin: 1.5rem;
  background: var(--uni-surface-color);
  color: var(--uni-text-color);
}
.row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: center;
}
.filter {
  inline-size: 12rem;
}
kbd {
  font: inherit;
}
</style><div class="row">
  <uni-button>
    <button data-uni="button" data-variant="primary">Save</button>
  </uni-button>
  <uni-button>
    <button data-uni="button">Cancel</button>
  </uni-button>
  <uni-button>
    <button data-uni="button" data-variant="quiet">Skip</button>
  </uni-button>
  <uni-button>
    <button data-uni="button" data-variant="danger">Delete</button>
  </uni-button>
</div>
<div class="row">
  <uni-button disabled focusable-when-disabled>
    <button data-uni="button" data-variant="primary">Saving…</button>
  </uni-button>
  <uni-button>
    <a data-uni="button" href="#guide">Read the guide</a>
  </uni-button>
</div>
<div class="row">
  <uni-button>
    <button data-uni="button">
      <span data-element="Label">Export</span>
      <span data-element="Description">As a PDF file</span>
      <kbd>Ctrl+E</kbd>
    </button>
  </uni-button>
  <uni-button>
    <div data-uni-part="button-frame" class="filter">
      <button data-uni="button">
        <span data-element="Label">Fruit</span>
        <span data-element="Description">Apple</span>
      </button>
      <span data-uni-part="button-extras">
        <uni-button>
          <button
            data-uni="button"
            data-variant="quiet"
            aria-label="Clear Fruit"
          >
            ×
          </button>
        </uni-button>
      </span>
    </div>
  </uni-button>
</div>
<output id="log">Press a button with the mouse, a key or a tap.</output>
<script src="/demo/button.js"></script><script>const log = document.getElementById('log');

document.addEventListener('press', (event) => {
  // The pressed button's name: its aria-label, its label part or its text.
  const root = event.target.querySelector('[data-uni="button"]');
  const label = root.querySelector('[data-element="Label"]') ?? root;
  const name = root.getAttribute('aria-label') ?? label.textContent.trim();
  log.textContent = `${name}: press, ${event.detail.pointerType}`;
});
</script>

Usage

React

import { button } from '@uniaria/core/button';
import { useBehavior } from '@uniaria/react';

export function SaveButton(props: { isSaving: boolean; onSave(): void }) {
  const save = useBehavior(button, {
    onPress: props.onSave,
    isDisabled: props.isSaving,
    isFocusableWhenDisabled: true,
  });
  return (
    <button {...save.parts.root}>{props.isSaving ? 'Saving…' : 'Save'}</button>
  );
}

Vue

<script setup lang="ts">
import { button } from '@uniaria/core/button';
import { useBehavior } from '@uniaria/vue';

const props = defineProps<{ isSaving: boolean }>();
const emit = defineEmits<{ save: [] }>();

const save = useBehavior(button, () => ({
  onPress: () => emit('save'),
  isDisabled: props.isSaving,
  isFocusableWhenDisabled: true,
}));
</script>

<template>
  <button v-bind="save.parts.root">{{ isSaving ? 'Saving…' : 'Save' }}</button>
</template>

HTML

<uni-button id="save">
  <button data-uni="button">Save</button>
</uni-button>

<script type="module">
  import '@uniaria/elements/button';

  document.querySelector('#save').addEventListener('press', (event) => {
    console.log('Pressed with', event.detail.pointerType);
  });
</script>

With href, the root is a native link: link semantics, native navigation (including modifier clicks, middle click and “open in new tab”), and the same press events and state attributes as a button. There is no separate Link component, so an action and a link can share one look while screen readers still list them correctly.

React

const docs = useBehavior(button, { href: '/docs' });
return <a {...docs.parts.root}>Docs</a>;

Render your router’s link component as the root and spread the part props onto it for client-side routing.

Vue

<script setup lang="ts">
const docs = useBehavior(button, { href: '/docs' });
</script>

<template>
  <a v-bind="docs.parts.root">Docs</a>
</template>

For client-side routing, bind the part props onto your router’s link component, <RouterLink v-bind="docs.parts.root" to="/docs">: Vue passes them on to the <a> it renders.

HTML

<uni-button>
  <a data-uni="button" href="/docs">Docs</a>
</uni-button>

The element reads href from the link, so it works before JavaScript runs. Set the href property to change it later.

Label, description and extras

A label part names the button by itself, and a description part describes it. An icon, a badge or a shortcut hint elsewhere in the button stays out of its name: “button, Save, Saves the file” rather than “button, SaveSaves the file⌘S”.

A button can’t hold other buttons. For controls that belong with it, such as a Clear button or a count, put the button in a frame and the controls in its extras, beside the button. Each control there is a component of its own, with its own name.

React

import { button } from '@uniaria/core/button';
import { useBehavior } from '@uniaria/react';

export function FruitFilter(props: {
  fruit: string;
  onPick(): void;
  onClear(): void;
}) {
  const filter = useBehavior(button, { onPress: props.onPick });
  const clear = useBehavior(button, { onPress: props.onClear });
  return (
    <div {...filter.parts.frame}>
      <button {...filter.parts.root}>
        <span {...filter.parts.label}>Fruit</span>
        <span {...filter.parts.description}>{props.fruit}</span>
      </button>
      <span {...filter.parts.extras}>
        <button
          {...clear.parts.root}
          data-variant="quiet"
          aria-label="Clear Fruit"
        >
          ×
        </button>
      </span>
    </div>
  );
}

Vue

<script setup lang="ts">
import { button } from '@uniaria/core/button';
import { useBehavior } from '@uniaria/vue';

defineProps<{ fruit: string }>();
const emit = defineEmits<{ pick: []; clear: [] }>();

const filter = useBehavior(button, { onPress: () => emit('pick') });
const clear = useBehavior(button, { onPress: () => emit('clear') });
</script>

<template>
  <div v-bind="filter.parts.frame">
    <button v-bind="filter.parts.root">
      <span v-bind="filter.parts.label">Fruit</span>
      <span v-bind="filter.parts.description">{{ fruit }}</span>
    </button>
    <span v-bind="filter.parts.extras">
      <button
        v-bind="clear.parts.root"
        data-variant="quiet"
        aria-label="Clear Fruit"
      >
        ×
      </button>
    </span>
  </div>
</template>

HTML

<uni-button id="fruit">
  <div data-uni-part="button-frame">
    <button data-uni="button">
      <span data-element="Label">Fruit</span>
      <span data-element="Description">Apple</span>
    </button>
    <span data-uni-part="button-extras">
      <uni-button id="clear-fruit">
        <button data-uni="button" data-variant="quiet" aria-label="Clear Fruit">
          ×
        </button>
      </uni-button>
    </span>
  </div>
</uni-button>

<script type="module">
  import '@uniaria/elements/button';

  document.querySelector('#fruit').addEventListener('press', (event) => {
    if (event.target.id === 'clear-fruit') clearFruit();
    else pickFruit();
  });
</script>

The frame goes inside the <uni-button> it belongs to, and each extra’s own element inside the extras. Press events bubble, so a listener on the outer element hears its extras’ presses too: event.target tells them apart.

Hover anywhere over the frame, over the button, an extra or a gap between them, is the button’s hover. The frame and the extras carry the button’s state attributes, so the row looks like one control in every state. A press on an extra is the extra’s alone and never presses the button. Give each extra a name that says what it acts on, such as “Clear Fruit”, and disable it with the button when it acts on what the button does: the extras show the button’s disabled state, but they are components of their own.

Anatomy

Part Element Notes
root button by default, a in link mode, any element through elementType You own the content, id, your classes, labelling (unless a label or description part takes it over), popup attributes (aria-haspopup, aria-expanded), form attributes and link attributes such as target.
label any, inside the root; usually span Optional. While rendered, it alone names the button: the root’s aria-labelledby is its id.
description any, inside the root; usually span Optional. While rendered, it describes the button: the root’s aria-describedby starts with its id, before a tooltip’s.
frame any, outside the root; usually div Optional. Around the root and the extras. It carries the root’s state attributes, and hover over it is the button’s. No role.
extras any, inside the frame; usually span Optional. Beside the root, never inside it: your own Buttons, badges and counts, in reading order. It carries the root’s state attributes. No role: each control in it is a component of its own, named on its own.

A root that isn’t a button, input or a gets role="button", a tabindex and keyboard handling. The custom element has no role of its own and never takes focus.

UniAria writes the label’s and the description’s id, unless they have one, and everything that refers to them follows it (see Ids). While the label is rendered, UniAria writes the root’s whole aria-labelledby, and an aria-label on the root is ignored. While the description is rendered, it writes the whole aria-describedby, with a linked tooltip’s id after the description’s. Otherwise your own attributes stay. An id goes into a reference only once its part is rendered: in React right after the first commit, in Vue right after mount, and on the custom element when it upgrades. Until then, such as in server HTML, the button is named by its whole content.

The frame and the extras sit outside the root, so they are marked with data-uni-part instead of data-element (see Styling).

Props

Prop Attribute Type Default Description
isDisabled disabled boolean false Native disabled on a button; aria-disabled="true" and no tab stop elsewhere. No press events fire.
isFocusableWhenDisabled focusable-when-disabled boolean false While disabled, keeps the root focusable with aria-disabled instead of disabled, so focus stays put. Presses and form submission stay blocked.
href href string — Link mode. Removed while disabled.
type type 'button' | 'submit' | 'reset' 'button' For a native button root. Deliberately not HTML’s submit default.
elementType — (read from the tag) string 'button' The tag you render as the root. React and Vue warn in development when it doesn’t match.
shouldExcludeFromTabOrder exclude-from-tab-order boolean false tabindex="-1": focusable by pointer and script, skipped by Tab.
shouldPreventFocusOnPress prevent-focus-on-press boolean false A pointer press leaves focus where it was, for buttons that act on something else, such as a toolbar over an editor.

Events

Event Callback prop Detail When
pressstart onPressStart PressEvent A press begins: pointer down, Enter or Space down, or a screen-reader click.
pressend onPressEnd PressEvent A press ends, whether or not it activates.
press onPress PressEvent The button activates: released over the root, or the key released on it.
focuschange onFocusChange boolean The root gained or lost focus.

On activation the order is pressstart, pressend, press. A cancelled press (the pointer leaves, a scroll takes over the touch) fires pressstart and pressend only. Exactly one press fires per activation, on every input.

interface PressEvent {
  type: 'pressstart' | 'pressend' | 'press';
  pointerType: 'mouse' | 'pen' | 'touch' | 'keyboard' | 'virtual';
  target: Element; // the root part
  shiftKey: boolean;
  ctrlKey: boolean;
  metaKey: boolean;
  altKey: boolean;
  key?: string; // keyboard presses only
  x: number; // relative to the root
  y: number;
}

virtual is a click without a real pointer: a screen reader’s, or a script’s element.click(). The custom element dispatches each event on itself, bubbling, with the press event in detail.

State

Field Type Meaning
isPressed boolean A pointer or key is down on the root, and releasing it now would activate.
isHovered boolean A mouse or pen is over the root, or anywhere over the frame while one is rendered. Never from touch, never while disabled.
isFocused boolean The root has focus.
isFocusVisible boolean The root has focus and the last interaction was a keyboard or a screen reader.
isDisabled boolean The isDisabled prop.

Most of the time the state attributes below are all you need. To render something from state in React or Vue, select it, and the component renders again only when that value changes (Vue gives a ref):

const isPressed = useBehaviorState(save, (state) => state.isPressed);

On the custom element, read element.instance.getState(). See State.

Styling

Attribute On When
data-uni="button" root Always.
data-element="Label", data-element="Description" label, description Always.
data-uni-part="button-frame", data-uni-part="button-extras" frame, extras Always.
data-hovered root, frame, extras A mouse or pen is over the root, or anywhere over the frame while one is rendered.
data-pressed root, frame, extras The button is pressed.
data-focused root, frame, extras The root has focus.
data-focus-visible root, frame, extras The root has focus from a keyboard or a screen reader.
data-disabled root, frame, extras The button is disabled.

State attributes are present or absent, never "false". The frame and the extras carry the button’s, so your CSS can paint the extras for any state of their button, or show them only while it is hovered or focused. They describe the button: data-focused on the extras means the button has focus, and :focus-within that one of the extras has. UniAria also sets touch-action: pan-x pan-y pinch-zoom on the root: it removes the double-tap delay on touch screens and lets a scroll cancel a press.

Default styles

The optional @uniaria/styles package styles the root and the other parts, as in the demo above, whose CSS tab holds only the page’s layout. Set data-variant on the root to primary, quiet or danger (for a destructive action); without it, the button is secondary. A quiet button takes the text color around it and mixes its hover and press fills from it, so it reads on any fill.

With a label part, the button is laid out in a grid: the element before the label, such as an icon, sits at its start, and the one after it, such as a shortcut hint, at its end. One element each: wrap several in one. The label and the description take one line each and end in an ellipsis.

In a frame, the button spans the frame and the extras sit over its end, so its fill, border and focus ring stay under them in every state. A framed button needs a label part, which keeps its content clear of the extras. A Button in the extras is 24 pixels high. Only what acts in the extras takes the pointer, such as buttons and links, so a count or a gap between extras presses the button. The frame takes its button’s text color, so extras drawn in currentColor match it. See Labels, frames and extras.

To show cut-off text in full, link a Tooltip with shouldOpenOnlyWhenTruncated (attribute open-only-when-truncated) and relation: 'none'. It opens only while the label or the description is cut off, shows their whole text in its text part, and stays out of what screen readers read, since the whole text is in the button already.

Keyboard

Key Behavior
Space Presses on keydown and activates on keyup. The page doesn’t scroll. Does nothing on a link, as natively.
Enter Presses on keydown and activates on keyup. On a link the browser navigates on keydown, natively.
Tab Moves focus normally. The root is a tab stop unless disabled (without isFocusableWhenDisabled) or excluded.

Holding a key down never presses again, and a keyup that lands elsewhere because focus moved ends the press without activating.

In a frame, Tab moves from the button to each control in its extras, in DOM order: each is a tab stop of its own. To take one out of the Tab order, set shouldExcludeFromTabOrder on it, and give keyboard users another way to do what it does.

Accessibility

  • Focus. Pressing never moves focus on its own: the action decides, as the APG describes. Opening a dialog moves focus into it; a plain action leaves it on the button. A pointer press focuses the button in every browser, Safari included, unless shouldPreventFocusOnPress is set.
  • Disabling while busy. Natively disabling a focused button drops focus to the page. While an action runs, use isDisabled with isFocusableWhenDisabled, so focus stays and screen readers say “disabled”.
  • Screen readers. VoiceOver, TalkBack, NVDA and JAWS click without a real pointer; UniAria recognizes those clicks and fires the press events with pointerType: 'virtual'.
  • Spoken output. “button, Save”; “button, Save, disabled” while disabled, focusable or not; “link, Docs” in link mode. UniAria never writes aria-disabled="false", so a re-enabled button isn’t announced as “not disabled”.
  • Label and description. With a label “Save”, a description “Saves the file” and a hint “⌘S” inside, the button reads “button, Save, Saves the file”: the hint stays out. Without the parts, the whole content is its name, “button, SaveSaves the file⌘S”.
  • Extras. Each extra is read on its own after the button, never as part of its name. A frame with a button “Fruit” and, in its extras, a “Clear Fruit” button and a count “3” reads “button, Fruit”, then “button, Clear Fruit”, then “3”. Name each extra in its context, “Clear Fruit” rather than a bare “Close” repeated on every row. Text that should describe the button, such as a badge, goes in its description part instead.
  • Tooltips. Give the button a Tooltip through its link, and the tooltip’s text becomes its description, after the description part’s. It opens on hover over the button itself, not over its extras, so an extra’s own tooltip doesn’t open it as well.

Compared with other libraries

  • Enter activates on keyup, as in React Aria. The APG’s example activates on keydown; if a keydown opened a dialog, the keyup would land on whatever the dialog focused.
  • type defaults to button, as in React Aria, not HTML’s submit.
  • Toggle buttons are a separate component, ToggleButton, so a button’s role never changes under the user.
  • No pending state. isDisabled with isFocusableWhenDisabled covers it without dropping focus; a spinner stays with your design system.
  • Label and description parts. React Aria names a Button by its whole content, and has label and description slots only in options and menu items.
  • Extras sit beside the button, in a frame it spans. React Aria has no controls beside a button; its GridList rows hold them as cells.