Split Button
A primary action with its variations behind a caret: one press for the thing you meant, a menu for the things you nearly meant.
Live demo
SplitButton is the control for an action that has a default and a family: Save, and also Save-and-close; Deploy, and also Deploy-to-staging; Export, and also Export-as-CSV. The press is one click away and the alternatives are two, which is the whole point — a plain Menu makes the common case cost an extra press, and a row of Buttons makes the rare case cost the same as the common one.
It is a composition rather than a re-implementation: both halves carry Button's own classes, so every variant, tone, and size the button set speaks is the split button's too, and the dropdown is a real <xtyle-menu>, so the roving focus, the typeahead, the separators, the headings, the hint accelerators, the danger rows, and the select event all come from Menu instead of a second copy of them living here. What the element adds is the group: one shared shape with a square seam, the divider, the caret, and the menu keys answering from either half — ArrowDown on the primary drops the menu the way it does on the caret. The primary fires a plain click; the menu fires select with the chosen row's value, and the caret's own click never reaches a click listener, so the two paths never blur together.
When to use
How this component composes with the rest of the set.
Props
10 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
Variants
solid
Filled: the default, for the one primary action on a surface.
outline
Bordered: a secondary action that still needs an edge.
subtle
Tinted: a quiet action inside a dense toolbar.
ghost
Bare: chrome only on hover, for a row of actions.
Sizes
xs
The dense toolbar step.
sm
Compact.
md
The default.
lg
The prominent step, for a page's main action.
States
open
The dropdown is showing; the caret is flipped and aria-expanded is true.
disabled
Both halves are inert and the seam fades with them.
Anatomy
The named parts that make up the component, with their selectors.
split-button
The role="group" wrapper holding the two halves as one shape.
primary
The default action: a real Button, square on its trailing edge where it meets the caret.
toggle
The caret half: a menu button (aria-haspopup="menu", aria-expanded) that opens the dropdown.
divider
The hairline seam between the halves, drawn in currentColor inside the toggle so it reads on a solid fill and a ghost alike.
caret
The chevron, rotated 180° while the menu is open.
menu
The composed <xtyle-menu> in its cursor-anchored posture, opened against the caret. It renders through component.menu, so the dropdown's own chrome stays a mod surface.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--border-thin
--duration-fast
--ease-standard
--radius-full
--space-1
Slots
The primary action's label. Everything else about the control is a prop; this is the word on the button.
Accessibility
Code
Save, and the other saves
The canonical shape: the default action on the button, its variations behind the caret, and the destructive one marked as such.
<xtyle-split-button
variant="solid"
tone="accent"
menu-label="More save actions"
items='[
{ "label": "Save and close", "value": "save-close" },
{ "label": "Save as draft", "value": "save-draft", "hint": "Ctrl+D" },
{ "separator": true },
{ "label": "Discard changes", "value": "discard", "intent": "danger" }
]'
>
Save
</xtyle-split-button>
<script>
const split = document.querySelector("xtyle-split-button");
split.addEventListener("click", () => save());
split.addEventListener("select", (event) => run(event.detail.value));
</script>
<script lang="ts">
import { SplitButton } from "@xtyle/svelte";
const actions = [
{ label: "Save and close", value: "save-close" },
{ label: "Save as draft", value: "save-draft", hint: "Ctrl+D" },
{ separator: true },
{ label: "Discard changes", value: "discard", intent: "danger" },
];
</script>
<SplitButton
tone="accent"
items={actions}
menuLabel="More save actions"
onclick={() => save()}
onselect={(event) => run(event.detail.value)}
>
Save
</SplitButton>
---
import { SplitButton } from "@xtyle/astro";
const actions = [
{ label: "Deploy to staging", value: "staging" },
{ label: "Deploy to preview", value: "preview" },
{ separator: true },
{ label: "Roll back", value: "rollback", intent: "danger" },
];
---
<SplitButton id="deploy" tone="accent" items={actions} menuLabel="Other deploy targets">
Deploy
</SplitButton>
<script>
const split = document.getElementById("deploy")!;
split.addEventListener("click", () => deployProduction());
split.addEventListener("select", (event) => deployTo((event as CustomEvent).detail.value));
</script>