Cloud|

@k2b/ui

Buttons

Semantic button and icon-button variants with loading behavior.

Button@k2b/uiButtonLink@k2b/uiIconButton@k2b/uiIconButtonLink@k2b/uiSplitButton@k2b/ui

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.

SettingsPublishRefreshAdd filterDeleteSavingButton linksOpen button linksMore send optionsFile actions

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

tsx
import { Button, ButtonLink, IconButton, IconButtonLink, SplitButton } from "@k2b/ui";

Example

tsx
<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.

tsx
<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.

tsx
<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:

tsx
<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.

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.