@k2b/ui
Buttons
Semantic button and icon-button variants with loading behavior.
Package-native buttons share one variant and size contract. Input actions align with adjacent field controls, immediate actions press with a subtle flat scale without layout movement, split-button menu triggers stay still, Button defaults to primary, IconButton defaults to ghost, and text actions stay surface-free and flush with nearby copy.
TSX
Copy<Button variant="primary">Save</Button><Button variant="secondary">Preview</Button><Button variant="ghost">Later</Button><Button variant="text" size="xs">More</Button><Button variant="warning" size="xs">Undo send · 8s</Button><Button variant="danger" size="sm">Delete</Button><Button variant="subtle" size="xs"> <i class="ti ti-activity" aria-hidden="true" /> Status</Button><Button loading loadingLabel="Saving">Save</Button><Button disabled>Disabled</Button> {/* IconButton defaults to variant="ghost" */}<IconButton label="Settings"> <i class="ti ti-settings" /></IconButton><IconButton label="Publish" variant="primary"><i class="ti ti-rocket" /></IconButton><IconButton label="Refresh" variant="secondary"><i class="ti ti-refresh" /></IconButton><IconButton label="Add filter" variant="input"><i class="ti ti-plus" /></IconButton><IconButton label="Delete" variant="danger"> <i class="ti ti-trash" /></IconButton><IconButton label="Saving" loading loadingLabel="Saving"><i class="ti ti-device-floppy" /></IconButton> {/* Navigational counterparts render anchors */}<ButtonLink href="/settings" variant="secondary">Settings</ButtonLink><IconButtonLink href="/settings" label="Open settings"> <i class="ti ti-external-link" /></IconButtonLink> <SplitButton onClick={send} menuLabel="More send options" items={[ { label: "Save as draft", icon: "ti ti-device-floppy", action: saveDraft }, { label: "Send later", icon: "ti ti-clock", action: scheduleSend }, ]}> <i class="ti ti-send" aria-hidden="true" /> Send</SplitButton>Button, ButtonLink, IconButton, IconButtonLink, and SplitButton provide one semantic action hierarchy with consistent focus, sizing, and variants.
Use Buttons
Use primary for the main forward action, secondary for supporting actions, ghost for quiet toolbar actions, text for inline disclosures without a surface or horizontal inset, subtle for compact contextual actions, input for actions placed directly beside a field, warning for time-sensitive or cautionary actions, danger for destructive work, and success only when success is the action's meaning.
Import
import { Button, ButtonLink, IconButton, IconButtonLink, SplitButton } from "@k2b/ui";Example
<Button variant="primary" loading={saving()} loadingLabel="Saving">
Save
</Button>
<Button variant="subtle" size="xs">
<i class="ti ti-activity" aria-hidden="true" /> Status
</Button>
<Button variant="warning" size="xs">Undo send · 8s</Button>
<Button variant="text" size="xs">More</Button>
<IconButton label="Project settings">
<i class="ti ti-settings" aria-hidden="true" />
</IconButton>
<IconButton label="Add filter" variant="input">
<i class="ti ti-plus" aria-hidden="true" />
</IconButton>
<ButtonLink href="/settings" variant="secondary">Settings</ButtonLink>
<IconButtonLink href="/settings" label="Open settings">
<i class="ti ti-external-link" aria-hidden="true" />
</IconButtonLink>
<SplitButton
onClick={send}
menuLabel="More send options"
items={[
{ label: "Save as draft", icon: "ti ti-device-floppy", action: saveDraft },
{ label: "Send later", icon: "ti ti-clock", action: scheduleSend },
]}
>
<i class="ti ti-send" aria-hidden="true" /> Send
</SplitButton>size accepts xs, sm, md, or lg. Button defaults to primary; IconButton defaults to ghost.
Loading
loading disables the action and keeps the button exactly the size it had: its
label and icons stay in place but invisible, and a spinner sits centered over
them. This holds for every variant and size, for IconButton, CopyButton, and
the main action of a SplitButton, so neighbouring buttons and wrapped rows do
not move when an action starts or ends. With reduced motion, the spinner stands
still.
Pass the idle content as children and leave it unchanged while loading is
set: no spinner of your own, no hidden icon, no "Saving" text. The invisible
children set the busy width, so swapping them still moves the button, and the
button already draws the spinner.
loadingLabel is not shown. It becomes the button's accessible name while it
is busy, also over an aria-label you passed, so it can describe the running
work precisely ("Saving the invoice") without changing the width. Without
loadingLabel, the button keeps its label or aria-label as its name. A busy
button is marked aria-busy="true" and disabled. The new name is not
announced, so report progress that needs attention and the result, such as a
saved state or an error, where the page reports results, for example with a
toast.
Disabled
disabled removes the button from the tab order and hides its tooltip. When a
control that is unavailable right now must stay reachable, for example a
toolbar command whose tooltip says what it does, set aria-disabled="true"
instead. Buttons and links with aria-disabled="true" look the same as
disabled ones, with no hover or press feedback. They keep focus and the tooltip.
The click handler still runs, so it must ignore the press or explain why the
action is unavailable.
<IconButton label="Indent" aria-disabled={canIndent() ? undefined : "true"} onClick={() => canIndent() && indent()}>
<i class="ti ti-indent-increase" aria-hidden="true" />
</IconButton>The input variant uses the same height, radius, muted surface, hover border, and inset focus treatment as form fields. Use it for a separate action immediately beside an input, not for actions embedded inside the field shell.
Button labels stay on one line by default. Let the surrounding toolbar wrap whole
actions when space is limited. Set wrap on Button or ButtonLink for
deliberately long or rich content that should wrap within the available width.
This also applies to buttons composed through SplitButton.
Button content is centered by default. Set align="start" on Button or
ButtonLink for a full-width row, such as a result or selection list. The row
content then starts at the leading edge, and the label fills the button. A
min-w-0 flex-1 text block pushes trailing content, such as an action icon or
a tag, to the end. With truncate, long text ends in an ellipsis before it can
push that trailing content out. Button styles always win over utility classes,
so justify-start on a button has no effect; use align instead. SplitButton
does not take align.
<Button variant="ghost" align="start" class="w-full" onClick={select}>
<Avatar name="Maria Kolb" size="sm" />
<span class="min-w-0 flex-1">
<span class="block truncate">Maria Kolb</span>
<span class="block truncate text-xs">maria@example.com</span>
</span>
<i class="ti ti-plus" aria-hidden="true" />
</Button>Hover preserves each variant's color hierarchy. Pressing an immediate action adds a subtle inward scale without adding shadow depth or changing layout; a split button's main action moves the compound control, while its menu trigger opens without scaling. Reduced-motion preferences keep the color feedback without the scale.
Use SplitButton when one immediate action has closely related alternatives. The main segment remains a native button; the icon-only segment opens the existing DropdownItem menu contract. variant, size, disabled, and loading apply to both segments. menuLabel is required as the secondary trigger's accessible name.
Links use normal document navigation by default. When the link renders in browser code, opt into the shared navigation contract explicitly:
<ButtonLink href="/items" navigation="enhanced" scroll="preserve" onNavigate={refreshWorkspace}>
More items
</ButtonLink>Enhanced button links require onNavigate; without it they safely retain
native document navigation.
Tooltip and split-button options
All button variants accept tooltip?: JSX.Element | false,
tooltipDelay?: number (250ms), and tooltipPlacement?: "top" | "bottom" | "left" | "right"
(default top). Use false to omit a tooltip. IconButton and
IconButtonLink show their label as the tooltip by default; pass tooltip
for a shorter or richer hint, such as a keyboard shortcut. Content follows the
Tooltip contract.
SplitButton extends Button props with required items: readonly DropdownItem[]
and menuLabel: string. menuPosition?: DropdownPosition | (() => DropdownPosition)
and menuWidth?: string configure the menu; position defaults to "bottom-left", and the menu sizes to its
entries unless menuWidth sets an exact CSS length. See
menu types. Link buttons' onNavigate
uses the navigation event contract.
Accessibility
All matching native button or anchor attributes pass through. The default button type is button, so form submission stays explicit. IconButton and IconButtonLink require label; SplitButton requires menuLabel. These labels supply the icon-only control's accessible name and tooltip. Render icon-only actions through these components, not through Button or ButtonLink with an aria-label, so every one shows its label.
Touch targets
On a device with a coarse pointer, such as a phone or tablet, every button, the
dialog close control, and a toast's action and close button accept taps in an
invisible area at least 44 px (2.75rem) tall and wide, centered on the control.
The visible size stays the same, so xs, sm, and icon-only buttons keep their
compact look. Tap areas end at the edge of a clipping container
(overflow: hidden or a scroll area).
A tap area never reaches back over the control before it. A button that
follows anything in its row, such as another button or a field, does not extend
backwards. The same applies when the button sits in up to two wrappers that
follow other content: an island from @k2b/ssr, a Dropdown, a
Tooltip.Anchor, or a Toolbar.Group. For example, header actions that each
mount as their own island keep each other's edges. The gap before the button
belongs to the control before it.
Above and below, the tap area reaches 8 px past an sm button and 10 px past
an xs button. A tap area that only touches the control above still takes a
one-pixel strip from its edge, so lines need a little more than that reach. On
touch devices, @k2b/ui's own button rows that wrap or stack keep 0.625rem
between lines: DetailPanel primary actions (also below the header's identity
row), PdfPreview actions, settings group and collection actions, image input
actions, wrapping or vertical toolbars, and the chat composer's footer below its
message field. DetailPanel.Action rows sit
flush, so on touch devices each row grows to 44 px itself and its tap area
keeps the row height. Menu items and select options follow the same rule; see
menus.
In your own layouts, keep at least 0.625rem (0.75rem next to xs buttons)
between lines of compact buttons that wrap or stack, before a field or link
that follows a compact button, and before a button that sits in your own
wrapper element after other content. Where you can, place a row's islands
directly in the row instead of wrapping each one in its own element. Do not
set position: static on a button: the tap area is positioned against the
button itself.
Runtime
Buttons render complete server HTML. Click and reactive loading behavior require browser code only when their state or handlers are client-owned.
For a selection with related actions (for example, an open file and file actions),
provide primaryItems and primaryMenuLabel to make the primary segment a
dropdown instead of an immediate action. menuIcon optionally replaces the
secondary chevron. Both menus use the same DropdownItem contract.