Skip to content

Button

A native <button> with three variants, three sizes, and one piece of behaviour: while loading, a press does nothing.

Presses: 0

Props
<script setup lang="ts">
import { Button } from '@caioalfonso/kanso-vue';
import { ref } from 'vue';
const saving = ref(false);
async function save() {
saving.value = true;
await new Promise((resolve) => setTimeout(resolve, 1200));
saving.value = false;
}
</script>
<template>
<Button :loading="saving" @click="save">Save changes</Button>
</template>

Turn loading on and press it. The counter stops, the button keeps its focus ring, and it still announces as “Save changes”.

import { Button } from '@caioalfonso/kanso-react/button';
import '@caioalfonso/kanso-styles/tokens';
import '@caioalfonso/kanso-styles/base';
import '@caioalfonso/kanso-styles/button';

The stylesheet is optional. See Installation for package setup, the Vue entry points, and why tokens and base come along.

PartElementNotes
root<button>Carries data-variant, data-size, data-loading.
label<span>Wraps the children so the spinner can fade them without deleting the name.

The list above comes from buttonAnatomy: {buttonAnatomy.join(', ')}.

PropTypeDefaultNotes
variant'solid' | 'outline' | 'ghost''solid'
size'sm' | 'md' | 'lg''md'Type and padding. Never the target size.
loadingbooleanfalsearia-busy, focusable, activation blocked.
disabledbooleanfalseThe native attribute.
type'button' | 'submit' | 'reset''button'

Everything else goes straight to the <button>.

None of its own, deliberately. Enter and Space activate a native <button>, and reaching for role="button" on a <div> to hand-roll them is the classic way to end up with a control that works for the mouse and not the keyboard.

While loading, both keys are blocked along with the pointer — the guard is on activation, not on the pointer.

The two look interchangeable and are not:

  • disabled removes the button from the tab order. A keyboard user who pressed it a moment ago has just lost their place, and nothing announced why.
  • loading keeps it focusable, sets aria-busy="true", and blocks activation — including form submission for a type="submit" button.

The block lives in core, in the composed click handler. That is deliberate: your onClick is passed into connectButton rather than left in the props spread, because core’s props are applied last and would otherwise replace it. A button whose handler silently never fires renders perfectly and passes an axe scan.

aria-disabled is not set while loading. “Busy” is a truer description of a button working on your last press than “unavailable”.

The obvious way to swap a label for a spinner is visibility: hidden or display: none. Both remove the element from the accessibility tree and take the button’s accessible name with them.

So the children are wrapped in a label part and faded with opacity, and the spinner is a pseudo-element on the button:

[data-scope='button'] [data-part='label'][data-loading] {
opacity: 0;
}

If you write your own loading treatment, keep that rule. Under prefers-reduced-motion: reduce the spin is cancelled and the ring stays static, which is still a visible “not idle”.

Every size keeps a 44 px minimum height. sm is narrower and lighter, not shorter.

That is stricter than WCAG 2.2 SC 2.5.8 (24 px) and matches SC 2.5.5 (AAA). The alternative — a genuinely smaller box with the hit area extended by a transparent pseudo-element — is a real technique, and it is rejected here because it makes the target invisible both to someone reading the CSS and to a test that measures the box.

If your design needs a denser control than 44 px, that is a decision to make deliberately in your own stylesheet, not one to inherit by accident.

  • Always a native <button>, so Enter and Space come free.
  • type="button" by default, so a button inside a form never submits by accident.
  • Focus is the browser’s own ring via :focus-visible.
  • Disabled buttons are rendered at reduced opacity. WCAG exempts inactive controls from the contrast minimum — this is the one place in the library where a colour pair is deliberately not measured.