Button
A pressable control that runs an action, in eight variants and four sizes, each with a square icon form.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 480px; height: 96px; background: Tokens.color-background;
HorizontalLayout { alignment: center; spacing: 8px; padding: 16px;
Button { text: "Save changes"; } Button { text: "Cancel"; variant: ButtonVariant.outline; } Button { text: "Delete"; variant: ButtonVariant.destructive; } }}Usage
import { Button, ButtonSize, ButtonVariant } from "@glint/components/button.slint";
Button { text: "Delete project"; variant: ButtonVariant.destructive; size: ButtonSize.sm; disabled: root.is-saving; clicked => { root.delete-project(); }}That is a fragment to drop into a component of your own: is-saving and
delete-project are yours, not Glint’s.
A Button sizes itself to its label. Put it in a layout that stretches and it
fills the space it is given; put it in one that does not and it stays at its
content width. clicked fires on release, and only while the button is
enabled and not loading — the guard is in the component, so a handler never
has to repeat it.
Examples
Variants
Eight of them, chosen by meaning rather than by color: default for the one
action a screen is about, outline and secondary for the ones beside it,
ghost and link for actions that should not draw the eye, destructive for
what cannot be undone, and glow and glass for surfaces that call for them.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 480px; height: 160px; background: Tokens.color-background;
VerticalLayout { alignment: center; spacing: 12px; padding: 16px;
HorizontalLayout { alignment: center; spacing: 8px;
Button { text: "Default"; } Button { text: "Outline"; variant: ButtonVariant.outline; } Button { text: "Secondary"; variant: ButtonVariant.secondary; } Button { text: "Ghost"; variant: ButtonVariant.ghost; } }
HorizontalLayout { alignment: center; spacing: 8px;
Button { text: "Link"; variant: ButtonVariant.link; } Button { text: "Destructive"; variant: ButtonVariant.destructive; } Button { text: "Glow"; variant: ButtonVariant.glow; } Button { text: "Glass"; variant: ButtonVariant.glass; } } }}Sizes
xs, sm, default and lg set the height and the horizontal padding; the
label follows the reader’s font size, because Glint’s typography tokens are
rem-based.
import { Button, ButtonSize } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 480px; height: 96px; background: Tokens.color-background;
HorizontalLayout { alignment: center; spacing: 8px; padding: 16px;
Button { text: "Extra small"; size: ButtonSize.xs; } Button { text: "Small"; size: ButtonSize.sm; } Button { text: "Default"; } Button { text: "Large"; size: ButtonSize.lg; } }}Welded into a stack
join says where a button sits in a welded column: first, middle and
last square the corners that face a neighbor, and none — the default —
leaves all four rounded. The seam belongs to ButtonGroupColumn, which
overlaps its children by the hairline they share; the corners belong to each
button, because Slint gives a parent no way to reach into its children.
import { Button, ButtonJoin, ButtonVariant } from "@glint/components/button.slint";import { ButtonGroupColumn } from "@glint/components/button-group.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 480px; height: 176px; background: Tokens.color-background;
HorizontalLayout { alignment: center; padding: 16px;
ButtonGroupColumn { accessible-label: "Zoom";
Button { text: "Zoom in"; variant: ButtonVariant.outline; join: ButtonJoin.first; } Button { text: "Reset"; variant: ButtonVariant.outline; join: ButtonJoin.middle; } Button { text: "Zoom out"; variant: ButtonVariant.outline; join: ButtonJoin.last; } } }}Icon buttons
The icon, icon-xs, icon-sm and icon-lg sizes keep a square minimum and
take the icon as a child. A button with no label has no accessible name, so
name it: accessible-label is set from the call site, and the role, the
enabled state and the guarded default action still come from Button itself.
import { Button, ButtonSize, ButtonVariant } from "@glint/components/button.slint";import { Icon } from "@glint/components/icon.slint";import { Tokens } from "@glint/theme/tokens.slint";import { IconSet } from "@lucide";
export component Demo inherits Window { width: 480px; height: 96px; background: Tokens.color-background;
HorizontalLayout { alignment: center; spacing: 8px; padding: 16px;
Button { size: ButtonSize.icon; accessible-label: "Add item";
Icon { icon: IconSet.Plus; tint: Tokens.color-primary-foreground; } }
Button { size: ButtonSize.icon; variant: ButtonVariant.outline; accessible-label: "Copy to clipboard";
Icon { icon: IconSet.Copy; } }
Button { size: ButtonSize.icon-sm; variant: ButtonVariant.ghost; accessible-label: "Open settings";
Icon { icon: IconSet.Settings; size: 14px; } } }}Loading
A loading button keeps its label and gains a leading spinner, and every activation path goes inert until the work finishes. It gets wider by the width of the spinner: a button that must not reflow should be given a fixed width.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 480px; height: 96px; background: Tokens.color-background;
in-out property <bool> saving: false;
HorizontalLayout { alignment: center; spacing: 8px; padding: 16px;
Button { text: "Save changes"; loading: root.saving; clicked => { root.saving = true; } }
Button { text: "Reset"; variant: ButtonVariant.outline; clicked => { root.saving = false; } } }}Disabled
A disabled button dims, stops firing clicked and leaves the tab order, while
staying in the accessibility tree so it can still be found and read.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 480px; height: 96px; background: Tokens.color-background;
HorizontalLayout { alignment: center; spacing: 8px; padding: 16px;
Button { text: "Publish"; disabled: true; } Button { text: "Discard"; variant: ButtonVariant.outline; disabled: true; } }}Filling the width it is given
Inside a stretching layout a Button widens to the space available — Glint
sizes it through min-width and preferred-width rather than a fixed
width, so no wrapper is needed for a full-width action.
import { Button, ButtonVariant } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 320px; height: 128px; background: Tokens.color-background;
VerticalLayout { alignment: center; spacing: 8px; padding: 16px;
Button { text: "Continue"; } Button { text: "Back"; variant: ButtonVariant.ghost; } }}Handling a click
clicked is the whole interface between the button and the application: the
component decides when a press counts, and the handler decides what it does.
import { Button } from "@glint/components/button.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 480px; height: 96px; background: Tokens.color-background;
in-out property <int> count: 0;
HorizontalLayout { alignment: center; spacing: 8px; padding: 16px;
Button { text: root.count == 0 ? "Not clicked yet" : "Clicked " + root.count + "×"; clicked => { root.count += 1; } } }}API Reference
Properties
| Property | Type | Default | Description |
|---|---|---|---|
variant | in ButtonVariant | ButtonVariant.default | Visual style — see ButtonVariant. |
size | in ButtonSize | ButtonSize.default | Height + horizontal padding preset — see ButtonSize, whose icon-* members are the square, label-less form of each height. |
text | in string | no default | Label rendered inside the button. |
leading-icon | in LucideIcon | no default | Glyph before the label, normally selected from IconSet in lucide-slint. A loading button shows its spinner in this place instead. |
trailing-icon | in LucideIcon | no default | Glyph after the label, normally selected from IconSet in lucide-slint. |
join | in ButtonJoin | ButtonJoin.none | Where this button sits in a welded ButtonGroup — see ButtonJoin. |
disabled | in bool | false | When true, the button dims and stops firing clicked. |
loading | in bool | false | When true, indicates busy/loading state and prevents interaction. |
loading-label | in string | @tr("Loading") | Name of the spinner a loading button shows. Overridable because the button owns that element and a consumer cannot reach it (same reason AttachmentRow exposes remove-label). |
haspopup | in bool | false | When true, this button opens a popup/menu; like vega's aria-haspopup it opts out of the 1px press dip. |
focus-visible | out bool | no default | Whether this control holds the keyboard *and* got it from the keyboard — the focus-visible a hover surface opens on. Published because Slint reports focus only to the element holding it, so a Tooltip wrapping this control cannot read it off the scope inside (tooltip.slint). |
focus-held | out bool | no default | The same focus, still true while a popup has borrowed the window's — what a hover surface opened by this control has to gate on, since showing itself is what takes focus-visible away. See Tooltip. |
focused | out bool | no default | Whether this control holds the keyboard at all, however it arrived. DialogPanel keys its forward Tab wrap on the close control being the last stop in the cycle, and an X focused by a click is still that stop — which is the one question focus-visible cannot answer. |
Callbacks
| Callback | Description |
|---|---|
clicked() | Fired on press release while enabled. |
Functions
| Function | Description |
|---|---|
focus-from-keyboard() | Hand the control the keyboard the way a key press does, ring and all. A host that moves the focus onto a control because the user pressed something — Questionnaire stepping to the next question — cannot use focus(): Slint reports that as programmatic, which is how a host parking the keyboard looks, and the scope drops the ring for it. Published by every control that publishes focus-visible, for the same reason: the scope inside cannot be reached from outside the component. |
Enums
| Enum | Values |
|---|---|
ButtonVariant | default, outline, secondary, ghost, link, destructive, glow, glass |
ButtonSize | xs, sm, default, lg, icon, icon-xs, icon-sm, icon-lg |
ButtonJoin | none, first, middle, last |
Accessibility
- Role and name. A
Buttonexposes itself as a button whose accessible name is itstext. An icon-only button has no text to take a name from, so setaccessible-labelat the call site. - Keyboard. Tab reaches the button; Enter and Space
activate it. The focus ring is keyboard-only: a press with the pointer
leaves no ring behind, the way
:focus-visiblebehaves on the web. - Assistive technology. Screen readers activate the button through its accessible default action, which is guarded by the same rule as the pointer and keyboard paths — a disabled or loading button cannot be fired through any of the three.
- Disabled. The button reports itself disabled and leaves the tab order, but stays in the accessibility tree: a control a user cannot find is worse than one they find and are told is unavailable.
- Loading. Busy is not the same as unavailable, and the accessibility tree
can only say the latter. A loading button therefore reports itself disabled
and carries
loading-labelas its description, so a screen reader says the state with the button’s own name instead of leaving it to whoever explores the spinner underneath. Overrideloading-labelto say what is happening in your own words. - The label is not a second element. The text inside the button is silent in the accessibility tree — the button already announces it — so the name resolves to exactly one control.
- Popup triggers. A button that opens a menu or a popover should set
haspopup, which also drops the 1px press dip that would otherwise fight the surface opening under it.