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.
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>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.
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.
| 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).
| 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. |
| 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.
| 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.
| 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.
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.
| 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.
- 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
shouldPreventFocusOnPressis set. - Disabling while busy. Natively disabling a focused button drops focus to
the page. While an action runs, use
isDisabledwithisFocusableWhenDisabled, 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.
- 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.
typedefaults tobutton, as in React Aria, not HTML’ssubmit.- Toggle buttons are a separate component, ToggleButton, so a button’s role never changes under the user.
- No pending state.
isDisabledwithisFocusableWhenDisabledcovers 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
labelanddescriptionslots 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.