Patterns
Split Button
A main action button with a second button that opens a menu of alternatives.
Patterns
A main action button with a second button that opens a menu of alternatives.
import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, SplitButton, toast } from 'ferry-ui'
import { CalendarClock, Download, FileSpreadsheet, FileText } from 'lucide-react'
export default function SplitButtonHero() {
return (
<SplitButton
icon={<Download />}
onClick={() => toast.success('CSV export started')}
menuLabel="More export options"
menu={
<>
<DropdownMenuLabel>Export as</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => toast.success('Excel export started')}>
<FileSpreadsheet /> Excel (.xlsx)
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => toast.success('PDF export started')}>
<FileText /> PDF
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => toast('Schedule an export')}>
<CalendarClock /> Schedule an export…
</DropdownMenuItem>
</>
}
>
Export CSV
</SplitButton>
)
}onSelect of the item.Import the component. Give the label as the child and the items in menu.
import { DropdownMenuItem, SplitButton } from 'ferry-ui'
<SplitButton
onClick={() => {}}
menuLabel=""
menu={
<>
<DropdownMenuItem onSelect={() => {}} />
</>
}
>
{/* the label of the main action */}
</SplitButton>The menu prop takes the items, the labels, the separators and the groups of Dropdown Menu. Run each action in onSelect of its item.
The variant prop sets the look of the two buttons. A split button has five of the variants of Button.
import { DropdownMenuItem, SplitButton } from 'ferry-ui'
const VARIANTS = [
{ variant: 'default', label: 'Default' },
{ variant: 'primary', label: 'Primary' },
{ variant: 'outline', label: 'Outline' },
{ variant: 'destructive', label: 'Destructive' },
{ variant: 'warning', label: 'Warning' },
] as const
export default function SplitButtonVariants() {
return (
<>
{VARIANTS.map(({ variant, label }) => (
<SplitButton
key={variant}
variant={variant}
menuLabel={`More actions, ${variant}`}
menu={
<>
<DropdownMenuItem>First alternative</DropdownMenuItem>
<DropdownMenuItem>Second alternative</DropdownMenuItem>
</>
}
>
{label}
</SplitButton>
))}
</>
)
}The size prop sets the height of the two buttons.
import { DropdownMenuItem, SplitButton } from 'ferry-ui'
const SIZES = [
{ size: 'tiny', label: 'Tiny' },
{ size: 'sm', label: 'Small' },
{ size: 'md', label: 'Medium' },
{ size: 'lg', label: 'Large' },
] as const
export default function SplitButtonSizes() {
return (
<>
{SIZES.map(({ size, label }) => (
<SplitButton
key={size}
size={size}
menuLabel={`More actions, ${size}`}
menu={
<>
<DropdownMenuItem>First alternative</DropdownMenuItem>
<DropdownMenuItem>Second alternative</DropdownMenuItem>
</>
}
>
{label}
</SplitButton>
))}
</>
)
}| Size | Height |
|---|---|
tiny | 26px |
sm | 30px (default) |
md | 34px |
lg | 38px |
Set loading while the main action is in progress. The main button shows a spinner. The two buttons are disabled.
import * as React from 'react'
import { DropdownMenuItem, SplitButton, toast } from 'ferry-ui'
import { CalendarClock, Save, Send } from 'lucide-react'
export default function SplitButtonLoading() {
const [publishing, setPublishing] = React.useState(false)
function publish() {
setPublishing(true)
// Stands for a request to the server.
window.setTimeout(() => {
setPublishing(false)
toast.success('Page published')
}, 1500)
}
return (
<SplitButton
variant="primary"
icon={<Send />}
loading={publishing}
onClick={publish}
menuLabel="More publish options"
menu={
<>
<DropdownMenuItem onSelect={() => toast('Schedule the page')}>
<CalendarClock /> Schedule for later…
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => toast.success('Draft saved')}>
<Save /> Save as draft
</DropdownMenuItem>
</>
}
>
{publishing ? 'Publishing…' : 'Publish'}
</SplitButton>
)
}disabled disables the two buttons. If the alternatives still apply, set menuDisabled to false. Tell the user why the main action is not available.
Add a billing email to send this invoice.
import { DropdownMenuItem, SplitButton, toast } from 'ferry-ui'
import { Download, Link2, Mail } from 'lucide-react'
export default function SplitButtonMenuAvailable() {
return (
<>
<p id="send-invoice-reason" className="text-[13px] text-foreground-light">
Add a billing email to send this invoice.
</p>
<SplitButton
icon={<Mail />}
disabled
// The main action is not available, but its alternatives are.
menuDisabled={false}
menuLabel="More invoice actions"
actionProps={{ 'aria-describedby': 'send-invoice-reason' }}
menu={
<>
<DropdownMenuItem onSelect={() => toast.success('PDF download started')}>
<Download /> Download PDF
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => toast.success('Payment link copied')}>
<Link2 /> Copy payment link
</DropdownMenuItem>
</>
}
>
Send invoice
</SplitButton>
</>
)
}To open the menu from the code, pass open and onOpenChange. Update the state in onOpenChange, or the menu cannot close.
import * as React from 'react'
import { Button, DropdownMenuItem, SplitButton, toast } from 'ferry-ui'
export default function SplitButtonControlled() {
const [open, setOpen] = React.useState(false)
return (
<>
<Button variant="ghost" onClick={() => setOpen(true)}>
Show the other formats
</Button>
<SplitButton
open={open}
// Always update the state here, or the menu cannot close.
onOpenChange={setOpen}
onClick={() => toast.success('CSV export started')}
menuLabel="More export options"
menu={
<>
<DropdownMenuItem onSelect={() => toast.success('Excel export started')}>Excel (.xlsx)</DropdownMenuItem>
<DropdownMenuItem onSelect={() => toast.success('PDF export started')}>PDF</DropdownMenuItem>
</>
}
>
Export CSV
</SplitButton>
</>
)
}With type="submit", the main button submits the form around it. Enter in a field then runs the main action.
import { Button, DropdownMenuItem, Field, Input, SplitButton, toast } from 'ferry-ui'
import { CalendarClock, Save } from 'lucide-react'
export default function SplitButtonSubmit() {
return (
<form
className="mx-auto flex w-full max-w-sm flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
toast.success('Page published')
}}
>
<Field label="Title">
<Input defaultValue="Release notes for March" />
</Field>
<div className="flex items-center justify-end gap-2">
<Button type="reset">Cancel</Button>
{/* Enter in the field submits the form: it runs the main action. */}
<SplitButton
type="submit"
variant="primary"
menuLabel="More publish options"
menu={
<>
<DropdownMenuItem onSelect={() => toast('Schedule the page')}>
<CalendarClock /> Schedule for later…
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => toast.success('Draft saved')}>
<Save /> Save as draft
</DropdownMenuItem>
</>
}
>
Publish
</SplitButton>
</div>
</form>
)
}If the user cannot undo the action of an item, give the item variant="destructive". Open the confirmation from its onSelect. A className in menuProps sets the width of the menu.
import * as React from 'react'
import { ConfirmDialog, DropdownMenuItem, DropdownMenuSeparator, SplitButton, toast } from 'ferry-ui'
import { Ban, RefreshCw } from 'lucide-react'
export default function SplitButtonDestructive() {
const [open, setOpen] = React.useState(false)
const [all, setAll] = React.useState(false)
function ask(allKeys: boolean) {
setAll(allKeys)
setOpen(true)
}
return (
<>
<SplitButton
variant="destructive"
onClick={() => ask(false)}
menuLabel="More revoke options"
menuProps={{ className: 'w-64' }}
menu={
<>
<DropdownMenuItem onSelect={() => toast.success('Key replaced')}>
<RefreshCw /> Revoke and create a replacement
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onSelect={() => ask(true)}>
<Ban /> Revoke all API keys…
</DropdownMenuItem>
</>
}
>
Revoke key
</SplitButton>
<ConfirmDialog
open={open}
onOpenChange={setOpen}
title={all ? 'Revoke all API keys?' : 'Revoke API key “Analytics export”?'}
description="Requests that use a revoked key start to fail immediately. You cannot undo this."
confirmLabel={all ? 'Revoke all keys' : 'Revoke key'}
onConfirm={() => {
toast.success(all ? 'All API keys revoked' : 'API key revoked')
}}
/>
</>
)
}menuLabel is the accessible name of the menu button. The default is "More actions".menuLabel: "More publish options".SplitButton also accepts each attribute of the <div> element. They go to the root group.
| Prop | Type | Default |
|---|---|---|
childrenRequired | ReactNode | - |
Label of the main action button. Name the action ("Publish", "Export CSV"). | ||
menuRequired | ReactNode | - |
The menu opened by the chevron: DropdownMenuItem / Label / Separator / Group elements (run each action in the item's | ||
onClick | MouseEventHandler<HTMLButtonElement> | - |
Runs the main action when the left button is clicked. | ||
menuLabel | string | More actions |
Accessible name of the icon-only chevron trigger. Default "More actions"; name the main action ("More publish options") when several split buttons share a screen. | ||
variant | "default" | "primary" | "outline" | "destructive" | "warning" | default |
Button style of both halves. Default | ||
size | "tiny" | "sm" | "md" | "lg" | sm |
Height of both halves. Default | ||
icon | ReactNode | - |
Leading icon of the main button (replaced by a spinner while | ||
loading | boolean | false |
The main action is in flight: spinner on the main button, both halves disabled (see | ||
disabled | boolean | false |
Disables the main button, and the menu trigger unless | ||
menuDisabled | boolean | - |
Disables the menu trigger on its own. Defaults to | ||
type | "button" | "submit" | "reset" | button |
Native type of the main button. Default | ||
open | boolean | - |
Controlled open state of the menu. | ||
defaultOpen | boolean | - |
Initial open state of the menu when uncontrolled. | ||
onOpenChange | ((open: boolean) => void) | - |
Called when the menu opens or closes. | ||
menuProps | SplitButtonMenuProps | - |
Props of the menu panel: | ||
actionProps | SplitButtonActionProps | - |
Extra props for the main button ( | ||