Accessible, unstyled SplitButton: a primary action paired with a menu button (WAI-ARIA APG) that opens related actions. Items, groups, radio/checkbox items, and submenus are the shared Menu parts re-exported under SplitButton names.
|
|
| Package |
@vizejs/ui/split-button |
| Maturity |
stable |
| Own the source |
vize lib pull split-button |
| Requires |
collection, context, controllable-state, direction, dismissable-layer, focus-scope, id, inert-outside, menu, pointer-grace, portal, positioner, presence, scroll-lock, typeahead |
| Aliases |
split button, menu button with default action, dropdown button, combo button |
| Covers |
WAI-ARIA menu button pattern, PrimeVue SplitButton, Fluent UI SplitButton, Carbon Combo Button |
Minimal setup
Use a Vue 3.5+ project with Vue SFC compilation configured (see Vite+ integration).
vp install @vizejs/ui
Add the public import below to <script setup lang="ts">. Components provide behavior without styles; the preview uses optional Paper styles.
import { SplitButton, SplitButtonRoot, SplitButtonAction, SplitButtonTrigger } from "@vizejs/ui/split-button";
Or copy the source into your project with vize lib pull split-button (see Source Distribution).
Try it
This is the basic example maintained with the component source, using the public package imports. The preview adds the optional Paper styles.
Open the interactive example · Styling these components
Copy the example
<!-- Save action with related save options in an attached menu. -->
<script setup lang="ts">
import { ref } from "vue";
import {
SplitButton,
SplitButtonAction,
SplitButtonContent,
SplitButtonItem,
SplitButtonSeparator,
SplitButtonTrigger,
} from "@vizejs/ui/split-button";
const lastAction = ref("none");
</script>
<template>
<div>
<SplitButton aria-label="Save options">
<SplitButtonAction @press="() => (lastAction = 'Save')">Save</SplitButtonAction>
<SplitButtonTrigger aria-label="More save options">▾</SplitButtonTrigger>
<SplitButtonContent>
<SplitButtonItem @select="() => (lastAction = 'Save as draft')">
Save as draft
</SplitButtonItem>
<SplitButtonItem @select="() => (lastAction = 'Save and publish')">
Save and publish
</SplitButtonItem>
<SplitButtonSeparator />
<SplitButtonItem @select="() => (lastAction = 'Schedule')">Schedule…</SplitButtonItem>
</SplitButtonContent>
</SplitButton>
<output>Last action: {{ lastAction }}</output>
</div>
</template>
API
Source: src/families/actions/split-button/split-button-root.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned base id. null and undefined select a deterministic fallback. |
open |
boolean |
undefined |
Controlled open state of the menu. undefined selects uncontrolled behavior. |
defaultOpen |
boolean |
false |
Initial open state for uncontrolled use. |
modal |
boolean |
true |
Make outside content inert and lock document scroll while the menu is open. |
dir |
MenuDirection |
undefined |
Reading direction: flips submenu arrow keys and submenu placement. undefined inherits DirectionProvider/LocaleProvider, then "ltr". |
loop |
boolean |
false |
Wrap arrow-key navigation from the last item to the first and back. |
disabled |
boolean |
false |
Disable the primary action and the trigger, and close the menu when it becomes disabled. |
anchor |
SplitButtonAnchor |
"group" |
Element the menu is positioned against: the whole group or only the trigger. |
ariaLabel |
string |
undefined |
Accessible name of the group when no aria-labelledby supplies one. |
ariaLabelledby |
string |
undefined |
Space-separated ids that label the group. |
ariaDescribedby |
string |
undefined |
Space-separated ids that describe the group. |
Events
| Event |
Payload |
Description |
update:open |
[value: boolean] |
Fired with the requested open value (supports v-model:open). |
openChange |
[value: boolean, previous: boolean, nativeEvent: Event | null] |
Fired after any distinct open-state request with the previous value and native event. |
Slots
| Slot |
Slot props |
Description |
default |
SplitButtonSlotState |
Primary action, trigger, and content. Receives the menu open state and availability. |
Source: src/families/actions/split-button/split-button-root.vue
Props
| Prop |
Type |
Default |
Description |
id |
string | null |
undefined |
Consumer-owned base id. null and undefined select a deterministic fallback. |
open |
boolean |
undefined |
Controlled open state of the menu. undefined selects uncontrolled behavior. |
defaultOpen |
boolean |
false |
Initial open state for uncontrolled use. |
modal |
boolean |
true |
Make outside content inert and lock document scroll while the menu is open. |
dir |
MenuDirection |
undefined |
Reading direction: flips submenu arrow keys and submenu placement. undefined inherits DirectionProvider/LocaleProvider, then "ltr". |
loop |
boolean |
false |
Wrap arrow-key navigation from the last item to the first and back. |
disabled |
boolean |
false |
Disable the primary action and the trigger, and close the menu when it becomes disabled. |
anchor |
SplitButtonAnchor |
"group" |
Element the menu is positioned against: the whole group or only the trigger. |
ariaLabel |
string |
undefined |
Accessible name of the group when no aria-labelledby supplies one. |
ariaLabelledby |
string |
undefined |
Space-separated ids that label the group. |
ariaDescribedby |
string |
undefined |
Space-separated ids that describe the group. |
Events
| Event |
Payload |
Description |
update:open |
[value: boolean] |
Fired with the requested open value (supports v-model:open). |
openChange |
[value: boolean, previous: boolean, nativeEvent: Event | null] |
Fired after any distinct open-state request with the previous value and native event. |
Slots
| Slot |
Slot props |
Description |
default |
SplitButtonSlotState |
Primary action, trigger, and content. Receives the menu open state and availability. |
Source: src/families/actions/split-button/split-button-action.vue
Props
| Prop |
Type |
Default |
Description |
type |
"button" | "reset" | "submit" |
"button" |
Native button submission behavior. |
disabled |
boolean |
false |
Disable the primary action while the trigger stays available. |
ariaLabel |
string |
undefined |
Accessible name when no visible label supplies one. |
Events
| Event |
Payload |
Description |
press |
[nativeEvent: MouseEvent] |
Fired after user activation reaches the enabled primary action. |
keydown |
[nativeEvent: KeyboardEvent] |
Fired before keyboard handling; call preventDefault() to skip the built-in keys. |
Slots
| Slot |
Slot props |
Description |
default |
SplitButtonSlotState |
Primary action contents. Receives the menu open state and availability. |
Exposed
| Member |
Type |
Description |
element |
HTMLButtonElement | null |
Rendered native button. |
focus |
(options?: FocusOptions) => void |
Move focus to the primary action. |
Source: src/families/actions/split-button/split-button-trigger.vue
Props
| Prop |
Type |
Default |
Description |
disabled |
boolean |
false |
Remove the trigger from activation and sequential keyboard focus. |
ariaLabel |
string |
undefined |
Accessible name of the menu button. Required when the trigger renders only an icon. |
openOn |
"click" | "pointerdown" |
"pointerdown" |
Primary mouse button event that toggles the menu. pointerdown opens on press (native menu feel, press-drag-release selection); click waits for release. |
Events
| Event |
Payload |
Description |
pointerdown |
[nativeEvent: PointerEvent] |
Fired before a primary-button pointer-down toggles the menu; preventable. |
keydown |
[nativeEvent: KeyboardEvent] |
Fired before keyboard handling; call preventDefault() to skip the built-in keys. |
Slots
| Slot |
Slot props |
Description |
default |
SplitButtonSlotState |
Trigger contents. Receives the menu open state and availability. |
Exposed
| Member |
Type |
Description |
element |
HTMLButtonElement | null |
Rendered native button. |
focus |
(options?: FocusOptions) => void |
Move focus to the trigger. |
Behavior
@vizejs/ui/split-button pairs a primary action with a WAI-ARIA APG menu button inside one
named role="group". It is built on the shared menu core (see ../../menus/menu/menu.behavior.md):
split-button-root.vue publishes data-menu-kind="split-button", split-button-action.vue is the
primary native button, and split-button-trigger.vue is the menu button. Items, groups, submenus,
and content are the Menu SFCs re-exported under SplitButton* names. Every row is proven by the
named test in split-button.test.ts or split-button-ssr.test.ts.
| # |
State |
Input |
Outcome |
Proven by |
| S1 |
closed |
render; press the primary action |
a named role="group" holds the action then the trigger; only the trigger carries aria-haspopup="menu"; pressing the action emits press and never opens the menu |
renders a named group with a primary action and a menu button |
| S2 |
closed / open |
primary mouse pointer-down on the trigger |
toggles; opening prevents trigger focus and focuses the content; aria-expanded, aria-controls, and every part's data-state follow |
mouse pointer-down on the trigger toggles the menu and focuses the content |
| S3 |
closed |
secondary button, ctrl-press, touch tap, click |
secondary and ctrl presses are ignored; touch taps and openOn="click" toggle on click |
secondary buttons and ctrl-clicks do not toggle; touch taps and openOn click toggle on click |
| S4 |
closed |
Enter / Space / ArrowDown / ArrowUp on trigger |
opens with the first or last item focused; Escape closes and returns focus to the trigger |
trigger keyboard opens on the first or last item and Escape returns focus to it |
| S5 |
closed |
Alt+ArrowDown on the primary action |
opens with the first item focused; closing returns focus to the action; plain ArrowDown is not consumed |
Alt+ArrowDown on the primary action opens the menu and focus returns to the action |
| S6 |
closed |
prevented keydown / pointerdown emits |
state is unchanged |
preventable keydown and pointerdown emits keep the menu closed |
| S7 |
open |
select an item, including from a submenu |
every level closes, focus returns to the trigger, and the primary press does not fire |
selecting an item closes every level and returns focus to the trigger |
| S8 |
open → disabled root |
disabled becomes true |
the menu closes; both buttons are natively disabled; the group publishes aria-disabled and data-disabled |
a disabled root disables both buttons and closes an open menu |
| S9 |
part disabled |
disabled on the action or on the trigger only |
the other part keeps working |
the action and the trigger can be disabled independently |
| S10 |
open |
anchor="group" (default) / anchor="trigger" |
the menu is positioned against the whole group or against the trigger |
the menu is positioned against the group by default and the trigger on request |
| S11 |
SSR |
two requests + hydrate |
byte-identical markup with data-menu-kind="split-button" and warning-free hydration |
renders identical split button markup across SSR requests and hydrates cleanly |
| S12 |
SSR closed / disabled |
render |
no menu, no aria-controls; a disabled root renders two disabled buttons |
a closed or disabled split button renders no menu on the server |
Public surface
| Kind |
Surface |
| parts |
root (group), action (primary button), trigger (menu button); menu parts are documented in the Menu contract |
| data |
data-vize-ui (split-button, split-button-action, split-button-trigger), data-state (open / closed), data-disabled |
| emits |
root: update:open, openChange; action: press, keydown; trigger: pointerdown, keydown |
| expose |
root: the Menu root surface plus disabled and element; action and trigger: element, focus() |
| naming |
the group needs aria-label or aria-labelledby; an icon-only trigger needs aria-label because it also names the menu through aria-labelledby |