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

- ID: `cmp_split_button_01`
- Slug: `split-button-01`
- Version: `1.0.0` (current)
- Status: published
- Published: 2026-10-21T23:52:06Z
- Updated: 2026-10-21
- Available versions: `1.0.0`
- Kind: control
- Primary category: `buttons`
- Detail page: https://pagesugar.com/components/split-button-01?variant=blue
- Preview: https://pagesugar.com/preview/split-button-01

## Variants

| Variant | Label | Default | Artifact digest |
| --- | --- | --- | --- |
| `neutral` | Neutral | yes | `sha256-4eb9c29f005be04f41c863555d3b7b782c0d871ce2f0a28fae8c07b2c91716df` |
| `blue` | Blue accent | no | `sha256-e54d771fe262552c666a633bc2b697f51d88bccef2585c15c53316da45d6d529` |

## Runtime and compatibility

- Runtime: svelte
- Svelte: 5
- SvelteKit required: no (portable Svelte component)
- Imports: 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.
- Tailwind CSS: 4
- SSR: supported
- Requires client-side JavaScript: yes
- Integration level: local-interaction
- Appearance modes: light
- Suggested directory: `src/lib/components/split-button-01`

## Dependencies

- `bits-ui` `^2.19.3` (resolved at build time: `2.19.3`): Headless DropdownMenu: menu roles, roving focus, typeahead, outside-press and Escape dismissal, focus return, portal and collision-aware positioning.

## Services

No external services required.

## Usage

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

Required props: `label`, `onAction`, `items`, `onSelect`

```svelte
<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)}
/>
```

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-4 and text-sm on 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.

## Usage guide

### Split button

Draft usage notes.

## Props

| Name | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `label` | `string` | yes |  | The default action's label, e.g. 'Save'. Also names the group. |
| `onAction` | `() => void` | yes |  | Called when the primary half is activated. Not called while pending or disabled. |
| `items` | `SplitButtonItem[]` | yes |  | 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 |  | 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 |  | Extra classes on the group, for margins or a full width (the primary half takes the extra room). |

## Customization

Change 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-accent fills the primary variant and colours the focus ring; --split-button-on-accent is its label, chevron and seam. Keep the pair above 4.5:1.
- Menu: --split-button-menu-surface is the menu's fill, --split-button-ink its labels, --split-button-muted its descriptions and icons, --split-button-hairline its separators and the secondary variant's edge and seam.
- Danger: --split-button-danger colours destructive items. The default is red-700 (6.5:1 on white).
- Worked retone for a #09090b band: --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 trigger w-9; change both together, and keep the 44 px touch sizes.

| Token | Public CSS variable |
| --- | --- |
| `accent` | `--split-button-accent` |
| `onAccent` | `--split-button-on-accent` |
| `ink` | `--split-button-ink` |
| `muted` | `--split-button-muted` |
| `hairline` | `--split-button-hairline` |
| `surface` | `--split-button-surface` |
| `menuSurface` | `--split-button-menu-surface` |
| `danger` | `--split-button-danger` |

## Accessibility

- 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-expanded and aria-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-busy and aria-disabled, and a polite live region announces pendingLabel once.
- 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.

## License

- Declared source: MIT
- Default license approval is pending. See https://pagesugar.com/docs/license.

## Source

- Palette: Blue accent (`blue`)
- Entry: `SplitButton.svelte`
- Suggested directory: `src/lib/components/split-button-01`
- Files: 1
- Artifact digest: `sha256-e54d771fe262552c666a633bc2b697f51d88bccef2585c15c53316da45d6d529`

Paths below are relative to the suggested directory. Copy the files as they are;
they import nothing from this site.

#### `SplitButton.svelte`

Role: entry · 18031 bytes · SHA-256 `675c90bf216f18e578c4de0358282e6c0501898d3f762d4109396a215f32e8d4`

```svelte
<!--
	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>
```

## Artifacts

### Blue accent (`blue`)

- Artifact digest: `sha256-e54d771fe262552c666a633bc2b697f51d88bccef2585c15c53316da45d6d529`
- Entry: `SplitButton.svelte`
- Receipt: https://pagesugar.com/artifacts/cmp_split_button_01/1.0.0/blue/sha256-e54d771fe262552c666a633bc2b697f51d88bccef2585c15c53316da45d6d529/manifest.json
- Bundle: https://pagesugar.com/artifacts/cmp_split_button_01/1.0.0/blue/sha256-e54d771fe262552c666a633bc2b697f51d88bccef2585c15c53316da45d6d529/bundle.zip (9934 bytes, sha256 `d4f45ae8d05f2dd89d57322b2aead8548f34e81c3eb4640c401076d52293ebd6`)

Files:

- `SplitButton.svelte` (entry, 18031 bytes): https://pagesugar.com/artifacts/cmp_split_button_01/1.0.0/blue/sha256-e54d771fe262552c666a633bc2b697f51d88bccef2585c15c53316da45d6d529/source/SplitButton.svelte
