Button
stableButton is the default control for actions that affect the current interface,
such as saving changes, opening a dialog, submitting a form, or deleting an
item.
Use ButtonLink when the interaction navigates to another page or URL. Use IconButton when a familiar action must be represented by an icon without a visible text label.
primary secondary tertiary dangersm md lgBasic usage
Import Button from @ulubit/ui and provide a visible action label.
---
import { Button } from "@ulubit/ui";
---
<Button>Open settings</Button>Variants
Choose a variant based on the action’s purpose and priority.
| Variant | Use for | Example |
|---|---|---|
primary |
The main action in a form, dialog, or section | Save changes |
secondary |
An alternative to the primary action | Cancel |
tertiary |
A lower-priority supporting action | Clear filters |
danger |
A destructive or difficult-to-reverse action | Delete project |
primary is the default. Avoid placing several primary buttons in the same
action group when one action should have clear priority.
Use specific danger labels. Prefer Delete project over Delete, and consider confirmation when the result is irreversible or significant.
<div class="actions">
<Button type="submit">Save changes</Button>
<Button variant="secondary">Cancel</Button>
</div>Sizes
Size controls the button’s dimensions, not its importance.
| Size | Use for |
|---|---|
sm |
Compact interfaces such as table rows or toolbars |
md |
Most page and form actions |
lg |
Spacious layouts or intentionally larger targets |
md is the default.
<Button size="sm">Edit</Button>
<Button>Continue</Button>
<Button size="lg">Create account</Button>Icons
Most buttons do not need an icon. Add one only when it makes the action easier to recognize or reinforces an established meaning.
Use the start and end slots for decorative icons that support the visible
label.
<Button>
<PlusIcon slot="start" />
Add member
</Button>
<Button variant="secondary">
Continue
<ArrowRightIcon slot="end" />
</Button>The component hides slotted icons from assistive technologies. The visible label must still describe the action without relying on the icon.
Full width
Buttons are sized to their content by default. Use fullWidth when a button
should fill the available inline space of its container, such as in a narrow
form or stacked mobile action group.
<div class="form-actions">
<Button type="submit" fullWidth>
Save changes
</Button>
</div>The parent layout remains responsible for constraining the available width.
Button type
The component defaults to type="button" to avoid submitting a form
accidentally.
Set type="submit" explicitly for form submission:
<form>
<Button type="submit">Save changes</Button>
</form>Use type="reset" only when resetting the entire form is intentional and
clearly communicated.
Disabled state
Use the native disabled attribute when an action is unavailable.
<Button disabled>Save changes</Button>A disabled button cannot be focused or activated. Avoid disabling an action without showing what the user must do to enable it.
Loading state
Button includes a spinner for actions that take time, such as submitting a form
or uploading a file. The component renders the spinner markup but does not
manage the operation itself.
The consuming application controls the loading state using native attributes:
- Set
aria-busy="true"to show the spinner while keeping the button’s dimensions. - Set
disabledto prevent repeat activation during the operation. - Clear
aria-busyand restoredisabledwhen the operation finishes, whether it succeeds or fails. - Use a separate status region to announce progress and the result. The spinner is decorative and does not announce either.
The button’s original accessible name remains available during loading, even
though its label is visually transparent. aria-busy does not disable a button
by itself, and disabled does not display the spinner by itself.
Submit a form with an Astro Action
This example uses an existing sendMessage Astro Action configured with
accept: "form". The page must support on-demand rendering for the native
HTML form submission fallback.
---
export const prerender = false;
import { actions } from "astro:actions";
import { Button } from "@ulubit/ui";
const result = Astro.getActionResult(actions.sendMessage);
---
<form method="POST" action={actions.sendMessage} data-contact-form>
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<Button type="submit">Send message</Button>
<p role="status" data-form-status>
{result?.error
? "Message could not be sent."
: result?.data
? "Message sent."
: ""}
</p>
</form>
<script>
import { actions } from "astro:actions";
const form = document.querySelector<HTMLFormElement>("[data-contact-form]");
const button = form?.querySelector<HTMLButtonElement>('button[type="submit"]');
const status = form?.querySelector<HTMLElement>("[data-form-status]");
if (form && button && status) {
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (button.disabled) return;
const formData = new FormData(form);
button.disabled = true;
button.setAttribute("aria-busy", "true");
status.textContent = "Sending message…";
try {
const { error } = await actions.sendMessage(formData);
if (error) {
status.textContent = "Message could not be sent.";
return;
}
form.reset();
status.textContent = "Message sent.";
} catch {
status.textContent = "Connection error. Please try again.";
} finally {
button.removeAttribute("aria-busy");
button.disabled = false;
}
});
}
</script>The browser performs native field validation before firing the submit event.
The script captures the form data before disabling the button. Astro Action
validation errors are returned in error; unexpected failures are handled in
catch. The finally block always restores the button.
Without JavaScript, the form submits natively and the result is rendered by
Astro.getActionResult(). This fallback requires an on-demand rendered page
and a configured server adapter.
Disabling a focused button can affect keyboard focus. Test the submission flow with a keyboard and screen reader, and keep important feedback in the status region rather than relying on the spinner alone.
Labels
Use a short label that describes the action.
Prefer:
- Save changes
- Add member
- Delete account
- Clear filters
Avoid vague labels such as:
- OK
- Yes
- Click here
- Submit
Use sentence case unless a proper name requires different capitalization.
Accessibility
Button renders a native <button> and retains its built-in keyboard, focus,
and form behavior.
The component provides hover, active, focus-visible, disabled, and loading
styles across all variants. Loading is controlled by the consuming application
with aria-busy and disabled; it is not automatic.
Color tokens
Button variants use component-level color tokens from the UluBit color system. Each variant defines its background, interaction states, border, and text color independently.
The tokens below show the default values provided by @ulubit/foundations. Copy the relevant group to override a variant.
Primary
--color-button-primary-bg: var(--color-brand-600);
--color-button-primary-bg-hover: var(--color-brand-700);
--color-button-primary-bg-active: var(--color-brand-800);
--color-button-primary-border: transparent;
--color-button-primary-text: var(--color-neutral-50);Secondary
--color-button-secondary-bg: transparent;
--color-button-secondary-bg-hover: color-mix(in oklch, var(--color-text) 8%, transparent);
--color-button-secondary-bg-active: color-mix(in oklch, var(--color-text) 14%, transparent);
--color-button-secondary-border: var(--color-border);
--color-button-secondary-text: var(--color-text);Tertiary
--color-button-tertiary-bg: transparent;
--color-button-tertiary-bg-hover: color-mix(in oklch, var(--color-button-tertiary-text) 10%, transparent);
--color-button-tertiary-bg-active: color-mix(in oklch, var(--color-button-tertiary-text) 16%, transparent);
--color-button-tertiary-border: transparent;
--color-button-tertiary-text: light-dark(var(--color-brand-800), var(--color-brand-400));Danger
--color-button-danger-bg: var(--color-red-700);
--color-button-danger-bg-hover: var(--color-red-800);
--color-button-danger-bg-active: var(--color-red-900);
--color-button-danger-border: transparent;
--color-button-danger-text: var(--color-neutral-50);Disabled
Disabled colors are shared across variants where applicable. Primary and danger use the disabled background, secondary uses the disabled border, and all variants use the disabled text color. Tertiary keeps its background and border transparent when disabled.
--color-button-disabled-bg: light-dark(var(--color-neutral-200), var(--color-neutral-800));
--color-button-disabled-border: light-dark(var(--color-neutral-300), var(--color-neutral-700));
--color-button-disabled-text: light-dark(var(--color-neutral-500), var(--color-neutral-400));Focus
The focus indicator uses the shared semantic focus color rather than a variant-specific Button token.
--color-focus: var(--color-neutral-500);All Button variants use --color-focus for their :focus-visible outline.
Override the semantic token only when the project requires a different shared focus color. The resulting indicator must remain clearly visible against the colors immediately surrounding the control.
Overriding color tokens
Override only the tokens that need to change. Set them globally to change every button using that variant, or scope them to a class or parent element for a local customization.
For example, to customize a primary button:
.custom-button {
--color-button-primary-bg: var(--color-brand-700);
--color-button-primary-bg-hover: var(--color-brand-800);
--color-button-primary-bg-active: var(--color-brand-800);
--color-button-primary-text: var(--color-neutral-50);
}<Button class="custom-button">
Custom button
</Button>Tokens that are not overridden continue to use their defaults.
API
The component accepts valid native <button> attributes, including
aria-busy. The consuming application controls asynchronous state.
| Property | Type | Default | Description |
|---|---|---|---|
variant |
"primary" | "secondary" | "tertiary" | "danger" |
"primary" |
Defines the action’s visual hierarchy and intent. |
size |
"sm" | "md" | "lg" |
"md" |
Defines the button’s physical dimensions. |
fullWidth |
boolean |
false |
Makes the button fill the available inline space of its container. |
type |
"button" | "submit" | "reset" |
"button" |
Defines native button behavior. |
disabled |
boolean |
false |
Prevents interaction with the button. |
class |
string |
— | Adds a class alongside the component’s internal classes. |
| Default slot | AstroComponentFactory |
— | Provides the visible label shown while idle. |
start slot |
AstroComponentFactory |
— | Adds a decorative icon before the idle label. |
end slot |
AstroComponentFactory |
— | Adds a decorative icon after the idle label. |
Native attributes such as name, value, form, aria-describedby, aria-label,
aria-labelledby, aria-busy, and data-* are forwarded to the underlying
<button> element. aria-busy="true" shows the loading spinner; set disabled
separately when repeat activation must be prevented.