shshadcn-htmx

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.json

2. Use it

components/ui/split-button.tsx
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
components/ui/split-button.tsx
/** @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

templates/components/split-button.html
{% 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
templates/components/split-button.html
{# 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

components/split-button.tmpl
{{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
components/split-button.tmpl
{{/* 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

lib/my_app_web/components/split_button.ex
<.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
lib/my_app_web/components/split_button.ex
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

snippets/split-button.html
<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
snippets/split-button.html
<!--
  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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 gap-2 px-4 text-sm has-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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 [&amp;:not(:popover-open)]:hidden [&amp;: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 [&amp;_svg]:size-4 [&amp;_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 [&amp;_svg]:size-4 [&amp;_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 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0">Save and close</button>
    </li>
  </ul>
</div>

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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-secondary text-secondary-foreground hover:bg-secondary/80 h-8 gap-1.5 px-3 text-xs has-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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 [&amp;:not(:popover-open)]:hidden [&amp;: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 [&amp;_svg]:size-4 [&amp;_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 [&amp;_svg]:size-4 [&amp;_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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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-[&gt;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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.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 [&amp;:not(:popover-open)]:hidden [&amp;: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 [&amp;_svg]:size-4 [&amp;_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 [&amp;_svg]:size-4 [&amp;_svg]:shrink-0">Export as JSON</button>
    </li>
  </ul>
</div>

Further reading

API Reference

<SplitButton>

PropTypeDefaultDescription
menuId*string
Required. Id of the SplitButtonMenu popup; matched by popovertarget on the toggle.MDNpopovertarget
labelstring
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).
toggleLabelstring"More actions"
Accessible name for the icon-only disclosure toggle.APGMenu button pattern
disabledbooleanfalse
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>
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required