Icon Button
stableIconButton is a compact native button whose visible content is an icon rather than a text label. It is intended for familiar, frequently used actions where space is limited and the icon’s meaning is clear from established conventions or surrounding context.
Because the action is not visibly named, every IconButton requires an accessible label that describes what the control does.
Basic usage
Provide a familiar icon and a non-empty aria-label that describes the action.
---
import { IconButton } from "@ulubit/ui";
---
<IconButton aria-label="Close dialog">
<CloseIcon />
</IconButton>Use an ordinary Button when visible text would make the action clearer.
Accessible label
Every icon button requires a non-empty aria-label because its icon is hidden from assistive technologies.
The label must describe the action, not the icon’s appearance.
Prefer:
Copy linkClose dialogDelete projectOpen settings
Avoid:
Copy iconXTrash canButton
<IconButton aria-label="Copy link">
<CopyIcon />
</IconButton>The accessible name remains in place during loading. Announce progress and results separately rather than replacing aria-label with a loading message.
Variants
Choose a variant based on the action’s purpose and priority.
| Variant | Use for |
|---|---|
primary |
A high-emphasis icon action |
secondary |
A visible alternative or supporting action |
tertiary |
Most compact supporting actions |
danger |
A destructive icon action |
tertiary is the default because icon-only controls are usually supporting actions that should not compete with the page’s primary action.
Use a specific accessible label for danger actions, such as Delete project rather than Delete when the affected item is not otherwise clear.
IconButton uses the same variant and disabled color tokens as Button. See Button color tokens for the complete reference.
Sizes
Icon buttons support sm, md, and lg. Size controls both the square target and the icon dimensions.
| Size | Use for |
|---|---|
sm |
Compact interfaces with sufficient spacing between controls |
md |
Most icon-button uses |
lg |
Spacious interfaces or intentionally larger targets |
md is the default.
<IconButton aria-label="Edit item" size="sm">
<EditIcon />
</IconButton>
<IconButton aria-label="Edit item">
<EditIcon />
</IconButton>
<IconButton aria-label="Edit item" size="lg">
<EditIcon />
</IconButton>Disabled state
Use the native disabled attribute when the action is unavailable.
<IconButton aria-label="Delete project" variant="danger" disabled>
<TrashIcon />
</IconButton>A disabled icon button cannot be focused or activated. Provide nearby context when the reason it is unavailable would otherwise be unclear.
Loading state
IconButton includes a spinner that can be displayed while an asynchronous action is pending. The component provides the visual state, but it does not run the operation or control its lifecycle.
When the operation begins, the consuming script must:
- Set
aria-busy="true"to show the spinner. - Set
disabledto prevent repeated activation. - Update a separate status message to communicate progress.
When the operation finishes or fails, remove aria-busy and restore disabled. The spinner replaces the visible icon without changing the button’s dimensions. The required aria-label stays unchanged so the control retains its accessible name.
For example, to refresh some data through an Astro Action (assuming a refreshStatus Action is defined in your project):
---
import { IconButton } from "@ulubit/ui";
---
<IconButton aria-label="Refresh status" data-refresh-status-button>
<RefreshIcon />
</IconButton>
<p role="status" data-refresh-status-feedback></p>
<script>
import { actions } from "astro:actions";
const button = document.querySelector<HTMLButtonElement>(
"[data-refresh-status-button]"
);
const feedback = document.querySelector<HTMLElement>(
"[data-refresh-status-feedback]"
);
if (button && feedback) {
button.addEventListener("click", async () => {
if (button.disabled) return;
button.disabled = true;
button.setAttribute("aria-busy", "true");
feedback.textContent = "Refreshing status…";
try {
const { error } = await actions.refreshStatus();
if (error) {
feedback.textContent = "Could not refresh status.";
return;
}
feedback.textContent = "Status refreshed.";
} catch {
feedback.textContent = "Connection error. Try again.";
} finally {
button.removeAttribute("aria-busy");
button.disabled = false;
}
});
}
</script>The example uses an illustrative Action name. Define the Action and provide the icon in your project. The finally block restores the button whether the Action succeeds, returns an error, or throws unexpectedly.
For ordinary form submissions, prefer a text Button with type="submit". See Button loading state for the form submission pattern.
Accessibility: The spinner is decorative. aria-busy is not a replacement for a status announcement, so use a persistent status region such as role="status" for progress and results. Disabling a focused button can also affect keyboard focus; verify that behavior in the consuming application.
Tooltips
aria-label provides an accessible name, but it does not create visible help text.
Use a text Button when the icon is not clear enough on its own. Do not add a title attribute as a substitute for a well-designed visible label or a proper Tooltip component.
Boundaries
IconButton performs an action. It does not provide:
- navigation behavior
- toggle or pressed-state behavior
- automatic tooltips
- asynchronous operation management
Use a native <a> for icon-only navigation. Use ButtonLink when the navigation control has a visible text label.
Introduce a dedicated toggle-button pattern only when an action requires a persistent pressed state.
Accessibility
IconButton renders a native <button> and retains its built-in keyboard and focus behavior. The required aria-label supplies its accessible name, while the decorative icon is hidden from assistive technologies.
The loading spinner is also decorative. The required aria-label remains on the button in every state. The shared loading styles make the icon visually transparent while preserving its dimensions. Announce progress and results through a separate status region.
API
The component accepts valid native <button> attributes except aria-labelledby. aria-label is required through the component API, and aria-busy is forwarded as a native attribute that also controls the visual spinner.
| Property | Type | Default | Description |
|---|---|---|---|
aria-label |
string |
Required | Provides the button’s accessible name. |
variant |
"primary" | "secondary" | "tertiary" | "danger" |
"tertiary" |
Defines visual hierarchy and intent. |
size |
"sm" | "md" | "lg" |
"md" |
Defines the square target and icon dimensions. |
aria-busy |
boolean or "true" | "false" (native attribute) |
— | Displays the spinner when set to "true"; does not disable the button automatically. |
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 |
Required | Provides the decorative icon. |
Native attributes such as name, value, form, aria-describedby, aria-controls, and data-* are forwarded to the underlying <button>.