Article ready to publish
<script setup lang="ts">
import { ref } from 'vue'
import { Clock, FilePen, Send } from '@lucide/vue'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/vue'
const result = ref('Article ready to publish')
</script>
<template>
<Stack align="center" gap="sm">
<SplitButton
label="Article actions"
menu-label="More publishing options"
@click="result = 'Action: publish now'"
>
<template #icon><Send /></template>
Publish now
<template #content>
<DropdownMenuItem @select="result = 'Action: save draft'">
<template #icon><FilePen /></template>
Save draft
</DropdownMenuItem>
<DropdownMenuItem @select="result = 'Action: schedule'">
<template #icon><Clock /></template>
Schedule
</DropdownMenuItem>
</template>
</SplitButton>
<Text role="status" size="sm" tone="muted">{{ result }}</Text>
</Stack>
</template>
Usage
import { SplitButton, DropdownMenuItem } from '@hina-ui/vue'
The primary button emits click; the trailing arrow opens the menu. Place alternatives in #content and handle each DropdownMenuItem through select. Use this when one action is the clear default, such as publishing with a save-draft alternative or exporting in a chosen format.
<SplitButton menu-label="More saving options" @click="save">
Save
<template #content>
<DropdownMenuItem @select="saveCopy">Save a copy</DropdownMenuItem>
</template>
</SplitButton>
Use ButtonGroup for equally common actions, or DropdownMenu when there is no default action. Selecting a menu item does not automatically change the primary action; the application decides whether to remember the choice.
Examples
Variants and tones
Both buttons share variant and tone, with joined inner corners. Solid, soft, and ghost variants include a divider; the outline variant shares one border at the join.
Each style keeps its primary action separate from the menu
<script setup lang="ts">
import { ref } from 'vue'
import { DropdownMenuItem, Flex, SplitButton, Stack, Text } from '@hina-ui/vue'
const variants = ['solid', 'soft', 'outline', 'ghost'] as const
const result = ref('Each style keeps its primary action separate from the menu')
</script>
<template>
<Stack align="center" gap="lg">
<Flex wrap justify="center" gap="lg">
<SplitButton
v-for="variant in variants"
:key="variant"
:variant="variant"
:menu-label="`${variant} options`"
@click="result = `${variant}: saved`"
>
{{ variant }}
<template #content>
<DropdownMenuItem @select="result = `${variant}: save a copy`">
Save a copy
</DropdownMenuItem>
</template>
</SplitButton>
</Flex>
<Flex wrap justify="center" gap="lg">
<SplitButton
tone="neutral"
menu-label="Download options"
@click="result = 'Action: download'"
>
Download
<template #content>
<DropdownMenuItem @select="result = 'Action: copy link'">Copy link</DropdownMenuItem>
</template>
</SplitButton>
<SplitButton
tone="danger"
variant="soft"
menu-label="Archive options"
@click="result = 'Action: archive'"
>
Archive
<template #content>
<DropdownMenuItem @select="result = 'Action: move to trash'">
Move to trash
</DropdownMenuItem>
</template>
</SplitButton>
</Flex>
<Text role="status" size="sm" tone="muted">{{ result }}</Text>
</Stack>
</template>
Sizes and rounded ends
size sets the height of both buttons. pill rounds the outer ends of the group. Neither joined button scales when pressed.
Both buttons share the same height
<script setup lang="ts">
import { ref } from 'vue'
import { DropdownMenuItem, Flex, SplitButton, Stack, Text } from '@hina-ui/vue'
const sizes = ['sm', 'md', 'lg'] as const
const result = ref('Both buttons share the same height')
</script>
<template>
<Stack align="center">
<Flex wrap align="center" justify="center" gap="lg">
<SplitButton
v-for="size in sizes"
:key="size"
:size="size"
:menu-label="`${size} options`"
@click="result = `${size}: saved`"
>
Save
<template #content>
<DropdownMenuItem @select="result = `${size}: save a copy`">Save a copy</DropdownMenuItem>
</template>
</SplitButton>
<SplitButton
pill
variant="outline"
menu-label="Rounded button options"
@click="result = 'Action: save'"
>
Rounded
<template #content>
<DropdownMenuItem @select="result = 'Action: save a copy'">Save a copy</DropdownMenuItem>
</template>
</SplitButton>
</Flex>
<Text role="status" size="sm" tone="muted">{{ result }}</Text>
</Stack>
</template>
Loading and disabled states
loading displays a progress indicator, disables both buttons, and closes an open menu. disabled disables the whole group. Use primary-disabled or menu-disabled to disable either side separately, such as allowing a draft to be saved when publishing is unavailable.
Disable either action independently
<script setup lang="ts">
import { ref } from 'vue'
import { DropdownMenuItem, FormField, SplitButton, Stack, Switch, Text } from '@hina-ui/vue'
const loading = ref(false)
const disabled = ref(false)
const primaryDisabled = ref(false)
const menuDisabled = ref(false)
const result = ref('Disable either action independently')
</script>
<template>
<Stack class="w-full max-w-xs" gap="lg">
<Stack gap="sm">
<FormField label="Loading" orientation="horizontal"><Switch v-model="loading" /></FormField>
<FormField label="Disable both" orientation="horizontal">
<Switch v-model="disabled" />
</FormField>
<FormField label="Disable primary action" orientation="horizontal">
<Switch v-model="primaryDisabled" />
</FormField>
<FormField label="Disable menu" orientation="horizontal">
<Switch v-model="menuDisabled" />
</FormField>
</Stack>
<Stack align="center" gap="sm">
<SplitButton
:loading="loading"
:disabled="disabled"
:primary-disabled="primaryDisabled"
:menu-disabled="menuDisabled"
menu-label="Save options"
@click="result = 'Action: save'"
>
Save changes
<template #content>
<DropdownMenuItem @select="result = 'Action: save a copy'">Save a copy</DropdownMenuItem>
</template>
</SplitButton>
<Text role="status" size="sm" tone="muted">{{ result }}</Text>
</Stack>
</Stack>
</template>
Remembering the chosen format
Compose the menu with existing DropdownMenu children, including groups, radio items, checkbox items, and submenus. Here the application stores the format and uses it for the primary button’s label and action.
The primary action uses the selected format
<script setup lang="ts">
import { ref } from 'vue'
import { Download } from '@lucide/vue'
import {
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
SplitButton,
Stack,
Text,
} from '@hina-ui/vue'
const format = ref('CSV')
const result = ref('The primary action uses the selected format')
</script>
<template>
<Stack align="center" gap="sm">
<SplitButton
variant="outline"
menu-label="Export format"
@click="result = `Action: export ${format}`"
>
<template #icon><Download /></template>
Export {{ format }}
<template #content>
<DropdownMenuLabel>File format</DropdownMenuLabel>
<DropdownMenuRadioGroup v-model="format">
<DropdownMenuRadioItem value="CSV">CSV</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="JSON">JSON</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="XLSX">Excel (.xlsx)</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</template>
</SplitButton>
<Text role="status" size="sm" tone="muted">{{ result }}</Text>
</Stack>
</template>
Full width
block makes the primary button fill the available space while the arrow stays square. Long default labels truncate. Both class and style apply to the group container.
The primary action grows; the menu button stays square
<script setup lang="ts">
import { ref } from 'vue'
import { Download } from '@lucide/vue'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/vue'
const result = ref('The primary action grows; the menu button stays square')
</script>
<template>
<Stack class="w-full max-w-xs" gap="sm">
<SplitButton
block
menu-label="More report actions"
@click="result = 'Action: export the full report'"
>
<template #icon><Download /></template>
Export the complete monthly activity report for all projects
<template #content>
<DropdownMenuItem @select="result = 'Action: export selection'">
Export selection
</DropdownMenuItem>
<DropdownMenuItem @select="result = 'Action: copy report link'">
Copy report link
</DropdownMenuItem>
</template>
</SplitButton>
<Text role="status" size="sm" tone="muted">{{ result }}</Text>
</Stack>
</template>
Form submission
type="submit" applies only to the primary button. The menu trigger always uses type="button"; opening the menu or selecting an item does not accidentally submit the form. Native attributes such as name, value, and form pass through to the primary button.
This example simulates asynchronous publishing. Publishing runs Form validation, while saving a draft is a separate menu action that accepts an unfinished title.
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { DropdownMenuItem, Form, FormField, Input, SplitButton, Text } from '@hina-ui/vue'
const values = reactive({ title: '' })
const result = ref('')
const schema = v.object({
title: v.pipe(v.string(), v.trim(), v.minLength(2, 'Enter at least 2 characters')),
})
async function publish(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 800))
result.value = `Published: ${(data as { title: string }).title}`
}
function saveDraft() {
result.value = `Draft saved: ${values.title.trim() || 'Untitled article'}`
}
</script>
<template>
<Form
v-slot="{ submitting }"
:values="values"
:rules="schema"
class="w-full max-w-sm"
@submit="publish"
>
<FormField
name="title"
label="Article title"
description="Publishing validates the title. Saving a draft allows unfinished content."
required
>
<Input v-model="values.title" placeholder="Enter a title" />
</FormField>
<SplitButton
type="submit"
:loading="submitting"
menu-label="More saving options"
class="self-start"
>
Publish article
<template #content>
<DropdownMenuItem @select="saveDraft">Save draft</DropdownMenuItem>
</template>
</SplitButton>
<Text v-if="result" role="status" size="sm" tone="muted">{{ result }}</Text>
</Form>
</template>
Right-to-left layout
dir="rtl" controls button order, joined corners, and menu direction together. The menu aligns to the logical end of the whole button group.
The menu button follows the writing direction
<script setup lang="ts">
import { ref } from 'vue'
import { DropdownMenuItem, SplitButton, Stack, Text } from '@hina-ui/vue'
const result = ref('The menu button follows the writing direction')
</script>
<template>
<Stack align="center" gap="sm">
<SplitButton dir="rtl" menu-label="خيارات النشر" @click="result = 'Action: publish'">
نشر المقال
<template #content>
<DropdownMenuItem @select="result = 'Action: save draft'">حفظ المسودة</DropdownMenuItem>
<DropdownMenuItem @select="result = 'Action: schedule'">جدولة النشر</DropdownMenuItem>
</template>
</SplitButton>
<Text role="status" size="sm" tone="muted">{{ result }}</Text>
</Stack>
</template>
A link as the primary action
as changes only the primary button. Use your router’s link component for in-app navigation; attributes such as to pass through to it. The menu trigger remains a button.
<SplitButton
:as="NuxtLink"
:to="localePath('/components/button')"
menu-label="Related documentation"
>
View Button
<template #content>
<DropdownMenuItem @select="navigateTo(localePath('/components/button-group'))">
View ButtonGroup
</DropdownMenuItem>
</template>
</SplitButton>
Behavior and accessibility
Tabreaches the primary action and menu trigger separately. On the primary button,Enteror Space runs the action andArrowDownopens the menu. A link keeps native link keyboard behavior.- On the menu trigger,
Enter, Space, orArrowDownopens the menu. Keyboard opening focuses the first available item. Menu navigation, first/last item keys, and typeahead followDropdownMenu. Escapecloses the menu and returns focus to its trigger. Selecting a regular item also closes it; use@select.preventon that item to keep the menu open.- Give
menu-labela name describing the alternatives. It defaults to the current locale’s “More actions”.labeloptionally names the whole group. #iconand#trailingbelong to the primary action. The menu arrow usesDisclosureIconand follows the open state.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'solid' | 'soft' | 'outline' | 'ghost' | 'solid' | Shared appearance |
tone | 'accent' | 'neutral' | 'danger' | 'accent' | Shared tone |
size | 'xs' | 'sm' | 'md' | 'lg' | 'md' | Button size |
block | boolean | false | Fill the container width |
pill | boolean | false | Fully rounded outer ends |
ripple | boolean | true | Enable ripple feedback |
loading | boolean | false | Show progress and disable both buttons |
disabled | boolean | false | Disable both buttons |
primaryDisabled | boolean | false | Disable only the primary action |
menuDisabled | boolean | false | Disable only the menu |
as | string | Component | 'button' | Primary action element or component |
type | 'button' | 'submit' | 'reset' | 'button' | Primary button type |
label | string | — | Accessible name for the group |
menuLabel | string | Localized “More actions” | Accessible name for the menu trigger and menu |
modal | boolean | true | Restrict outside interaction while the menu is open |
dir | 'ltr' | 'rtl' | Inherited | Button and menu direction |
side | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | Preferred menu side |
align | 'start' | 'center' | 'end' | 'end' | Menu alignment relative to the whole group |
sideOffset | number | 8 | Gap between the group and menu |
class | string | — | Group container classes |
style | StyleValue | — | Group container styles |
menuClass | string | — | Menu content classes |
Other attributes and listeners, including id, aria-label, href, to, and keydown, pass through to the primary button.
Models
| Model | Type | Default | Description |
|---|---|---|---|
v-model:open | boolean | false | Menu open state; closes while loading or when the menu is disabled |
Events
| Event | Payload | Description |
|---|---|---|
click | MouseEvent | Primary action click; menu interactions do not emit this event |
update:open | boolean | Menu open state changed |
Slots
| Slot | Props | Description |
|---|---|---|
default | — | Primary action label |
icon | — | Leading primary action icon |
trailing | — | Trailing primary action content |
content | { close: () => void } | Menu content, usually composed of DropdownMenuItem and related components |
Expose
| Method | Type | Description |
|---|---|---|
focus | () => void | Focus the primary action when enabled |
openMenu | () => Promise<void> | Focus the menu trigger and open the menu; does nothing when unavailable |
closeMenu | () => void | Close the menu |