Components
Split Button
A primary action joined to a disclosure toggle that reveals related secondary actions. Unlike a dropdown menu, the main button does something on its own — the menu is just the alternatives. Native Popover API + the APG menu keyboard contract.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/split-button.json2. Use it
import { SplitButton, SplitButtonMenu, SplitButtonItem }
from "@/components/ui/split-button"
<SplitButton label="Save" menuId="save-actions" hx-post="/save" />
<SplitButtonMenu id="save-actions">
<SplitButtonItem hx-post="/save-draft">Save draft</SplitButtonItem>
<SplitButtonItem hx-post="/save-template">Save as template</SplitButtonItem>
</SplitButtonMenu>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Split Button — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A primary action <button> joined to a small disclosure toggle. The toggle
// opens a popup listing related secondary actions. Distinct from a dropdown
// menu: there is always a DEFAULT primary action that fires on its own click,
// independent of the menu.
//
// Anatomy / interaction modelled on Adam Argyle's web.dev split-button pattern
// repos/web.dev/src/site/content/en/patterns/components/split-buttons/index.md
// repos/web.dev/src/site/content/en/patterns/components/split-buttons/assets/body.html
// (joined primary button + a popup button carrying aria-haspopup; secondary
// actions live in a <ul> of <button>s). We translate its hover/focus CSS to
// theme-token Tailwind and drop its custom JS in favour of native primitives.
//
// Built on the native HTML Popover API (popover + popovertarget), so the
// platform gives us light-dismiss, ESC, top-layer rendering and focus
// restoration to the toggle — same approach as registry/ui/dropdown-menu.tsx.
// repos/mdn/files/en-us/web/api/popover_api/
// repos/mdn/files/en-us/web/html/reference/attributes/popovertarget/
//
// The popup carries data-slot="dropdown-menu" so it reuses the existing APG
// menu keyboard contract already shipped in public/site.js (ArrowUp/Down,
// Home/End, type-to-find, Enter/Space activate, click closes). A tiny
// split-button block in public/site.js mirrors the popup's open state onto
// the toggle's aria-expanded — the APG menu-button requirement the dropdown
// contract doesn't cover.
// repos/aria-practices/content/patterns/menu-button/menu-button-pattern.html
// ("aria-haspopup" on the trigger; "aria-expanded" true when displayed)
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/menu_role/
//
// htmx attrs / data-* / aria-* on the primary action ride through via {...rest}.
// repos/htmx/www/reference.md (hx-get/hx-post/hx-target/hx-swap/…)
export type SplitButtonVariant = "default" | "secondary" | "destructive" | "outline"
export type SplitButtonSize = "sm" | "default" | "lg"
export type SplitButtonSide = "top" | "right" | "bottom" | "left"
// The joined group. Rounded on the outside; the two children square off their
// shared inner edge so they read as one control with a divider.
const rootClasses =
"inline-flex items-stretch rounded-md shadow-xs outline-none isolate"
// Both segments share the same visual skin; only their inner radii differ.
const segmentBase =
"inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors " +
"focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"disabled:pointer-events-none disabled:opacity-50 " +
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " +
// htmx v4 in-flight affordance, mirroring registry/ui/button.tsx.
"[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70"
const variants: Record<SplitButtonVariant, string> = {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border bg-background text-foreground hover:bg-accent hover:text-accent-foreground",
}
// Per-size geometry for the primary action.
const actionSizes: Record<SplitButtonSize, string> = {
sm: "h-8 gap-1.5 px-3 text-xs has-[>svg]:px-2.5",
default: "h-9 gap-2 px-4 text-sm has-[>svg]:px-3",
lg: "h-10 gap-2 px-6 text-sm has-[>svg]:px-4",
}
// The toggle is square; width tracks its height per size.
const toggleSizes: Record<SplitButtonSize, string> = {
sm: "w-8",
default: "w-9",
lg: "w-10",
}
// The hairline between the two segments. On a filled variant we tint the
// foreground colour down; on outline the shared border already divides them.
const dividerByVariant: Record<SplitButtonVariant, string> = {
default: "border-l border-primary-foreground/20",
secondary: "border-l border-foreground/15",
destructive: "border-l border-white/25",
outline: "border-l-0",
}
type SplitButtonProps = PropsWithChildren<{
// Required — the popup id, matched by popovertarget on the toggle.
menuId: string
// Visible label of the primary action.
label?: string
variant?: SplitButtonVariant
size?: SplitButtonSize
// Which side of the toggle the popup opens on (positioned by site.js).
side?: SplitButtonSide
// Accessible name for the disclosure toggle (it has only an icon).
toggleLabel?: string
disabled?: boolean
class?: ClassValue
type?: "button" | "submit" | "reset"
// htmx + form attrs ride onto the PRIMARY action button.
"hx-get"?: string
"hx-post"?: string
"hx-put"?: string
"hx-patch"?: string
"hx-delete"?: string
"hx-target"?: string
"hx-swap"?: string
"hx-trigger"?: string
"hx-confirm"?: string
name?: string
value?: string
}>
// Down-chevron, mirrored from the web.dev pattern's popup-button glyph.
function Chevron() {
return (
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
</svg>
)
}
export function SplitButton(props: SplitButtonProps) {
const {
children,
menuId,
label,
variant = "default",
size = "default",
side = "bottom",
toggleLabel = "More actions",
disabled,
class: className,
type = "button",
...rest
} = props
return (
<div data-slot="split-button" class={cn(rootClasses, className)}>
<button
type={type}
disabled={disabled}
data-slot="split-button-action"
class={cn(
segmentBase,
variants[variant],
actionSizes[size],
// Square off the toggle-facing edge.
"rounded-l-md rounded-r-none",
)}
{...rest}
>
{label ?? children}
</button>
<button
type="button"
disabled={disabled}
popovertarget={menuId}
popovertargetaction="toggle"
aria-haspopup="menu"
aria-expanded="false"
aria-label={toggleLabel}
data-slot="split-button-toggle"
class={cn(
segmentBase,
variants[variant],
toggleSizes[size],
dividerByVariant[variant],
"rounded-r-md rounded-l-none",
)}
>
<Chevron />
</button>
</div>
)
}
type SplitButtonMenuProps = PropsWithChildren<{
// Required — matches popovertarget on the toggle.
id: string
side?: SplitButtonSide
class?: ClassValue
}>
// The popup of secondary actions. role="menu" + data-slot="dropdown-menu" so
// the existing public/site.js menu keyboard contract drives it for free.
export function SplitButtonMenu(props: SplitButtonMenuProps) {
const { id, side = "bottom", class: className, children } = props
return (
<ul
id={id}
popover="auto"
role="menu"
data-slot="dropdown-menu"
data-side={side}
class={cn(
"z-50 m-0 min-w-[12rem] list-none rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"[&:not(:popover-open)]:hidden",
"[&:popover-open]:animate-[scn-popover-in_120ms_ease-out]",
className,
)}
>
{children}
</ul>
)
}
type SplitButtonItemProps = PropsWithChildren<{
href?: string
onclick?: string
disabled?: boolean
variant?: "default" | "destructive"
class?: ClassValue
"hx-get"?: string
"hx-post"?: string
"hx-put"?: string
"hx-patch"?: string
"hx-delete"?: string
"hx-target"?: string
"hx-swap"?: string
"hx-trigger"?: string
"hx-confirm"?: string
}>
// One secondary action. Mirrors DropdownMenuItem so it inherits the same
// role="menuitem" keyboard + click-closes behaviour from site.js.
export function SplitButtonItem(props: SplitButtonItemProps) {
const { children, href, onclick, disabled, variant = "default", class: className, ...rest } = props
const itemBase =
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none " +
"focus:bg-accent focus:text-accent-foreground " +
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 " +
"[&_svg]:size-4 [&_svg]:shrink-0"
const variantCls =
variant === "destructive"
? "text-destructive focus:bg-destructive/10 focus:text-destructive"
: ""
const Tag: any = href ? "a" : "button"
return (
<li role="none" class="contents">
<Tag
role="menuitem"
type={href ? undefined : "button"}
tabindex={-1}
href={href}
onclick={onclick}
data-slot="split-button-item"
data-disabled={disabled ? "true" : undefined}
class={cn(itemBase, variantCls, className)}
{...rest}
>
{children}
</Tag>
</li>
)
}
1. Save the file
Copy split-button.html into templates/components/.
2. Use it
{% from "components/split-button.html" import
split_button, split_button_menu_open, split_button_menu_close,
split_button_item %}
{{ split_button("Save", menu_id="save-actions", hx_post="/save") }}
{{ split_button_menu_open(id="save-actions") }}
{{ split_button_item("Save draft", hx_post="/save-draft") }}
{{ split_button_item("Save as template", hx_post="/save-template") }}
{{ split_button_menu_close() }}View source
{# SplitButton macros — shadcn-htmx, htmx v4 + Tailwind v4.
Primary action <button> joined to a disclosure toggle that opens a popup
of secondary actions. Native [popover] + the APG menu keyboard contract
from public/site.js (the popup carries data-slot="dropdown-menu"); a tiny
split-button block in site.js mirrors open state onto aria-expanded.
Built on Adam Argyle's web.dev split-button pattern. #}
{%- set _seg -%}
inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70
{%- endset -%}
{%- set _variants = {
"default": "bg-primary text-primary-foreground hover:bg-primary/90",
"secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"destructive": "bg-destructive text-white hover:bg-destructive/90",
"outline": "border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
} -%}
{%- set _action_sizes = {
"sm": "h-8 gap-1.5 px-3 text-xs has-[>svg]:px-2.5",
"default": "h-9 gap-2 px-4 text-sm has-[>svg]:px-3",
"lg": "h-10 gap-2 px-6 text-sm has-[>svg]:px-4"
} -%}
{%- set _toggle_sizes = { "sm": "w-8", "default": "w-9", "lg": "w-10" } -%}
{%- set _dividers = {
"default": "border-l border-primary-foreground/20",
"secondary": "border-l border-foreground/15",
"destructive": "border-l border-white/25",
"outline": "border-l-0"
} -%}
{% macro split_button(label, menu_id, variant="default", size="default", toggle_label="More actions", disabled=false, class_="", **attrs) %}
<div data-slot="split-button" class="inline-flex items-stretch rounded-md shadow-xs outline-none isolate {{ class_ }}">
<button type="button"{% if disabled %} disabled{% endif %}
data-slot="split-button-action"
class="{{ _seg }} {{ _variants[variant] }} {{ _action_sizes[size] }} rounded-l-md rounded-r-none"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label }}</button>
<button type="button"{% if disabled %} disabled{% endif %}
popovertarget="{{ menu_id }}" popovertargetaction="toggle"
aria-haspopup="menu" aria-expanded="false" aria-label="{{ toggle_label }}"
data-slot="split-button-toggle"
class="{{ _seg }} {{ _variants[variant] }} {{ _toggle_sizes[size] }} {{ _dividers[variant] }} rounded-r-md rounded-l-none">
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="size-4"><path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" /></svg>
</button>
</div>
{% endmacro %}
{% macro split_button_menu_open(id, side="bottom", extra_class="") %}
<ul id="{{ id }}" popover="auto" role="menu"
data-slot="dropdown-menu" data-side="{{ side }}"
class="z-50 m-0 min-w-[12rem] list-none rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out] {{ extra_class }}">
{% endmacro %}
{% macro split_button_menu_close() %}</ul>{% endmacro %}
{% macro split_button_item(label, href=none, onclick=none, disabled=false, variant="default", extra_class="", **attrs) %}
{%- set base -%}
relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0
{%- endset -%}
{%- set destructive -%}
{% if variant == "destructive" %}text-destructive focus:bg-destructive/10 focus:text-destructive{% endif %}
{%- endset -%}
<li role="none" class="contents">
{% if href %}
<a role="menuitem" tabindex="-1" href="{{ href }}"
data-slot="split-button-item"
{%- if disabled %} data-disabled="true"{% endif %}
class="{{ base }} {{ destructive }} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label }}</a>
{% else %}
<button type="button" role="menuitem" tabindex="-1"
{% if onclick %}onclick="{{ onclick }}"{% endif %}
data-slot="split-button-item"
{%- if disabled %} data-disabled="true"{% endif %}
class="{{ base }} {{ destructive }} {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ label }}</button>
{% endif %}
</li>
{% endmacro %}
1. Save the file
Add split-button.tmpl alongside your templates.
2. Use it
{{template "split_button" (dict "Label" "Save" "MenuID" "save-actions")}}
{{template "split_button_menu" (dict "ID" "save-actions" "Body" (htmlSafe `
{{template "split_button_item" (dict "Label" "Save draft")}}
{{template "split_button_item" (dict "Label" "Save as template")}}`))}}View source
{{/* SplitButton templates — shadcn-htmx, htmx v4 + Tailwind v4.
Primary action <button> joined to a disclosure toggle that opens a popup
of secondary actions. Native [popover] + the APG menu keyboard contract
from public/site.js (the popup carries data-slot="dropdown-menu"); a tiny
split-button block in site.js mirrors open state onto aria-expanded.
Built on Adam Argyle's web.dev split-button pattern. */}}
{{define "split_button"}}
{{- $variant := or .Variant "default" -}}
{{- $size := or .Size "default" -}}
{{- $toggleLabel := or .ToggleLabel "More actions" -}}
{{- $seg := "inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70" -}}
{{- $variants := dict "default" "bg-primary text-primary-foreground hover:bg-primary/90" "secondary" "bg-secondary text-secondary-foreground hover:bg-secondary/80" "destructive" "bg-destructive text-white hover:bg-destructive/90" "outline" "border bg-background text-foreground hover:bg-accent hover:text-accent-foreground" -}}
{{- $actionSizes := dict "sm" "h-8 gap-1.5 px-3 text-xs has-[>svg]:px-2.5" "default" "h-9 gap-2 px-4 text-sm has-[>svg]:px-3" "lg" "h-10 gap-2 px-6 text-sm has-[>svg]:px-4" -}}
{{- $toggleSizes := dict "sm" "w-8" "default" "w-9" "lg" "w-10" -}}
{{- $dividers := dict "default" "border-l border-primary-foreground/20" "secondary" "border-l border-foreground/15" "destructive" "border-l border-white/25" "outline" "border-l-0" -}}
<div data-slot="split-button" class="inline-flex items-stretch rounded-md shadow-xs outline-none isolate {{.Class}}">
<button type="button"{{if .Disabled}} disabled{{end}}
data-slot="split-button-action"
class="{{$seg}} {{index $variants $variant}} {{index $actionSizes $size}} rounded-l-md rounded-r-none"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Label}}</button>
<button type="button"{{if .Disabled}} disabled{{end}}
popovertarget="{{.MenuID}}" popovertargetaction="toggle"
aria-haspopup="menu" aria-expanded="false" aria-label="{{$toggleLabel}}"
data-slot="split-button-toggle"
class="{{$seg}} {{index $variants $variant}} {{index $toggleSizes $size}} {{index $dividers $variant}} rounded-r-md rounded-l-none">
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="size-4"><path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" /></svg>
</button>
</div>
{{end}}
{{define "split_button_menu"}}
{{- $side := or .Side "bottom" -}}
<ul id="{{.ID}}" popover="auto" role="menu" data-slot="dropdown-menu" data-side="{{$side}}"
class="z-50 m-0 min-w-[12rem] list-none rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out]">
{{.Body}}
</ul>
{{end}}
{{define "split_button_item"}}
{{- $variant := or .Variant "default" -}}
{{- $base := "relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0" -}}
{{- $destr := "" -}}{{- if eq $variant "destructive" -}}{{- $destr = "text-destructive focus:bg-destructive/10 focus:text-destructive" -}}{{- end -}}
<li role="none" class="contents">
{{if .Href}}
<a role="menuitem" tabindex="-1" href="{{.Href}}" data-slot="split-button-item"
{{if .Disabled}}data-disabled="true"{{end}}
class="{{$base}} {{$destr}}"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Label}}</a>
{{else}}
<button type="button" role="menuitem" tabindex="-1" data-slot="split-button-item"
{{if .Disabled}}data-disabled="true"{{end}}
{{- if .Onclick}} onclick="{{.Onclick}}"{{end}}
class="{{$base}} {{$destr}}"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end -}}
>{{.Label}}</button>
{{end}}
</li>
{{end}}
1. Save the file
Drop split_button.ex into lib/my_app_web/components/.
2. Use it
<.split_button label="Save" menu_id="save-actions" hx-post="/save">
<:menu>
<.split_button_item hx-post="/save-draft">Save draft</.split_button_item>
<.split_button_item hx-post="/save-template">Save as template</.split_button_item>
</:menu>
</.split_button>View source
defmodule ShadcnHtmx.Components.SplitButton do
@moduledoc """
SplitButton — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
A primary action `<button>` joined to a small disclosure toggle that opens a
popup of related secondary actions. Distinct from a dropdown menu: there is
always a DEFAULT primary action that fires on its own click.
Built on the native Popover API (popover + popovertarget). The popup carries
`data-slot="dropdown-menu"`, so it reuses the APG menu keyboard contract
(arrows, Home/End, type-to-find, Enter/Space activate, click closes) shipped
in public/site.js. A tiny split-button block in site.js mirrors the popup's
open state onto the toggle's `aria-expanded`. Anatomy modelled on Adam
Argyle's web.dev split-button pattern.
## Examples
<.split_button label="Save" menu_id="save-actions" hx-post="/save">
<:menu>
<.split_button_item hx-post="/save-draft">Save draft</.split_button_item>
<.split_button_item hx-post="/save-template">Save as template</.split_button_item>
</:menu>
</.split_button>
"""
use Phoenix.Component
@seg "inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors " <>
"focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 " <>
"disabled:pointer-events-none disabled:opacity-50 " <>
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 " <>
"[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70"
@variants %{
"default" => "bg-primary text-primary-foreground hover:bg-primary/90",
"secondary" => "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"destructive" => "bg-destructive text-white hover:bg-destructive/90",
"outline" => "border bg-background text-foreground hover:bg-accent hover:text-accent-foreground"
}
@action_sizes %{
"sm" => "h-8 gap-1.5 px-3 text-xs has-[>svg]:px-2.5",
"default" => "h-9 gap-2 px-4 text-sm has-[>svg]:px-3",
"lg" => "h-10 gap-2 px-6 text-sm has-[>svg]:px-4"
}
@toggle_sizes %{"sm" => "w-8", "default" => "w-9", "lg" => "w-10"}
@dividers %{
"default" => "border-l border-primary-foreground/20",
"secondary" => "border-l border-foreground/15",
"destructive" => "border-l border-white/25",
"outline" => "border-l-0"
}
attr :label, :string, required: true
attr :menu_id, :string, required: true
attr :variant, :string, default: "default", values: ~w(default secondary destructive outline)
attr :size, :string, default: "default", values: ~w(sm default lg)
attr :side, :string, default: "bottom", values: ~w(top right bottom left)
attr :toggle_label, :string, default: "More actions"
attr :disabled, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global
slot :menu, required: true
def split_button(assigns) do
assigns =
assign(assigns,
seg: @seg,
variant_cls: @variants[assigns.variant],
action_size: @action_sizes[assigns.size],
toggle_size: @toggle_sizes[assigns.size],
divider: @dividers[assigns.variant]
)
~H"""
<div data-slot="split-button" class={["inline-flex items-stretch rounded-md shadow-xs outline-none isolate", @class]}>
<button
type="button"
disabled={@disabled}
data-slot="split-button-action"
class={[@seg, @variant_cls, @action_size, "rounded-l-md rounded-r-none"]}
{@rest}
>
{@label}
</button>
<button
type="button"
disabled={@disabled}
popovertarget={@menu_id}
popovertargetaction="toggle"
aria-haspopup="menu"
aria-expanded="false"
aria-label={@toggle_label}
data-slot="split-button-toggle"
class={[@seg, @variant_cls, @toggle_size, @divider, "rounded-r-md rounded-l-none"]}
>
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" />
</svg>
</button>
</div>
<.split_button_menu id={@menu_id} side={@side}>
{render_slot(@menu)}
</.split_button_menu>
"""
end
attr :id, :string, required: true
attr :side, :string, default: "bottom", values: ~w(top right bottom left)
attr :class, :string, default: nil
slot :inner_block, required: true
def split_button_menu(assigns) do
~H"""
<ul
id={@id}
popover="auto"
role="menu"
data-slot="dropdown-menu"
data-side={@side}
class={[
"z-50 m-0 min-w-[12rem] list-none rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"[&:not(:popover-open)]:hidden",
"[&:popover-open]:animate-[scn-popover-in_120ms_ease-out]",
@class
]}
>
{render_slot(@inner_block)}
</ul>
"""
end
attr :href, :string, default: nil
attr :disabled, :boolean, default: false
attr :variant, :string, default: "default", values: ~w(default destructive)
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def split_button_item(assigns) do
base =
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none " <>
"focus:bg-accent focus:text-accent-foreground " <>
"data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 " <>
"[&_svg]:size-4 [&_svg]:shrink-0"
destr =
if assigns.variant == "destructive",
do: "text-destructive focus:bg-destructive/10 focus:text-destructive",
else: ""
assigns = assign(assigns, base: base, destr: destr)
cond do
assigns.href ->
~H"""
<li role="none" class="contents">
<a
role="menuitem"
tabindex="-1"
href={@href}
data-slot="split-button-item"
data-disabled={@disabled && "true"}
class={[@base, @destr, @class]}
{@rest}
>
{render_slot(@inner_block)}
</a>
</li>
"""
true ->
~H"""
<li role="none" class="contents">
<button
type="button"
role="menuitem"
tabindex="-1"
data-slot="split-button-item"
data-disabled={@disabled && "true"}
class={[@base, @destr, @class]}
{@rest}
>
{render_slot(@inner_block)}
</button>
</li>
"""
end
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<div data-slot="split-button" class="inline-flex items-stretch rounded-md …">
<button data-slot="split-button-action" class="… rounded-l-md rounded-r-none">Save</button>
<button popovertarget="save-actions" aria-haspopup="menu" aria-expanded="false"
data-slot="split-button-toggle" class="… rounded-r-md rounded-l-none">▾</button>
</div>
<ul id="save-actions" popover="auto" role="menu" data-slot="dropdown-menu" class="…">
<li role="none" class="contents"><button role="menuitem" tabindex="-1"
data-slot="split-button-item">Save draft</button></li>
</ul>View source
<!--
shadcn-htmx — raw HTML split button snippet.
A primary action <button> joined to a disclosure toggle that opens a popup
of secondary actions. Native [popover] handles open/close + light dismiss +
ESC + focus restoration. The inline JS adds the APG menu keyboard nav and
mirrors the popup's open state onto the toggle's aria-expanded. Relies only
on theme tokens. Anatomy from Adam Argyle's web.dev split-button pattern.
-->
<div data-slot="split-button" class="inline-flex items-stretch rounded-md shadow-xs outline-none isolate">
<button type="button" data-slot="split-button-action"
hx-post="/save"
class="inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 gap-2 px-4 text-sm has-[>svg]:px-3 rounded-l-md rounded-r-none">
Save
</button>
<button type="button" popovertarget="save-actions" popovertargetaction="toggle"
aria-haspopup="menu" aria-expanded="false" aria-label="More actions"
data-slot="split-button-toggle"
class="inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 w-9 border-l border-primary-foreground/20 rounded-r-md rounded-l-none">
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="size-4"><path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" /></svg>
</button>
</div>
<ul id="save-actions" popover="auto" role="menu" data-slot="dropdown-menu" data-side="bottom"
class="z-50 m-0 min-w-[12rem] list-none rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out]">
<li role="none" class="contents">
<button type="button" role="menuitem" tabindex="-1" data-slot="split-button-item" hx-post="/save-draft"
class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Save draft</button>
</li>
<li role="none" class="contents">
<button type="button" role="menuitem" tabindex="-1" data-slot="split-button-item" hx-post="/save-template"
class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Save as template</button>
</li>
</ul>
<!-- Behaviour — copy once per page. -->
<script>
// Mirror each split-button popup's open state onto its toggle's aria-expanded.
document.querySelectorAll('[data-slot="split-button"]').forEach(function (root) {
var toggle = root.querySelector('[data-slot="split-button-toggle"]')
var menu = toggle && document.getElementById(toggle.getAttribute('popovertarget'))
if (!toggle || !menu) return
menu.addEventListener('toggle', function (e) {
toggle.setAttribute('aria-expanded', e.newState === 'open' ? 'true' : 'false')
if (e.newState === 'open') {
var first = menu.querySelector('[role="menuitem"]:not([data-disabled="true"])')
if (first) setTimeout(function () { first.focus() }, 0)
}
})
})
// APG menu keyboard contract for the popup items.
document.addEventListener('keydown', function (e) {
var item = e.target.closest && e.target.closest('[role="menuitem"]'); if (!item) return
var menu = item.closest('[data-slot="dropdown-menu"]'); if (!menu) return
var items = [].slice.call(menu.querySelectorAll('[role="menuitem"]:not([data-disabled="true"])'))
var i = items.indexOf(item)
if (e.key === 'ArrowDown') { e.preventDefault(); items[(i + 1) % items.length].focus() }
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(i - 1 + items.length) % items.length].focus() }
else if (e.key === 'Home') { e.preventDefault(); items[0].focus() }
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus() }
else if (e.key.length === 1 && /\S/.test(e.key)) {
var ch = e.key.toLowerCase()
for (var k = 1; k <= items.length; k++) {
var c = items[(i + k) % items.length]
if ((c.textContent || '').trim().toLowerCase().startsWith(ch)) { c.focus(); break }
}
}
})
// Activating a menu item closes the popup.
document.addEventListener('click', function (e) {
var item = e.target.closest && e.target.closest('[data-slot="split-button-item"]')
if (!item || item.getAttribute('data-disabled') === 'true') return
var menu = item.closest('[data-slot="dropdown-menu"]')
if (menu && typeof menu.hidePopover === 'function') menu.hidePopover()
})
</script>
Examples
Default action + secondary menu
Click 'Save' to run the default action. Click the ▾ toggle to open related actions — ↑/↓ cycle, Home/End jump, ESC closes, Enter activates, type a letter to jump.
A split button is a default action welded to a disclosure. The primary <button> carries your htmx attributes and fires on its own click; the toggle carries aria-haspopup="menu" + popovertarget and opens a role="menu" popup. The popup reuses the dropdown-menu keyboard contract from site.js, and a small split-button block mirrors its open state onto the toggle's aria-expanded.
<SplitButton label="Save" menuId="save-actions" hx-post="/save" />
<SplitButtonMenu id="save-actions">
<SplitButtonItem hx-post="/save-draft">Save draft</SplitButtonItem>
<SplitButtonItem hx-post="/save-template">Save as template</SplitButtonItem>
<SplitButtonItem hx-post="/save-close">Save and close</SplitButtonItem>
</SplitButtonMenu>{{ split_button("Save", menu_id="save-actions", hx_post="/save") }}
{{ split_button_menu_open(id="save-actions") }}
{{ split_button_item("Save draft", hx_post="/save-draft") }}
{{ split_button_item("Save as template", hx_post="/save-template") }}
{{ split_button_menu_close() }}{{template "split_button" (dict "Label" "Save" "MenuID" "save-actions")}}
{{template "split_button_menu" (dict "ID" "save-actions" "Body" (htmlSafe `
{{template "split_button_item" (dict "Label" "Save draft")}}`))}}<.split_button label="Save" menu_id="save-actions" hx-post="/save">
<:menu>
<.split_button_item hx-post="/save-draft">Save draft</.split_button_item>
<.split_button_item hx-post="/save-template">Save as template</.split_button_item>
</:menu>
</.split_button><div class="flex items-center justify-center">
<div data-slot="split-button" class="inline-flex items-stretch rounded-md shadow-xs outline-none isolate">
<button type="button" data-slot="split-button-action" class="inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 gap-2 px-4 text-sm has-[>svg]:px-3 rounded-l-md rounded-r-none">Save</button>
<button type="button" popovertarget="ex-sb-1" popovertargetaction="toggle" aria-haspopup="menu" aria-expanded="false" aria-label="More actions" data-slot="split-button-toggle" class="inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 w-9 border-l border-primary-foreground/20 rounded-r-md rounded-l-none">
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z">
</path>
</svg>
</button>
</div>
<ul id="ex-sb-1" popover="auto" role="menu" data-slot="dropdown-menu" data-side="bottom" class="z-50 m-0 min-w-[12rem] list-none rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out]">
<li role="none" class="contents">
<button role="menuitem" type="button" tabindex="-1" data-slot="split-button-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Save draft</button>
</li>
<li role="none" class="contents">
<button role="menuitem" type="button" tabindex="-1" data-slot="split-button-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Save as template</button>
</li>
<li role="none" class="contents">
<button role="menuitem" type="button" tabindex="-1" data-slot="split-button-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Save and close</button>
</li>
</ul>
</div>Further reading
Variants & sizes
Secondary and outline skins, plus the small / default / large sizes. The toggle stays square and tracks the action's height.
Every segment shares one visual skin, so the joined control reads as a single unit. The hairline divider tints the foreground on filled variants and falls back to the shared border on the outline variant. A destructive secondary action still needs its own confirmation (a Dialog or htmx hx-confirm).
<SplitButton label="Publish" menuId="m1" variant="secondary" size="sm" />
<SplitButtonMenu id="m1">
<SplitButtonItem>Schedule…</SplitButtonItem>
<SplitButtonItem variant="destructive" hx-delete="/post/1"
hx-confirm="Discard this draft?">Discard…</SplitButtonItem>
</SplitButtonMenu>
<SplitButton label="Export" menuId="m2" variant="outline" size="lg" />{{ split_button("Publish", menu_id="m1", variant="secondary", size="sm") }}
{{ split_button_menu_open(id="m1") }}
{{ split_button_item("Schedule…") }}
{{ split_button_item("Discard…", variant="destructive") }}
{{ split_button_menu_close() }}{{template "split_button" (dict "Label" "Publish" "MenuID" "m1" "Variant" "secondary" "Size" "sm")}}<.split_button label="Publish" menu_id="m1" variant="secondary" size="sm">
<:menu>
<.split_button_item>Schedule…</.split_button_item>
<.split_button_item variant="destructive">Discard…</.split_button_item>
</:menu>
</.split_button><div class="flex flex-wrap items-center justify-center gap-4">
<div data-slot="split-button" class="inline-flex items-stretch rounded-md shadow-xs outline-none isolate">
<button type="button" data-slot="split-button-action" class="inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-8 gap-1.5 px-3 text-xs has-[>svg]:px-2.5 rounded-l-md rounded-r-none">Publish</button>
<button type="button" popovertarget="ex-sb-2" popovertargetaction="toggle" aria-haspopup="menu" aria-expanded="false" aria-label="More actions" data-slot="split-button-toggle" class="inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-secondary text-secondary-foreground hover:bg-secondary/80 w-8 border-l border-foreground/15 rounded-r-md rounded-l-none">
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z">
</path>
</svg>
</button>
</div>
<ul id="ex-sb-2" popover="auto" role="menu" data-slot="dropdown-menu" data-side="bottom" class="z-50 m-0 min-w-[12rem] list-none rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out]">
<li role="none" class="contents">
<button role="menuitem" type="button" tabindex="-1" data-slot="split-button-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Schedule…</button>
</li>
<li role="none" class="contents">
<button role="menuitem" type="button" tabindex="-1" data-slot="split-button-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 text-destructive focus:bg-destructive/10 focus:text-destructive">Discard…</button>
</li>
</ul>
<div data-slot="split-button" class="inline-flex items-stretch rounded-md shadow-xs outline-none isolate">
<button type="button" data-slot="split-button-action" class="inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background text-foreground hover:bg-accent hover:text-accent-foreground h-10 gap-2 px-6 text-sm has-[>svg]:px-4 rounded-l-md rounded-r-none">Export</button>
<button type="button" popovertarget="ex-sb-3" popovertargetaction="toggle" aria-haspopup="menu" aria-expanded="false" aria-label="More actions" data-slot="split-button-toggle" class="inline-flex items-center justify-center font-medium whitespace-nowrap outline-none transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 border bg-background text-foreground hover:bg-accent hover:text-accent-foreground w-10 border-l-0 rounded-r-md rounded-l-none">
<svg aria-hidden="true" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z">
</path>
</svg>
</button>
</div>
<ul id="ex-sb-3" popover="auto" role="menu" data-slot="dropdown-menu" data-side="bottom" class="z-50 m-0 min-w-[12rem] list-none rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none [&:not(:popover-open)]:hidden [&:popover-open]:animate-[scn-popover-in_120ms_ease-out]">
<li role="none" class="contents">
<button role="menuitem" type="button" tabindex="-1" data-slot="split-button-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Export as CSV</button>
</li>
<li role="none" class="contents">
<button role="menuitem" type="button" tabindex="-1" data-slot="split-button-item" class="relative flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0">Export as JSON</button>
</li>
</ul>
</div>Further reading
API Reference
<SplitButton>
| Prop | Type | Default | Description |
|---|---|---|---|
menuId* | string | — | Required. Id of the SplitButtonMenu popup; matched by popovertarget on the toggle.MDNpopovertarget |
label | string | — | Visible text of the primary action button. Falls back to children when omitted. |
variant | "default"|"secondary"|"destructive"|"outline" | "default" | Visual skin shared by both segments. |
size | "sm"|"default"|"lg" | "default" | Height of the action; the toggle stays square and tracks it. |
side | "top"|"right"|"bottom"|"left" | "bottom" | Which side of the toggle the popup opens on (positioned by site.js). |
toggleLabel | string | "More actions" | Accessible name for the icon-only disclosure toggle.APGMenu button pattern |
disabled | boolean | false | Disable both the action and the toggle. |
type | "button"|"submit"|"reset" | "button" | Submit / reset semantics for the primary action when nested in a form.MDN<button type> |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required