SplitButton

A primary action joined to a menu of alternatives, with separate click targets.

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>
vue

Usage

import { SplitButton, DropdownMenuItem } from '@hina-ui/vue'
ts

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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>
vue

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.

Publishing validates the title. Saving a draft allows unfinished content.

<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>
vue

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>
vue

Behavior and accessibility

  • Tab reaches the primary action and menu trigger separately. On the primary button, Enter or Space runs the action and ArrowDown opens the menu. A link keeps native link keyboard behavior.
  • On the menu trigger, Enter, Space, or ArrowDown opens the menu. Keyboard opening focuses the first available item. Menu navigation, first/last item keys, and typeahead follow DropdownMenu.
  • Escape closes the menu and returns focus to its trigger. Selecting a regular item also closes it; use @select.prevent on that item to keep the menu open.
  • Give menu-label a name describing the alternatives. It defaults to the current locale’s “More actions”. label optionally names the whole group.
  • #icon and #trailing belong to the primary action. The menu arrow uses DisclosureIcon and 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