Split button
A default action joined to a chevron that opens a menu of related alternatives, such as Save beside Save as draft and Save and share. Two tab stops in one group; the menu stays inside the viewport.
cmp_split_button_01 Before you use this component
- Install
bits-ui@^2.19.3. - Requires client-side JavaScript to work.
Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_split_button_01 version 1.0.0 with variant "blue", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Blue accent
- Version
- 1.0.0
- Digest
Full digest
sha256-e54d771fe262552c666a633bc2b697f51d88bccef2585c15c53316da45d6d529
<!--
A default action joined to a menu of related alternatives: "Save" beside a chevron that opens
"Save as draft", "Save and share" and the rest. The two halves are one shape split by a short
inset seam, each its own tab stop, inside a group named by the label. The menu is a Bits UI
DropdownMenu anchored to the whole group, so it opens flush with the group's edge, at least as
wide as the group, and flips or shifts to stay inside the viewport.
-->
<script lang="ts" module>
import type { Snippet } from 'svelte';
export type SplitButtonVariant = 'primary' | 'secondary';
export type SplitButtonAlign = 'start' | 'end';
export interface SplitButtonItem {
/** Passed to onSelect when this item is chosen. Unique within the menu. */
id: string;
/** The action, e.g. 'Save as draft'. */
label: string;
/** One line on what the action does, set small and muted under the label. */
description?: string;
/** Decorative 16 px icon before the label. */
icon?: Snippet;
/** Stays in the menu with aria-disabled, so people learn the action exists. */
disabled?: boolean;
/** Sets the item in the danger colour. Put it last, in a group of its own. */
destructive?: boolean;
/** A separator is drawn wherever the group changes from one item to the next. */
group?: string;
}
</script>
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { onMount, tick } from 'svelte';
import type { ClassValue } from 'svelte/elements';
interface Props {
/** The default action's label, and the group's accessible name. */
label: string;
/** Called when the primary half is activated. Not called while pending or disabled. */
onAction: () => void;
/** The alternatives shown in the menu, in order. With none, only the primary half renders. */
items: SplitButtonItem[];
/** Called with the chosen item's id. */
onSelect: (id: string) => void;
/** Accessible name for the trigger. Give it context: 'More save options'. */
menuLabel?: string;
/** Visual priority, shared by both halves. */
variant?: SplitButtonVariant;
/** Busy state on the primary half. The trigger stays usable unless disabled. */
pending?: boolean;
/** Announced once, politely, when pending starts. */
pendingLabel?: string;
/** Disables both halves. */
disabled?: boolean;
/** Which edge of the group the menu lines up with. Flips when it would leave the viewport. */
align?: SplitButtonAlign;
/** Whether the menu is open. Bindable. */
open?: boolean;
/** Extra classes on the group, for margins or a full width (the primary half grows). */
class?: ClassValue;
}
let {
label,
onAction,
items,
onSelect,
menuLabel = `${label} options`,
variant = 'primary',
pending = false,
pendingLabel = 'Working',
disabled = false,
align = 'end',
open = $bindable(false),
class: className
}: Props = $props();
const uid = $props.id();
const hasMenu = $derived(items.length > 0);
/** Reserve the icon column for every row when any row has an icon, so labels share one edge. */
const withIcons = $derived(items.some((item) => item.icon));
let group = $state<HTMLElement | null>(null);
let menu = $state<HTMLElement | null>(null);
/* Filled only after hydration, so a control that starts out pending still announces. */
let hydrated = $state(false);
onMount(() => {
hydrated = true;
});
function handleAction(event: MouseEvent) {
if (pending || disabled) {
event.preventDefault();
return;
}
onAction();
}
/*
* Which item a keyboard opening lands on. Bits UI opens on Enter, Space and ArrowDown but
* focuses the menu itself; the APG pattern wants the first enabled item, or the last for
* ArrowUp, which Bits UI does not handle at all.
*/
let landOn: 'first' | 'last' | null = null;
function handleTriggerKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowUp') {
event.preventDefault();
landOn = 'last';
readGroup();
open = true;
} else if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
landOn = 'first';
}
}
function handleOpenAutoFocus(event: Event) {
// Kept until the menu closes: the focus scope can mount twice while the menu settles.
const target = landOn;
if (!target) return;
// Bits UI would focus the menu itself; land on an item instead, once the items are in.
event.preventDefault();
tick().then(() => {
const node = menu ?? document.getElementById(ids.menu);
const all = [...(node?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
const enabled = all.filter((item) => item.getAttribute('aria-disabled') !== 'true');
// With every item disabled, land on one anyway so focus is never left behind.
const pool = enabled.length ? enabled : all;
(target === 'first' ? pool[0] : pool.at(-1))?.focus({ preventScroll: true });
});
}
/*
* The menu is portalled to the body, out of reach of tokens set on an ancestor of the group.
* Copy the group's resolved values onto it when it opens, so a retone reaches both.
*/
const TOKENS = [
'--_accent',
'--_on-accent',
'--_ink',
'--_muted',
'--_hairline',
'--_surface',
'--_menu-surface',
'--_danger'
];
let menuTokens = $state('');
/* The same for direction and language: the body is not inside the consumer's dir or lang. */
let dir = $state<'ltr' | 'rtl'>('ltr');
let lang = $state<string | undefined>();
/*
* Read before the menu mounts (from onOpenChange), so its props do not change underneath the
* focus scope; the effect covers a menu that starts open, and a closed menu forgets landOn.
*/
function readGroup() {
if (!group) return;
const style = getComputedStyle(group);
menuTokens = TOKENS.map((name) => `${name}: ${style.getPropertyValue(name)}`).join('; ');
dir = style.direction === 'rtl' ? 'rtl' : 'ltr';
lang = group.closest('[lang]')?.getAttribute('lang') ?? undefined;
}
$effect.pre(() => {
// A menu that is disabled or emptied while open closes, so bind:open stays truthful.
if (open && (disabled || !hasMenu)) open = false;
else if (open) readGroup();
else landOn = null;
});
function handleSelect(event: Event, item: SplitButtonItem) {
// Unavailable items stay focusable (APG) but do nothing; the menu stays open.
if (item.disabled || disabled) {
event.preventDefault();
return;
}
onSelect(item.id);
}
/*
* After a scroll-away close, focus goes back to the trigger without scrolling the page back
* to it; Bits UI's own return would undo the visitor's scroll.
*/
let scrolledAway = false;
function handleCloseAutoFocus(event: Event) {
if (!scrolledAway) return;
scrolledAway = false;
event.preventDefault();
document.getElementById(ids.trigger)?.focus({ preventScroll: true });
}
/* Close when the group scrolls fully out of view, so the menu never floats over unrelated content. */
$effect(() => {
if (!open || !group || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(([entry]) => {
if (entry && !entry.isIntersecting) {
scrolledAway = true;
open = false;
}
});
observer.observe(group);
return () => observer.disconnect();
});
const ids = $derived({ label: `${uid}-label`, trigger: `${uid}-trigger`, menu: `${uid}-menu` });
</script>
<div
bind:this={group}
role="group"
aria-labelledby={ids.label}
data-variant={variant}
data-disabled={disabled ? '' : undefined}
class={[
'split-button relative isolate inline-flex max-w-full rounded-lg',
'data-disabled:opacity-50',
className
]}
>
<button
type="button"
disabled={disabled || undefined}
aria-disabled={pending ? 'true' : undefined}
aria-busy={pending ? 'true' : undefined}
data-pending={pending ? '' : undefined}
class={[
'split-button__action relative flex min-h-9 min-w-0 flex-1 cursor-pointer items-center justify-center px-4 py-2 pointer-coarse:min-h-11',
hasMenu ? 'rounded-s-lg' : 'rounded-lg',
'bg-(--_fill) text-sm leading-5 font-medium text-(--_label) select-none hover:bg-(--_fill-hover) active:bg-(--_fill-press)',
'transition-[background-color] duration-150 ease-(--_ease) active:duration-[80ms] motion-reduce:transition-none',
'outline-offset-2 outline-(--_ring) focus-visible:z-10 focus-visible:shadow-(--_focus-backing) focus-visible:outline-2',
'disabled:cursor-not-allowed aria-busy:cursor-progress'
]}
onclick={handleAction}
>
<!-- Pending replaces the label with a spinner in the label's own box: the half keeps its
exact width and its name, nothing is squeezed into a short label, and pendingLabel is
announced by the live region below. -->
<span class="grid place-items-center">
<span
id={ids.label}
class={[
'col-start-1 row-start-1 text-center text-pretty break-words',
pending && 'opacity-0'
]}
>
{label}
</span>
{#if pending}
<svg
class="col-start-1 row-start-1 size-4 animate-spin motion-reduce:animate-pulse"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6.25"
stroke="currentColor"
stroke-opacity="0.25"
stroke-width="1.75"
/>
<path
d="M14.25 8A6.25 6.25 0 0 0 8 1.75"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
/>
</svg>
{/if}
</span>
</button>
{#if hasMenu}
<DropdownMenu.Root bind:open {dir} onOpenChange={(next) => next && readGroup()}>
<DropdownMenu.Trigger
id={ids.trigger}
{disabled}
aria-label={menuLabel}
class={[
'split-button__trigger group/trigger relative grid w-9 shrink-0 cursor-pointer place-items-center rounded-e-lg pointer-coarse:w-11',
'bg-(--_fill) text-(--_label) hover:bg-(--_fill-hover) active:bg-(--_fill-press) data-[state=open]:bg-(--_fill-press)',
'transition-[background-color] duration-150 ease-(--_ease) active:duration-[80ms] motion-reduce:transition-none',
'outline-offset-2 outline-(--_ring) focus-visible:z-10 focus-visible:shadow-(--_focus-backing) focus-visible:outline-2',
'disabled:cursor-not-allowed'
]}
onkeydown={handleTriggerKeydown}
>
<!-- The seam: inset from top and bottom so the two halves still read as one shape. -->
<span class="absolute inset-y-2 start-0 w-px bg-(--_seam)" aria-hidden="true"></span>
<svg
class="size-4 transition-transform duration-200 ease-(--_ease) group-data-[state=open]/trigger:rotate-180 motion-reduce:transition-none"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="m4.75 6.5 3.25 3.25 3.25-3.25"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
bind:ref={menu}
id={ids.menu}
aria-labelledby={ids.trigger}
customAnchor={group}
{align}
{dir}
{lang}
side="bottom"
sideOffset={8}
collisionPadding={8}
preventScroll={false}
onOpenAutoFocus={handleOpenAutoFocus}
onCloseAutoFocus={handleCloseAutoFocus}
style={menuTokens}
class={[
'split-button__menu z-50 w-max max-w-[min(22rem,var(--bits-dropdown-menu-content-available-width))] min-w-[min(max(var(--bits-dropdown-menu-anchor-width),12rem),var(--bits-dropdown-menu-content-available-width))]',
'max-h-(--bits-dropdown-menu-content-available-height) overflow-y-auto overscroll-contain',
'origin-(--bits-dropdown-menu-content-transform-origin) rounded-xl bg-(--_menu-surface) p-1 text-(--_ink) shadow-(--_shadow-menu) outline-none'
]}
>
{#each items as item, i (item.id)}
{#if i > 0 && item.group !== items[i - 1].group}
<DropdownMenu.Separator class="-mx-1 my-1 h-px bg-(--_hairline)" />
{/if}
<!-- Not Bits UI's disabled, which drops the item from arrow keys and typeahead: an
unavailable item stays reachable, with aria-disabled, and does nothing. -->
<DropdownMenu.Item
textValue={item.label}
onSelect={(event) => handleSelect(event, item)}
>
{#snippet child({ props })}
<div
{...props}
aria-disabled={item.disabled ? 'true' : undefined}
data-unavailable={item.disabled ? '' : undefined}
data-destructive={item.destructive ? '' : undefined}
class={[
'split-button__item group/item flex min-h-9 cursor-pointer items-start gap-2 rounded-lg px-2 py-2 outline-hidden select-none pointer-coarse:min-h-11',
'data-highlighted:bg-(--_item-highlight) data-unavailable:cursor-not-allowed',
// Arrowing through the menu draws the house ring, inset so the scrolling menu never clips it.
'-outline-offset-2 outline-(--_ring) focus-visible:outline-2 focus-visible:outline-solid',
'data-destructive:text-(--_danger) data-destructive:data-highlighted:bg-(--_danger-highlight)'
]}
>
{#if withIcons}
<span
class="grid h-5 w-4 shrink-0 place-items-center text-(--_muted) group-data-destructive/item:text-current group-data-unavailable/item:opacity-50 [&_svg]:size-4"
aria-hidden="true"
>
{@render item.icon?.()}
</span>
{/if}
<!-- Unavailable rows dim their content, not the row, so the focus ring keeps its contrast. -->
<span class="flex min-w-0 flex-col group-data-unavailable/item:opacity-50">
<span class="text-sm leading-5 font-medium text-pretty break-words"
>{item.label}</span
>
{#if item.description}
<span class="text-xs leading-4 text-pretty break-words text-(--_muted)"
>{item.description}</span
>
{/if}
</span>
</div>
{/snippet}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{/if}
</div>
<!-- Outside the group, so the primary half's name stays its label; filled only while pending. -->
<span class="sr-only" role="status" aria-live="polite"
>{hydrated && pending ? pendingLabel : ''}</span
>
<style>
/*
* Public tokens: set --split-button-* on the group or any ancestor to retone it. The menu is
* portalled, so it declares the same defaults and receives the group's resolved values inline.
*/
.split-button,
:global(.split-button__menu) {
--_accent: var(--split-button-accent, #1d4ed8);
--_on-accent: var(--split-button-on-accent, #ffffff);
--_ink: var(--split-button-ink, #18181b);
--_muted: var(--split-button-muted, #52525b);
--_hairline: var(--split-button-hairline, rgb(0 0 0 / 0.1));
--_surface: var(--split-button-surface, #ffffff);
--_menu-surface: var(--split-button-menu-surface, #ffffff);
--_danger: var(--split-button-danger, #b91c1c);
}
/* Formulas: tints mixed from the fill toward its label, so they follow any accent. */
.split-button {
--_ease: cubic-bezier(0.2, 0, 0, 1);
--_ring: var(--_accent);
/*
* A focused half's ring runs over its neighbour on the seam side, where a filled
* neighbour is the ring's own colour. A surface band under the ring (two pixels each
* side of it) keeps the whole ring at full contrast.
*/
--_focus-backing: 0 0 0 6px var(--_surface);
}
.split-button[data-variant='primary'] {
--_fill: var(--_accent);
--_fill-hover: color-mix(in oklab, var(--_accent) 90%, var(--_on-accent));
--_fill-press: color-mix(in oklab, var(--_accent) 85%, var(--_on-accent));
--_label: var(--_on-accent);
--_seam: color-mix(in oklab, var(--_on-accent) 24%, transparent);
--_edge: inset 0 1px 0 rgb(255 255 255 / 0.12);
}
.split-button[data-variant='secondary'] {
--_fill: var(--_surface);
--_fill-hover: color-mix(in oklab, var(--_surface) 95%, var(--_ink));
--_fill-press: color-mix(in oklab, var(--_surface) 91%, var(--_ink));
--_label: var(--_ink);
--_seam: var(--_hairline);
--_edge: inset 0 0 0 1px var(--_hairline);
}
/*
* The group's edge (a top highlight on a fill, a hairline round an outline) is one box drawn
* over both halves, so their fills never cover it and the control reads as one shape. The
* focus ring is drawn two pixels outside it.
*/
.split-button::after {
content: '';
position: absolute;
inset: 0;
/* Above a focused half (z-10) too, so its fill never hides the secondary hairline. */
z-index: 20;
border-radius: inherit;
box-shadow: var(--_edge);
pointer-events: none;
}
/* Disabled and pending halves neither change tone on hover nor press. */
.split-button[data-disabled],
.split-button__action[data-pending] {
--_fill-hover: var(--_fill);
--_fill-press: var(--_fill);
}
/* The menu's popover elevation (DESIGN §3.5) and its row tints. */
:global(.split-button__menu) {
--_shadow-menu:
0 0 0 1px rgb(0 0 0 / 0.05), 0 4px 6px -1px rgb(0 0 0 / 0.07),
0 10px 15px -3px rgb(0 0 0 / 0.05);
--_ring: var(--_accent);
--_item-highlight: color-mix(in oklab, var(--_ink) 6%, transparent);
--_danger-highlight: color-mix(in oklab, var(--_danger) 8%, transparent);
}
/* Open and close: keyframes, because Bits UI waits for the exit animation before unmounting. */
:global(.split-button__menu[data-state='open']) {
animation: split-button-in 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
:global(.split-button__menu[data-state='closed']) {
animation: split-button-out 150ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
@keyframes split-button-in {
from {
opacity: 0;
scale: 0.97;
}
}
@keyframes split-button-out {
to {
opacity: 0;
scale: 0.97;
}
}
@keyframes split-button-fade-in {
from {
opacity: 0;
}
}
@keyframes split-button-fade-out {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.split-button__menu[data-state='open']) {
animation-name: split-button-fade-in;
}
:global(.split-button__menu[data-state='closed']) {
animation-name: split-button-fade-out;
}
}
</style>
Usage#
On this pageGive it the default action's label and callback, the alternatives and a select callback. It reports which action was chosen and nothing more: it does not run, confirm or undo the actions, and it does not remember a new default when someone picks an alternative.
- Suggested location
src/lib/components/split-button-01
Limitations
- The control is 36 px tall (44 px on touch screens) with one label size. For a marketing-sized action, change
min-h-9,w-9,px-4andtext-smon the two halves. - Item icons are snippets, so a menu built from JSON maps icon names to snippets itself, as the preview does.
- The menu is portalled to the body. Tokens set on an ancestor of the group are copied onto it when it opens; a token changed while the menu is open reaches it the next time it opens.
- There are no submenus, checkbox or radio items; use a full dropdown menu for those.
- Pressing a half tints it rather than scaling it, because a shrinking half would open a gap in the shape.
Example
<script lang="ts">
import SplitButton from '$lib/components/split-button-01/SplitButton.svelte';
let saving = $state(false);
async function save() {
saving = true;
await new Promise((resolve) => setTimeout(resolve, 800));
saving = false;
}
</script>
<SplitButton
label="Save"
menuLabel="More save options"
pending={saving}
pendingLabel="Saving"
onAction={save}
items={[
{ id: 'draft', label: 'Save as draft', description: 'Only you and other editors can see it' },
{ id: 'share', label: 'Save and share with guests' },
{ id: 'delete', label: 'Delete draft', destructive: true, group: 'danger' }
]}
onSelect={(id) => console.log('chose', id)}
/>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Split button#
Draft usage notes.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
label | string | Yes | None | The default action's label, e.g. 'Save'. Also names the group. |
onAction | () => void | Yes | None | Called when the primary half is activated. Not called while pending or disabled. |
items | SplitButtonItem[] | Yes | None | The alternatives, in order: { id, label, description?, icon?: Snippet, disabled?, destructive?, group? }. A separator is drawn where group changes. Empty, only the primary half renders. |
onSelect | (id: string) => void | Yes | None | Called with the chosen item's id. The menu then closes and focus returns to the trigger. |
menuLabel | string | No | `${label} options` | Accessible name for the trigger. Defaults to the label plus 'options' ('Save options'); pass one with more context, or a translation, e.g. 'More save options'. |
variant | 'primary' | 'secondary' | No | 'primary' | Visual priority shared by both halves: an accent fill, or a surface fill inside a hairline. |
pending | boolean | No | false | Busy state on the primary half: a spinner takes the label's place, the width holds exactly, aria-busy is set and clicks are ignored. The trigger stays usable. |
pendingLabel | string | No | 'Working' | Announced once, politely, when pending starts, e.g. 'Saving'. |
disabled | boolean | No | false | Disables both halves. |
align | 'start' | 'end' | No | 'end' | Which edge of the group the menu lines up with. It flips when that would leave the viewport. |
open | boolean | No | false | Whether the menu is open. Bindable. |
class | ClassValue | No | None | Extra classes on the group, for margins or a full width (the primary half takes the extra room). |
Customization#
On this pageChange the labels, alternatives and priority through props, and retone both halves and the menu through eight --split-button-* CSS variables. Hover and press tones are mixed from the fill and its label colour, so they follow any accent.
- Priority: one primary split button per view. Beside another filled button, use variant='secondary'.
- Accent:
--split-button-accentfills the primary variant and colours the focus ring;--split-button-on-accentis its label, chevron and seam. Keep the pair above 4.5:1. - Menu:
--split-button-menu-surfaceis the menu's fill,--split-button-inkits labels,--split-button-mutedits descriptions and icons,--split-button-hairlineits separators and the secondary variant's edge and seam. - Danger:
--split-button-dangercolours destructive items. The default isred-700(6.5:1 on white). - Worked retone for a
#09090bband:--split-button-accent:#fafafa;--split-button-on-accent:#09090b;--split-button-ink:#fafafa;--split-button-muted:#a1a1aa;--split-button-surface:#18181b;--split-button-menu-surface:#18181b;--split-button-hairline: rgb(255 255 255 / 0.12);--split-button-danger:#f87171. - Alternatives: keep them to verbs on the same object as the default (Save as draft, Save and share). Put a destructive item last with its own group so a separator sets it apart.
- Width: pass class='
w-full' on a phone or in a narrow column; the primary half grows and the menu matches the new width. - Size: the halves are
min-h-9(36 px) and the triggerw-9; change both together, and keep the 44 px touch sizes.
Public CSS variables
| Variable | Token |
|---|---|
--split-button-accent | accent |
--split-button-on-accent | onAccent |
--split-button-ink | ink |
--split-button-muted | muted |
--split-button-hairline | hairline |
--split-button-surface | surface |
--split-button-menu-surface | menuSurface |
--split-button-danger | danger |
Dependencies and services#
On this page| Package | Range | Resolved at build time | Purpose |
|---|---|---|---|
bits-ui | ^2.19.3 | 2.19.3 | Headless DropdownMenu: menu roles, roving focus, typeahead, outside-press and Escape dismissal, focus return, portal and collision-aware positioning. |
Install with (shown for reference, run it yourself)
npm install bits-ui@^2.19.3 Accessibility#
On this page- Renders role="group" named by the primary half's label. The primary half and the trigger are separate tab stops, in that order.
- The trigger follows the WAI-ARIA menu button pattern:
aria-haspopup="menu",aria-expandedandaria-controls. Enter, Space and ArrowDown open the menu on the first enabled item; ArrowUp opens it on the last. - Inside the menu, items are role="menuitem" with roving focus: arrows move and wrap, Home and End jump, typing a letter jumps to the next match. Escape closes and returns focus to the trigger; Tab closes and moves on.
- Disabled items stay in the menu and in arrow-key order with
aria-disabled, so people learn the action exists. Say why in its description. - The trigger's name is
menuLabel, which defaults to the label plus 'options' ('Save options') so two controls on a page never share a name. Pass a translated one on a page that is not in English. - Pending keeps the primary half focusable with
aria-busyandaria-disabled, and a polite live region announcespendingLabelonce. - Destructive items are marked by colour and should say what they remove in their label; colour is not the only signal you should rely on.
Known limitations
- Typeahead matches the item label only, not its description.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 21 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.