Controls · SplitButton

SplitButton

A primary action joined to a ▾ menu trigger — Save | ▾ → secondary actions. Pure composition: <ButtonGroup attached> + <Button> + <DropdownMenu>. The ▾ trigger is auto-rendered and inherits the group's variant / size / color; only the ▾ opens the menu. 0 own events — interactive by composition.

parts{compiled.parts.order.length} events0 composesButtonGroup · Button · DropdownMenu scopeeidos
(lastAction = 'primary: Save')}> {primaryLabel} (lastAction = 'Save as…')}>Save as… (lastAction = 'Save a copy')} >Save a copy (lastAction = 'Discard')} >Discard
trace {#if trace.length === 0} click Save, open the ▾ menu, or pick an item {:else} {#each trace.slice(0, 3) as entry} {entry.event} · {entry.family}{entry.intent ? ' · ' + entry.intent : ''} {fmtTime(entry.at)} {/each} {/if} menu {open ? 'open' : 'closed'} · last {lastAction}
{#if tab === 'live'}

Controls

SplitButton is eidos-only — no soma split. The visual props propagate to BOTH the primary and the ▾ trigger via the ButtonGroup context.

eidos props · visual treatment
eidos props · menu (float) · <SplitButton.Menu>
{#if !menuOffsetAuto} {/if}
Demo content · primary label
eidos visual · composes ButtonGroup + Button + DropdownMenu svelte
{eidosSnippet}
{/if} {#if tab === 'api'}

API reference

Root props propagate to both buttons; DropdownMenu provider props (open, modal, onOpenChange…) pass through. The menu surface (Menu / Item / Separator / Group / CheckboxItem / RadioGroup / Sub…) is re-exported from DropdownMenu.

PropTypeDefaultDescription
open bindablebooleanfalseMenu open state.
variant'solid' | 'soft' | 'surface' | 'outline' | 'ghost' | 'plain''solid'Propagated to both buttons.
sizeResponsiveProp<'xs' | 'sm' | 'md' | 'lg' | 'xl'>'md'Propagated to both buttons.
color'primary' | 'secondary' | 'neutral''primary'Propagated to both buttons.
disabledbooleanfalseDisables both buttons.
iconSnippet— (chevron)Override the ▾ glyph. The default chevron rotates while open.
menuLabelstring'More actions'Accessible name for the ▾ trigger.
aria-labelstring—Accessible name for the split group.
<SplitButton.Menu> props · the float

The menu IS a DropdownMenu.Content with split-button-aware defaults: it anchors to the whole group, matches its width, and sits a gap below it. Every Content positioning prop (side, align, collisionPadding…) still passes through.

PropTypeDefaultDescription
matchAnchorWidthResponsiveProp<boolean>trueMin width tracks the split surface (the anchor width); content can grow it.
sideOffsetnumbercanonicalGap below the button. Unset → resolved from the size's --space-* step (density × scaling aware); pass a number to override.
sizeResponsiveProp<'xs'…'xl'>split's sizeForwarded to DropdownMenu.Content's size (1:1 scale); defaults to the split's size, override to decouple.
align'start' | 'center' | 'end''start'Edge of the surface the menu aligns to.
Reference comparison
LibraryClosest equivalentDifference vs UIX
fluent-ui<SplitButton>Same model (primary + menu). UIX shares variant/size/color to both via the ButtonGroup context automatically.
ant-design<Dropdown.Button>Same split. UIX's menu is the full DropdownMenu (groups/checkbox/radio/submenus), not a flat list.
muiButtonGroup + Menu recipeMUI documents it as a recipe; UIX ships it as a component with the chevron-rotates affordance.
{/if} {#if tab === 'morfo'}

morfo · declarative contract

scope-eidos — no soma runtime. The morfo declares the split-specific contract: the role="group" provider, the data-state that rotates the chevron, and the Primary / Trigger parts (structural identities over the composed Button / DropdownMenu.Trigger). Source: src/uix/morfo/components/split-button.ts.

FieldValue
name"{splitButtonMorfo.name}"
kebab"{splitButtonMorfo.kebab}"
scope[{splitButtonMorfo.scope.map((s) => `"${s}"`).join(', ')}]
apgmenu-button ↗
parts.length{splitButtonMorfo.parts.length}
events.length0
Parts
{#each partsList as part} {/each}
PartMarkerElementRoleArchetypeStates
{part.kebab} [{part.marker}] <{part.defaultElement}> {part.role ?? '—'} {part.archetype ?? '—'} {part.states.length ? part.states.join(' | ') : '—'}
{#each splitButtonMorfo.parts as rawPart} {@const partAny = rawPart as unknown as { kebab: string; aria?: ReadonlyArray<{ attr: string; value: { kind: string }; condition?: { when: string; prop?: string }; severity?: string; }>; }} {@const dataAttrs = compiled.contracts.dataAttrsByPart.get(partAny.kebab) ?? []} {@const ariaAttrs = partAny.aria ?? []} {#if dataAttrs.length || ariaAttrs.length}
{partAny.kebab}
{#if dataAttrs.length}
{#each dataAttrs as attr} {/each}
data-attrValuesSource
{attr.attr} {attr.values ? attr.values.join(' | ') : '—'} {'value' in attr && attr.value ? (attr as { value: { kind: string } }).value.kind : '—'}
{/if} {#if ariaAttrs.length}
{#each ariaAttrs as a} {/each}
aria-attrSourceConditionSeverity
{a.attr} {a.value.kind} {a.condition ? `when ${a.condition.when}${a.condition.prop ? ` (${a.condition.prop})` : ''}` : 'always'} {a.severity ?? 'required'}
{/if} {/if} {/each}
{/if} {#if tab === 'sema'}

sema · events

SplitButton declares no events of its own — it is interactive by composition. The primary fires contact-activate (Button), the ▾ fires emerge-open / emerge-close (DropdownMenu), and each item fires commit-select (DropdownMenu). Exercise the stage and watch the trace strip.

No Sema events declared.
{/if} {#if tab === 'tokens'} {/if} {#if tab === 'recipe'}

Eidos recipe

Layout + the segmented seam come from <ButtonGroup attached>; Button + DropdownMenu paint the buttons. SplitButton's own CSS at src/uix/eidos/components/split-button/split-button.css owns the split-specific affordances: the ▾ rotates open and stays a slim tail, and the menu tracks the surface width, scales with size, and sits a canonical gap below.

SelectorSourcePurpose
[data-split-button][data-state='open']morfoGroup open state (stamped from `open`).
… [data-split-button-trigger] [data-svg='chevron']eidosRotate the ▾ chevron 180° while the menu is open.
[data-split-button-trigger][data-icon-only]eidosSlim the ▾ to glyph + snug padding (not a square).
[data-dropdown-menu-content][data-split-button-menu]eidosmin-inline-size = the anchor (surface) width; content grows it.
[data-split-button-menu][data-size='…']eidosGap step per size (font / height / padding scale come from DropdownMenu's own size axis).
[data-split-button-menu][data-canonical-gap][data-side='…']eidosCanonical separation: a --space-* margin on the placed side.
{/if} {#if tab === 'a11y'}

Accessibility

ConcernContract
Grouprole="group" (from ButtonGroup) + aria-label — names the split as a unit.
PrimaryA native <button> (from Button) — its own action, independently Tab-focusable.
Menu triggerFollows the APG Menu Button (from DropdownMenu): aria-haspopup="menu" + aria-expanded, opens with Enter/Space/ArrowDown, names itself via menuLabel.
Menurole="menu" with arrow-key navigation, typeahead, Escape-to-close, focus return to the ▾ (all from DropdownMenu).
KeyboardTab moves between the primary and the ▾ (no roving); each opens / activates with its native keys.
{/if}