Component Porchlight CSS

Split button

A primary action button paired with a dropdown toggle for alternative actions. Composes two .c-button segments with a Popover API menu.

52 components 51 stable 1 experimental

Command

Search Porchlight

Split button

The .c-split is a composite action control: a primary button (the default action) fused with a dropdown toggle that opens a menu of alternative actions. The canonical “Save ▼” pattern - click the label to perform the default action, click the chevron to pick a variant.

Both segments are .c-button, so every variant, state, and density tier inherited from the button component works without extra effort. The split button only adds the layout, the seam divider, and the Popover API menu.

Semantic HTML

<div class="c-split">
  <button class="c-button c-split__primary" data-variant="primary">Save</button>
  <button
    class="c-button c-split__toggle"
    data-variant="primary"
    popovertarget="sb-1"
    aria-label="More save options"
  >
    <svg class="c-split__chevron" viewBox="0 0 24 24" ...>
      <path d="m6 9 6 6 6-6" />
    </svg>
  </button>
  <div popover class="c-split__menu" id="sb-1">
    <button class="c-split__option">Save as PDF</button>
    <button class="c-split__option">Save as draft</button>
    <hr class="c-menu__divider" />
    <button class="c-split__option" data-tone="danger">Discard changes</button>
  </div>
</div>

Class contract

SelectorRole
.c-splitFlex container that fuses the two segments.
.c-split__primaryThe primary .c-button (default action). Trailing radius set to 0.
.c-split__toggleThe dropdown .c-button (opens the menu). Leading radius set to 0.
.c-split__chevronArrow icon inside the toggle (rotates 180° when open).
.c-split__menuThe popover menu (anchored to the toggle).
.c-split__optionA menu item (button or link).
[data-tone="danger"]Destructive item (discard, delete).

Both buttons must carry the same data-variant so the fill, text, and border match across the seam.

Multiple instances: each .c-split must set a unique --c-split-anchor (e.g. style="--c-split-anchor: --sb-1;") so popovers don’t all tether to the last toggle on the page. The default --pl-split-anchor works only for a single instance.

SSR/CSP-safe anchors

CSS Anchor Positioning requires anchor-name and position-anchor to resolve to a CSS custom identifier. Today that identifier cannot be read from an HTML attribute, so each simultaneously rendered split button still needs a unique CSS value as well as a unique id / popovertarget pair.

If inline styles are awkward for a server-rendered or CSP-heavy app, generate a class and put the custom property in an app stylesheet or nonce-protected <style> block:

<div class="c-split request-actions-42">
  <button class="c-button c-split__primary" data-variant="primary">
    Approve
  </button>
  <button
    class="c-button c-split__toggle"
    data-variant="primary"
    popovertarget="request-actions-42-menu"
    aria-label="More request actions"
  >
    <svg class="c-split__chevron" viewBox="0 0 24 24">...</svg>
  </button>
  <div popover class="c-split__menu" id="request-actions-42-menu">...</div>
</div>
@layer app {
  .request-actions-42 {
    --c-split-anchor: --request-actions-42;
  }
}

For HTMX/server-rendered lists, a stable row/request id is a good suffix. If a page renders only one split button, you can omit the custom property and use the default anchor name.

Tokens exposed (component-local)

TokenDefaultPurpose
--c-split-anchor--pl-split-anchorCSS anchor name for the toggle (unique per instance).
--c-split-min-inline10remMinimum menu width.
--c-split-max-block20remMaximum menu height (scrolls if exceeded).
--c-split-divider--pl-color-borderDivider color between segments (per-variant override).

Accessibility

  • Keyboard: both segments are native <button> elements - Enter/Space activates them. The toggle uses popovertarget for zero-JS menu open. Menu items are buttons/links, so Tab/Shift+Tab navigates within the popover.
  • Toggle label: the toggle must carry an aria-label describing what the menu contains (e.g. "More save options"), since the icon alone is ambiguous.
  • Focus-visible: both segments inherit the base-layer focus outline; the primary variant also gets an accent glow.
  • Disabled: set disabled on both segments to disable the entire control.
  • Forced colors: falls back to ButtonBorder / Highlight.

Theme, density, RTL, motion

  • Light/dark: segment fills resolve via light-dark(). The divider on filled variants uses a semi-transparent white rule (oklch(100% 0 0deg / 20%)) so it reads on both accent and dark fills.
  • Density: both segments inherit --pl-control-block-size; set [data-density] on an ancestor - no per-component work.
  • RTL: the flex container follows the writing mode; the divider and radius squaring flip automatically via logical properties (border-inline-start).
  • Reduced motion: the chevron rotation and menu transitions are zeroed by the themes layer’s motion guard (--pl-motion-scale: 0).