Accessibility pattern · Buttons and actions
Split button
A split button is two buttons that look like one: the main action does the common thing at once, and the arrow opens a menu of variations. Each half is its own tab stop with its own name, so nobody has to guess which half they are on.
- WCAG criteria
- 7
- Keyboard rules
- 7
- Checked with
- axe, keyboard and the inspector
Live demo
Try it
Use it with a mouse, a keyboard or a screen reader. The inspector beside it shows what the browser tells assistive technology as you go: focus, state changes and announcements.
The demo works without JavaScript only as far as its HTML does; the inspector needs JavaScript.
Keyboard
Keys it answers to
Every action works without a pointer.
| Key | What it does |
|---|---|
| Tab or ShiftTab | Moves between the two halves: Publish first, then the arrow. |
| Enter or Space | On Publish, publishes at once. On the arrow, opens the menu at its first option. |
| Arrow Down or Arrow Up | On the arrow, opens the menu at the first or last option; in the menu, moves between options, wrapping at the ends. |
| Home or End | In the menu, moves to the first or last option. |
| A–Z | In the menu, moves to the next option starting with that letter. |
| Enter or Space | In the menu, runs the focused option, closes the menu and returns focus to the arrow. |
| Escape | Closes the menu and returns focus to the arrow. Tab, or a click outside, closes it too. |
Screen readers
What it announces
Written from the roles, names and states in the markup.
| When | Expected announcement |
|---|---|
| Focus reaches the main half | Publish, button |
| Tab moves to the arrow | More publish options, menu button, collapsed |
| Enter opens the menu | More publish options, menu. Schedule…, menu item, 1 of 3 |
| Escape closes it | More publish options, menu button, collapsed |
| Publish to drafts is chosen | Saved to drafts |
These are expected announcements, not recordings. Wording and order differ between screen readers and browsers.
Code
Copy the code
The exact files this demo runs on. The styles are served with a prefix that keeps this site's own styles out of the demo; what you copy is the original.
<div class="ap-split-button" data-ap-split-button>
<div class="ap-split-button__card">
<div class="ap-split-button__body">
<p class="ap-split-button__badge" data-state="draft"><span data-ap-badge>Draft</span></p>
<h3 class="ap-split-button__name">Monsoon recipes from Kerala</h3>
<p class="ap-split-button__meta">By Nikhil Menon · 6 min read</p>
<p class="ap-split-button__excerpt">Rain on the backwaters, a pot of kappa on the stove, and five dishes that taste of June in Alappuzha.</p>
</div>
<div class="ap-split-button__foot">
<p class="ap-split-button__status" role="status">
<svg class="ap-split-button__done" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m5 12 5 5L20 7"/></svg>
<span data-ap-text></span>
</p>
<div class="ap-split-button__group">
<button type="button" class="ap-split-button__main" data-ap-main>
<svg class="ap-split-button__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg>
Publish
</button>
<button type="button" class="ap-split-button__arrow" id="split-button-arrow" aria-label="More publish options" aria-haspopup="menu" aria-expanded="false" aria-controls="split-button-menu">
<svg class="ap-split-button__chev" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 15 6-6 6 6"/></svg>
</button>
<div class="ap-split-button__menu" role="menu" id="split-button-menu" aria-labelledby="split-button-arrow" hidden>
<button type="button" class="ap-split-button__item" role="menuitem" tabindex="-1" data-ap-action="schedule">
<svg class="ap-split-button__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>
<span class="ap-split-button__label">Schedule…</span>
</button>
<button type="button" class="ap-split-button__item" role="menuitem" tabindex="-1" data-ap-action="drafts">
<svg class="ap-split-button__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5"/><path d="M9 14h6M9 18h4"/></svg>
<span class="ap-split-button__label">Publish to drafts</span>
</button>
<button type="button" class="ap-split-button__item" role="menuitem" tabindex="-1" data-ap-action="template">
<svg class="ap-split-button__icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
<span class="ap-split-button__label">Save as template</span>
</button>
</div>
</div>
</div>
</div>
</div>
/* Split button. Uses the --ap-* design tokens. */
.ap-split-button {
container-type: inline-size;
width: min(100%, 520px);
margin-inline: auto;
}
.ap-split-button__card {
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius-lg);
background: var(--ap-surface);
box-shadow: var(--ap-shadow-md);
}
.ap-split-button__body {
padding: 20px 22px 18px;
}
.ap-split-button__badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0 0 10px;
padding: 3px 10px 3px 8px;
border-radius: var(--ap-radius-full);
background: var(--ap-surface-2);
color: var(--ap-text-2);
font-size: .8125rem;
font-weight: 600;
line-height: 1.4;
}
/* A dot for draft, filled for published, hollow for scheduled: the word says
it too. */
.ap-split-button__badge::before {
content: "";
width: 7px;
height: 7px;
border: 2px solid currentColor;
border-radius: var(--ap-radius-full);
}
.ap-split-button__badge[data-state="published"] {
background: var(--ap-success-soft);
color: var(--ap-success);
}
.ap-split-button__badge[data-state="published"]::before {
background: currentColor;
}
.ap-split-button__badge[data-state="scheduled"] {
background: var(--ap-info-soft);
color: var(--ap-info);
}
.ap-split-button__name {
margin: 0;
color: var(--ap-text);
font-size: 1.1875rem;
font-weight: 650;
line-height: 1.3;
}
.ap-split-button__meta {
margin: 4px 0 0;
color: var(--ap-text-3);
font-size: .875rem;
}
.ap-split-button__excerpt {
margin: 12px 0 0;
color: var(--ap-text-2);
line-height: 1.6;
}
.ap-split-button__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 16px;
padding: 14px 16px 14px 22px;
border-top: 1px solid var(--ap-border);
}
.ap-split-button__status {
display: flex;
flex: 1 1 160px;
align-items: center;
gap: 6px;
min-height: 1.45em;
margin: 0;
color: var(--ap-success);
font-size: .9375rem;
font-weight: 600;
line-height: 1.45;
}
.ap-split-button__done {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-split-button__status:not(.is-done) .ap-split-button__done {
display: none;
}
/* The two halves: one shape, two buttons, a gap between them. */
.ap-split-button__group {
position: relative;
display: flex;
gap: 2px;
margin-inline-start: auto;
}
.ap-split-button__main,
.ap-split-button__arrow {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--ap-target);
border: 0;
background: var(--ap-accent);
color: var(--ap-on-accent);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background-color var(--ap-duration) var(--ap-ease);
}
.ap-split-button__main {
gap: 8px;
padding: 0 18px 0 16px;
border-radius: var(--ap-radius-sm) 0 0 var(--ap-radius-sm);
}
.ap-split-button__arrow {
width: var(--ap-target);
padding: 0;
border-radius: 0 var(--ap-radius-sm) var(--ap-radius-sm) 0;
}
.ap-split-button__main:hover,
.ap-split-button__arrow:hover,
.ap-split-button__arrow[aria-expanded="true"] {
background: var(--ap-accent-hover);
}
.ap-split-button__main:focus-visible,
.ap-split-button__arrow:focus-visible {
position: relative;
z-index: 1;
outline: 2px solid var(--ap-focus);
outline-offset: 2px;
}
.ap-split-button__main:active,
.ap-split-button__arrow:active {
transform: translateY(1px);
}
.ap-split-button__icon,
.ap-split-button__chev {
width: 18px;
height: 18px;
flex-shrink: 0;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.ap-split-button__chev {
transition: transform var(--ap-duration) var(--ap-ease);
}
.ap-split-button__arrow[aria-expanded="true"] .ap-split-button__chev {
transform: rotate(180deg);
}
/* The menu opens upwards, over the card, from the arrow's end. */
.ap-split-button__menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 3;
display: grid;
width: 236px;
padding: 6px;
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
background: var(--ap-surface);
box-shadow: var(--ap-shadow-lg);
transform-origin: bottom right;
animation: ap-split-button-in 140ms var(--ap-ease);
}
.ap-split-button__menu[hidden] {
display: none;
}
.ap-split-button__item {
display: flex;
align-items: center;
gap: 10px;
min-height: 40px;
padding: 0 10px;
border: 0;
border-radius: var(--ap-radius-sm);
background: transparent;
color: var(--ap-text);
font: inherit;
font-size: .9375rem;
text-align: start;
cursor: pointer;
}
.ap-split-button__item .ap-split-button__icon {
stroke: var(--ap-text-3);
}
.ap-split-button__item:focus {
outline: none;
background: var(--ap-surface-2);
}
.ap-split-button__item:focus .ap-split-button__icon {
stroke: currentColor;
}
.ap-split-button__item:focus-visible {
outline: 2px solid var(--ap-focus);
outline-offset: -2px;
}
.ap-split-button__label {
flex: 1;
}
/* Narrow: the button keeps the top line; the result goes underneath. */
@container (max-width: 420px) {
.ap-split-button__body { padding-inline: 18px; }
.ap-split-button__foot { padding-inline: 18px 16px; }
.ap-split-button__status {
order: 1;
flex-basis: 100%;
min-height: 0;
}
}
@keyframes ap-split-button-in {
from { opacity: 0; transform: translateY(4px) scale(.98); }
}
@media (prefers-reduced-motion: reduce) {
.ap-split-button__main,
.ap-split-button__arrow,
.ap-split-button__chev { transition: none; }
.ap-split-button__main:active,
.ap-split-button__arrow:active { transform: none; }
.ap-split-button__menu { animation: none; }
}
/**
* Split button: a main action and a menu button joined into one shape.
*
* Markup: [data-ap-split-button] holding button[data-ap-main], a
* button[aria-haspopup=menu][aria-expanded][aria-controls] (the arrow) and
* the div[role=menu][hidden] it controls, with button[role=menuitem]
* [tabindex=-1][data-ap-action] options. A role=status paragraph with a
* [data-ap-text] span reports results; [data-ap-badge] shows the post state.
*/
const RESULTS = {
publish: { says: "Post published", badge: "Published", state: "published" },
schedule: { says: "Scheduled for tomorrow, 9:00 am", badge: "Scheduled", state: "scheduled" },
drafts: { says: "Saved to drafts", badge: "Draft", state: "draft" },
template: { says: "Saved as a template" },
};
export function init(root) {
const main = root.querySelector("[data-ap-main]");
const arrow = root.querySelector("[aria-haspopup=menu]");
const menu = document.getElementById(arrow.getAttribute("aria-controls"));
const items = [...menu.querySelectorAll("[role=menuitem]")];
const status = root.querySelector("[role=status]");
const statusText = status.querySelector("[data-ap-text]");
const badge = root.querySelector("[data-ap-badge]");
let outside = null;
let sayTimer = 0;
const isOpen = () => arrow.getAttribute("aria-expanded") === "true";
const labelOf = (item) => item.querySelector(".ap-split-button__label").textContent.trim();
function open(at = 0) {
if (!isOpen()) {
menu.hidden = false;
arrow.setAttribute("aria-expanded", "true");
outside = new AbortController();
document.addEventListener("pointerdown", (event) => {
if (!root.contains(event.target)) close(false);
}, { signal: outside.signal });
}
items[at < 0 ? items.length - 1 : at].focus();
}
// Focus goes back to the arrow, the half that opened the menu.
function close(returnFocus = true) {
if (!isOpen()) return;
menu.hidden = true;
arrow.setAttribute("aria-expanded", "false");
outside?.abort();
outside = null;
if (returnFocus) arrow.focus();
}
function run(action) {
const result = RESULTS[action];
if (!result) return;
if (result.badge) {
badge.textContent = result.badge;
badge.parentElement.dataset.state = result.state;
}
statusText.textContent = "";
status.classList.add("is-done");
clearTimeout(sayTimer);
sayTimer = setTimeout(() => { statusText.textContent = result.says; }, 60);
}
function typeahead(char, from) {
const c = char.toLocaleLowerCase();
for (let k = 1; k <= items.length; k++) {
const item = items[(from + k) % items.length];
if (labelOf(item).toLocaleLowerCase().startsWith(c)) return item;
}
return null;
}
function onKeydown(event) {
if (event.target === arrow) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
open(event.key === "ArrowDown" ? 0 : -1);
}
return;
}
const at = items.indexOf(event.target);
if (at === -1) return;
let next = null;
switch (event.key) {
case "ArrowDown": next = items[(at + 1) % items.length]; break;
case "ArrowUp": next = items[(at - 1 + items.length) % items.length]; break;
case "Home": case "PageUp": next = items[0]; break;
case "End": case "PageDown": next = items[items.length - 1]; break;
case "Escape":
event.preventDefault();
close();
return;
case "Tab":
close();
if (event.shiftKey) event.preventDefault();
return;
default:
if (event.key.length === 1 && /\S/.test(event.key) && !event.ctrlKey && !event.metaKey && !event.altKey) {
event.preventDefault();
next = typeahead(event.key, at);
}
}
if (next) {
event.preventDefault();
next.focus();
}
}
function onClick(event) {
const button = event.target.closest("button");
if (!button || !root.contains(button)) return;
if (button === main) {
close(false);
run("publish");
} else if (button === arrow) {
if (isOpen()) close();
else open(0);
} else if (items.includes(button)) {
close();
run(button.dataset.apAction);
}
}
function onPointerMove(event) {
const item = event.target.closest("[role=menuitem]");
if (item && document.activeElement !== item) item.focus();
}
function onFocusOut(event) {
if (isOpen() && event.relatedTarget && !menu.contains(event.relatedTarget) && event.relatedTarget !== arrow) close(false);
}
root.addEventListener("keydown", onKeydown);
root.addEventListener("click", onClick);
root.addEventListener("focusout", onFocusOut);
menu.addEventListener("pointermove", onPointerMove);
return () => {
outside?.abort();
clearTimeout(sayTimer);
root.removeEventListener("keydown", onKeydown);
root.removeEventListener("click", onClick);
root.removeEventListener("focusout", onFocusOut);
menu.removeEventListener("pointermove", onPointerMove);
};
}
for (const root of document.querySelectorAll("[data-ap-split-button]")) init(root);
/* Design tokens for the pattern components. Light by default, dark when the
system asks for it; set data-theme="dark" on :root to force dark. */
:root {
--ap-radius-sm: 8px;
--ap-radius: 12px;
--ap-radius-lg: 16px;
--ap-radius-full: 999px;
--ap-font: "Instrument Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--ap-mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", "SF Mono", Consolas, monospace;
--ap-ease: cubic-bezier(.2, .8, .2, 1);
--ap-duration: 180ms;
--ap-target: 44px;
--ap-bg: #F4F4F5;
--ap-surface: #FFFFFF;
--ap-surface-2: #F4F4F5;
--ap-surface-3: #E4E4E7;
--ap-border: #E4E4E7;
--ap-border-strong: #76767F;
--ap-text: #18181B;
--ap-text-2: #3F3F46;
--ap-text-3: #5E5E66;
--ap-accent: #4F46E5;
--ap-accent-hover: #4338CA;
--ap-on-accent: #FFFFFF;
--ap-accent-text: #4338CA;
--ap-accent-soft: #EEF2FF;
--ap-accent-soft-text: #3730A3;
--ap-focus: #4F46E5;
--ap-danger: #B91C1C;
--ap-danger-soft: #FEF2F2;
--ap-success: #15803D;
--ap-success-soft: #F0FDF4;
--ap-warning: #A15C07;
--ap-warning-soft: #FEFCE8;
--ap-info: #1D4ED8;
--ap-info-soft: #EFF6FF;
--ap-scrim: rgb(9 9 11 / .48);
--ap-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-light-bg: #F4F4F5;
--ap-light-surface: #FFFFFF;
--ap-light-surface-2: #F4F4F5;
--ap-light-surface-3: #E4E4E7;
--ap-light-border: #E4E4E7;
--ap-light-border-strong: #76767F;
--ap-light-text: #18181B;
--ap-light-text-2: #3F3F46;
--ap-light-text-3: #5E5E66;
--ap-light-accent: #4F46E5;
--ap-light-accent-hover: #4338CA;
--ap-light-on-accent: #FFFFFF;
--ap-light-accent-text: #4338CA;
--ap-light-accent-soft: #EEF2FF;
--ap-light-accent-soft-text: #3730A3;
--ap-light-focus: #4F46E5;
--ap-light-danger: #B91C1C;
--ap-light-danger-soft: #FEF2F2;
--ap-light-success: #15803D;
--ap-light-success-soft: #F0FDF4;
--ap-light-warning: #A15C07;
--ap-light-warning-soft: #FEFCE8;
--ap-light-info: #1D4ED8;
--ap-light-info-soft: #EFF6FF;
--ap-light-scrim: rgb(9 9 11 / .48);
--ap-light-shadow-sm: 0 1px 2px rgb(9 9 11 / .06);
--ap-light-shadow-md: 0 1px 2px rgb(9 9 11 / .05), 0 6px 16px -4px rgb(9 9 11 / .1);
--ap-light-shadow-lg: 0 2px 6px rgb(9 9 11 / .06), 0 20px 40px -12px rgb(9 9 11 / .22);
--ap-dark-bg: #09090B;
--ap-dark-surface: #18181B;
--ap-dark-surface-2: #27272A;
--ap-dark-surface-3: #3F3F46;
--ap-dark-border: #2E2E33;
--ap-dark-border-strong: #8E8E97;
--ap-dark-text: #FAFAFA;
--ap-dark-text-2: #D4D4D8;
--ap-dark-text-3: #A1A1AA;
--ap-dark-accent: #818CF8;
--ap-dark-accent-hover: #A5B4FC;
--ap-dark-on-accent: #0C0A1F;
--ap-dark-accent-text: #A5B4FC;
--ap-dark-accent-soft: #1E1B4B;
--ap-dark-accent-soft-text: #C7D2FE;
--ap-dark-focus: #A5B4FC;
--ap-dark-danger: #F87171;
--ap-dark-danger-soft: #2A1215;
--ap-dark-success: #4ADE80;
--ap-dark-success-soft: #0F2A1A;
--ap-dark-warning: #FACC15;
--ap-dark-warning-soft: #2A2410;
--ap-dark-info: #60A5FA;
--ap-dark-info-soft: #0F1D33;
--ap-dark-scrim: rgb(0 0 0 / .62);
--ap-dark-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-dark-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-dark-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
}
:root[data-theme="dark"] {
--ap-bg: #09090B;
--ap-surface: #18181B;
--ap-surface-2: #27272A;
--ap-surface-3: #3F3F46;
--ap-border: #2E2E33;
--ap-border-strong: #8E8E97;
--ap-text: #FAFAFA;
--ap-text-2: #D4D4D8;
--ap-text-3: #A1A1AA;
--ap-accent: #818CF8;
--ap-accent-hover: #A5B4FC;
--ap-on-accent: #0C0A1F;
--ap-accent-text: #A5B4FC;
--ap-accent-soft: #1E1B4B;
--ap-accent-soft-text: #C7D2FE;
--ap-focus: #A5B4FC;
--ap-danger: #F87171;
--ap-danger-soft: #2A1215;
--ap-success: #4ADE80;
--ap-success-soft: #0F2A1A;
--ap-warning: #FACC15;
--ap-warning-soft: #2A2410;
--ap-info: #60A5FA;
--ap-info-soft: #0F1D33;
--ap-scrim: rgb(0 0 0 / .62);
--ap-shadow-sm: 0 1px 2px rgb(0 0 0 / .4);
--ap-shadow-md: 0 1px 2px rgb(0 0 0 / .4), 0 8px 20px -6px rgb(0 0 0 / .5);
--ap-shadow-lg: 0 2px 8px rgb(0 0 0 / .45), 0 24px 48px -12px rgb(0 0 0 / .7);
}
WCAG 2.2
What it meets
The success criteria this pattern takes care of, and how.
-
1.4.11 Non-text Contrast
Level AA
The two halves are split by a visible gap, and each has its own focus ring, so it is clear which half has focus.
-
2.1.1 Keyboard
Level A
Both halves, the menu and every option work from the keyboard.
-
2.4.3 Focus Order
Level A
Focus enters the menu when it opens and returns to the arrow when it closes, so it is never lost.
-
2.4.6 Headings and Labels
Level AA
The arrow is named "More publish options", not "More" or "Toggle", so its purpose is clear out of context.
-
2.5.8 Target Size (Minimum)
Level AA
The arrow is a full 44-pixel target of its own, not a sliver of the main button.
-
4.1.2 Name, Role, Value
Level A
The main half is a plain button; the arrow has aria-haspopup and aria-expanded and names the menu it opens.
-
4.1.3 Status Messages
Level AA
Each result is confirmed in a status message, without taking focus from the button.
Usage
When to use it
Use it
- One action people take most of the time, with a few variations of it: publish, publish later, save as draft.
- Space for one button where the variations would otherwise need their own.
Use something else
- Unrelated actions behind one arrow: use a separate menu button.
- Variations that most people need: show them as separate buttons.
- Choosing a setting that the main button then uses: a select beside a button is clearer.
Common failures
How it usually goes wrong
One button with two hit areas
If the arrow is only a region inside a single button, keyboard and screen reader users can never reach the menu. Make it a real second button.
An arrow with no name
An icon-only arrow announced as "button" tells nobody what it opens. aria-label names it after what it holds.
The main action hidden in the menu only
Repeating the main action inside the menu is harmless, but leaving it out of the main half defeats the point of a split button.
Focus sent to the main half on close
People who opened the menu with the arrow expect to land back on the arrow. Escape and every option return focus there.
A sliver of a target
A 16-pixel arrow glued to a large button is easy to miss and easy to hit by accident. The arrow here is a 44-pixel square.
Notes
Building it
- Keep the two halves visually joined but separated by a gap, so each focus ring reads as belonging to one half.
- The menu is labelled by the arrow, so opening it announces "More publish options, menu".
- An ellipsis after an option (Schedule…) promises a further step, like choosing a time; keep that promise in a real product.
- The menu opens upwards here because the button sits at the bottom of its card; flip it to fit the space you have.
Sources: WAI-ARIA Authoring Practices: Menu Button · WAI-ARIA Authoring Practices: Button
Checked with axe in light and dark themes, at desktop and phone widths, and by keyboard. Not yet tested with every screen reader and browser pair. Report a correction