Building a split-button component

A foundational overview of how to build an accessible split-button component.

In this post I want to share thinking on a way to build a split button . Try the demo.

Demo

If you prefer video, here's a YouTube version of this post:

Overview

Split buttons are buttons that conceal a primary button and a list of additional buttons. They're useful for exposing a common action while nesting secondary, less frequently used actions until needed. A split button can be crucial to helping a busy design feel minimal. An advanced split button may even remember the last user action and promote it into the primary position.

A common split button can be found in your email application. The primary action is send, but perhaps you can send later or save a draft instead:

An example split button as seen in an email application.

The shared action area is nice, since the user doesn't need to look around. They know that essential email actions are contained in the split button.

Parts

Let's break down the essential parts of a split button before discussing their overall orchestration and final user experience. VisBug's accessibility inspect tool is used here to help show a macro view of the component, surfacing aspects of the HTML, style and accessibility for each major part.

The HTML elements that make up the split button.

Top level split button container

The highest level component is an inline flexbox, with a class of gui-split-button, containing the primary action and the .gui-popup-button.

The gui-split-button class inspected and showing the CSS properties used in this class.

The primary action button

The initially visible and focusable <button> fits within the container with two matching corner shapes for focus, hover and active interactions to appear contained within .gui-split-button.

The inspector showing the CSS rules for the button element.

The popup toggle button

The "popup button" support element is for activating and alluding to the list of secondary buttons. Notice it's not a <button> and it's not focusable. However, it is the positioning anchor for .gui-popup and host for :focus-within used to present the popup.

The inspector showing the CSS rules for the class gui-popup-button.

The popup card

This is a floating card child to its anchor .gui-popup-button, positioned absolute and semantically wrapping the button list.

The inspector showing the CSS rules for the class gui-popup

The secondary action(s)

A focusable <button> with a slightly smaller font size than the primary action button features an icon and a complimentary style to the primary button.

The inspector showing the CSS rules for the button element.

Custom properties

The following variables assist in creating color harmony and a central place to modify values used throughout the component.

@custom-media --motionOK (prefers-reduced-motion: no-preference);
@custom-media --dark (prefers-color-scheme: dark);
@custom-media --light (prefers-color-scheme: light);

.gui-split-button {
  --theme:             hsl(220 75% 50%);
  --theme-hover:  hsl(220 75% 45%);
  --theme-active:  hsl(220 75% 40%);
  --theme-text:      hsl(220 75% 25%);
  --theme-border: hsl(220 50% 75%);
  --ontheme:         hsl(220 90% 98%);
  --popupbg:         hsl(220 0% 100%);

  --border: 1px solid var(--theme-border);
  --radius: 6px;
  --in-speed: 50ms;
  --out-speed: 300ms;

  @media (--dark) {
    --theme:             hsl(220 50% 60%);
    --theme-hover:  hsl(220 50% 65%);
    --theme-active:  hsl(220 75% 70%);
    --theme-text:      hsl(220 10% 85%);
    --theme-border: hsl(220 20% 70%);
    --ontheme:         hsl(220 90% 5%);
    --popupbg:         hsl(220 10% 30%);
  }
}

Layouts and color

Markup

The element begins as a <div> with a custom class name.

<div class="gui-split-button"></div>

Add the primary button and the .gui-popup-button elements.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions"></span>
</div>

Notice the aria attributes aria-haspopup and aria-expanded. These cues are critical for screen readers to be aware of the capability and state of split button experience. The title attribute is helpful for everyone.

Add an <svg> icon and the .gui-popup container element.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <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>
    <ul class="gui-popup"></ul>
  </span>
</div>

For straightforward popup placement, .gui-popup is a child to the button that expands it. The only catch with this strategy is the .gui-split-button container can't use overflow: hidden, as it will clip the popup from being visually present.

A <ul> filled with <li><button> contents will announce itself as a "button list" to screen readers, which is precisely the interface being presented.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <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>
    <ul class="gui-popup">
      <li>
        <button>Schedule for later</button>
      </li>
      <li>
        <button>Delete</button>
      </li>
      <li>
        <button>Save draft</button>
      </li>
    </ul>
  </span>
</div>

For flair and to have fun with color, I've added icons to the secondary buttons from https://heroicons.com. Icons are optional for both the primary and secondary buttons.

<div class="gui-split-button">
  <button>Send</button>
  <span class="gui-popup-button" aria-haspopup="true" aria-expanded="false" title="Open for more actions">
    <svg aria-hidden="true" viewBox="0 0 20 20">
      <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>
    <ul class="gui-popup">
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
        </svg>
        Schedule for later
      </button></li>
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
        </svg>
        Delete
      </button></li>
      <li><button>
        <svg aria-hidden="true" viewBox="0 0 24 24">
          <path d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
        </svg>
        Save draft
      </button></li>
    </ul>
  </span>
</div>

Styles

With HTML and content in place, styles are ready to provide color and layout.

Styling the split button container

An inline-flex display type works well for this wrapping component as it should fit inline with other split buttons, actions or elements.

.gui-split-button {
  display: inline-flex;
  border-radius: var(--radius);
  background: var(--theme);
  color: var(--ontheme);
  fill: var(--ontheme);

  touch-action: manipulation;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

The split button.

The <button> styling

Buttons are very good at disguising how much code is required. You might need to undo or replace browser default styles, but you'll also need to enforce some inheritance, add interaction states and adapt to various user preferences and input types. Button styles add up quickly.

These buttons are different from regular buttons because they share a background with a parent element. Usually, a button owns its background and text color. These, though, share it, and only apply their own background on interaction.

.gui-split-button button {
  cursor: pointer;
  appearance: none;
  background: none;
  border: none;

  display: inline-flex;
  align-items: center;
  gap: 1ch;
  white-space: nowrap;

  font-family: inherit;
  font-size: inherit;
  font-weight: 500;

  padding-block: 1.25ch;
  padding-inline: 2.5ch;

  color: var(--ontheme);
  outline-color: var(--theme);
  outline-offset: -5px;
}

Add interaction states with a few CSS pseudo-classes and use of matching custom properties for the state:

.gui-split-button button {
  …

  &:is(:hover, :focus-visible) {
    background: var(--theme-hover);
    color: var(--ontheme);

    & > svg {
      stroke: currentColor;
      fill: none;
    }
  }

  &:active {
    background: var(--theme-active);
  }
}

The primary button needs a few special styles to complete the design effect:

.gui-split-button > button {
  border-end-start-radius: var(--radius);
  border-start-start-radius: var(--radius);

  & > svg {
    fill: none;
    stroke: var(--ontheme);
  }
}

Lastly, for some flair, the light theme button and icon get a shadow:

.gui-split-button {
  @media (--light) {
    & > button,
    & button:is(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--theme-active);
    }
    & > .gui-popup-button > svg,
    & button:is(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--theme-active));
    }
  }
}

A great button has paid attention to the microinteractions and tiny details.

A note about :focus-visible

Notice how the button styles use :focus-visible instead of :focus. :focus is a crucial touch to making an accessible user interface but it does have one downfall: it's not intelligent about whether or not the user needs to see it or not, it'll apply for any focus.

The video below attempts to break this microinteraction down, to show how :focus-visible is an intelligent alternative.

Styling the popup button

A 4ch flexbox for centering an icon and anchoring a popup button list. Like the primary button, it is transparent until otherwise hovered or interacted with, and stretched to fill.

The arrow part of the split button used to trigger the popup.

.gui-popup-button {
  inline-size: 4ch;
  cursor: pointer;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-inline-start: var(--border);
  border-start-end-radius: var(--radius);
  border-end-end-radius: var(--radius);
}

Layer in hover, focus and active states with CSS Nesting and the :is() functional selector:

.gui-popup-button {
  …

  &:is(:hover,:focus-within) {
    background: var(--theme-hover);
  }

  /* fixes iOS trying to be helpful */
  &:focus {
    outline: none;
  }

  &:active {
    background: var(--theme-active);
  }
}

These styles are the primary hook for showing and hiding the popup. When the .gui-popup-button has focus on any of its children, set opacity, position and pointer-events, on the icon and popup.

.gui-popup-button {
  …

  &:focus-within {
    & > svg {
      transition-duration: var(--in-speed);
      transform: rotateZ(.5turn);
    }
    & > .gui-popup {
      transition-duration: var(--in-speed);
      opacity: 1;
      transform: translateY(0);
      pointer-events: auto;
    }
  }
}

With the in and out styles complete, the last piece is to conditionally transition transforms depending on the user's motion preference:

.gui-popup-button {
  …

  @media (--motionOK) {
    & > svg {
      transition: transform var(--out-speed) ease;
    }
    & > .gui-popup {
      transform: translateY(5px);

      transition:
        opacity var(--out-speed) ease,
        transform var(--out-speed) ease;
    }
  }
}

A keen eye on the code would notice opacity is still transitioned for users who prefer reduced motion.

Styling the popup

The .gui-popup element is a floating card button list using custom properties and relative units to be subtly smaller, interactively matched with the primary button, and on brand with its use of color. Notice the icons have less contrast, are thinner, and the shadow has a hint of brand blue to it. Like with buttons, strong UI and UX is a result of these little details stacking up.

A floating card element.

.gui-popup {
  --shadow: 220 70% 15%;
  --shadow-strength: 1%;

  opacity: 0;
  pointer-events: none;

  position: absolute;
  bottom: 80%;
  left: -1.5ch;

  list-style-type: none;
  background: var(--popupbg);
  color: var(--theme-text);
  padding-inline: 0;
  padding-block: .5ch;
  border-radius: var(--radius);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  font-size: .9em;
  transition: opacity var(--out-speed) ease;

  box-shadow:
    0 -2px 5px 0 hsl(var(--shadow) / calc(var(--shadow-strength) + 5%)),
    0 1px 1px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 10%)),
    0 2px 2px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 12%)),
    0 5px 5px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 13%)),
    0 9px 9px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 14%)),
    0 16px 16px -2px hsl(var(--shadow) / calc(var(--shadow-strength) + 20%))
  ;
}

The icons and buttons are given brand colors to style nicely within each dark and light themed card:

Links and icons for checkout, Quick Pay, and Save for later.

.gui-popup {
  …

  & svg {
    fill: var(--popupbg);
    stroke: var(--theme);

    @media (prefers-color-scheme: dark) {
      stroke: var(--theme-border);
    }
  }

  & button {
    color: var(--theme-text);
    width: 100%;
  }
}

The dark theme popup has text and icon shadow additions, plus a slightly more intense box shadow:

The popup in the dark theme.

.gui-popup {
  …

  @media (--dark) {
    --shadow-strength: 5%;
    --shadow: 220 3% 2%;

    & button:not(:focus-visible, :hover) {
      text-shadow: 0 1px 0 var(--ontheme);
    }

    & button:not(:focus-visible, :hover) > svg {
      filter: drop-shadow(0 1px 0 var(--ontheme));
    }
  }
}

Generic <svg> icon styles

All icons are relatively sized to the button font-size they're used within by using the ch unit as the inline-size. Each is also given some styles to help outline icons soft and smooth.

.gui-split-button svg {
  inline-size: 2ch;
  box-sizing: content-box;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 2px;
}

Right-to-left layout

Logical properties do all the complex work. Here's the list of logical properties used: - display: inline-flex creates an inline flex element. - padding-block and padding-inline as a pair, instead of padding shorthand, get the benefits of padding the logical sides. - border-end-start-radius and friends will round corners based on document direction. - inline-size rather than width ensures the size isn't tied to physical dimensions. - border-inline-start adds a border to the start, which might be on the right or the left depending on script direction.

JavaScript

Nearly all of the following JavaScript is to enhance accessibility. Two of my helper libraries are used to make the tasks a little easier. BlingBlingJS is used for succinct DOM queries and easy event listener setup, while roving-ux helps facilitate accessible keyboard and gamepad interactions for the popup.

import $ from 'blingblingjs'
import {rovingIndex} from 'roving-ux'

const splitButtons = $('.gui-split-button')
const popupButtons = $('.gui-popup-button')

With the above libraries imported and the elements selected and saved into variables, upgrading the experience is a few functions away from being complete.

Roving index

When a keyboard or screen reader focuses the .gui-popup-button, we want to forward the focus into the first (or most recently focused) button in the .gui-popup. The library helps us do this with the element and target parameters.

popupButtons.forEach(element =>
  rovingIndex({
    element,
    target: 'button',
  }))

The element now passes focus to the target <button> children and enables standard arrow key navigation to browse options.

Toggling aria-expanded

While it's visually apparent that a popup is showing and hiding, a screen reader needs more than visual cues. JavaScript is used here to compliment the CSS driven :focus-within interaction by toggling a screen reader appropriate attribute.

popupButtons.on('focusin', e => {
  e.currentTarget.setAttribute('aria-expanded', true)
})

popupButtons.on('focusout', e => {
  e.currentTarget.setAttribute('aria-expanded', false)
})

Enabling the Escape key

The user's focus has been intentionally sent to a trap, which means we need to provide a way to leave. The most common way is to allow using the Escape key. To do so, watch for keypresses on the popup button, since any keyboard events on children will bubble up to this parent.

popupButtons.on('keyup', e => {
  if (e.code === 'Escape')
    e.target.blur()
})

If the popup button sees any Escape key presses, it removes focus from itself with blur().

Split button clicks

Finally, if the user clicks, taps, or keyboard interacts with the buttons, the application needs to perform the appropriate action. Event bubbling is used again here, but this time on the .gui-split-button container, to catch button clicks from a child popup or the primary action.

splitButtons.on('click', event => {
  if (event.target.nodeName !== 'BUTTON') return
  console.info(event.target.innerText)
})

Conclusion

Now that you know how I did it, how would you‽ 🙂

Let's diversify our approaches and learn all the ways to build on the web. Create a demo, tweet me links, and I'll add it to the community remixes section below!

Community remixes