Theme
On this page (6)
Component Skill — components/button-group/component-skill.md

Native basis

<div> container with .btn buttons. Visually merges adjacent buttons with connected borders.

Web Platform APIs

role="group"aria-labelrole="separator":has()Logical propertiesforced-colors

Classes

.btn-group.btn

Data attributes

• data-orientation - values: vertical

Notes

• Button groups work best with the outline variant - the connected borders create a cohesive unit.

• Adjacent button borders collapse so only one border renders between buttons.

• The group removes internal border-radii to create seamless joins.

• Use <hr role="separator"> in Split buttons to divide the primary action from its dropdown trigger - it is the single 1px line between them (the neighbouring borders drop, the inner corners stay square), and the trigger opens a Dropdown menu placed after the group.

• Uses CSS logical properties for automatic RTL support.

• No JavaScript required - this is purely a CSS layout component.

§Horizontal

Default horizontal group with connected outline buttons.

§With icons

Icon-only button group for compact toolbars.

§Vertical

Stacked buttons with connected top/bottom borders.

§Sizes

Button size is controlled with data-size on the individual .btn children - group them at any step of the full scale.

§Split button

The main action plus a menu of related ones. The separator is the single 1px line between them - the two halves stay one control. The chevron is a Dropdown trigger (data-dropdown-trigger + aria-haspopup="menu"); the menu sits after the group and lines up with the chevron's end (data-align="end"). Arrow keys move through the menu, Escape closes it and returns focus to the chevron.

§CSS view file

/* -- Button Group component ------------------------------------- */
@layer components {
  .btn-group {
    display: inline-flex;
    align-items: stretch;
    /* Collapse borders between adjacent buttons */
    & > .btn + .btn {
      margin-inline-start: -1px;
    }
    /* Remove internal radii for connected appearance. The orientation gate is
       :where()-wrapped so these INLINE-corner rules only ever apply to
       horizontal groups; ungated they also squared vertical buttons' corners
       at a higher specificity than the vertical block below, leaving "Top"
       with a rounded top-left but a square top-right (issue #13). */
    &:where(:not([data-orientation="vertical"])) > .btn:not(:first-child) {
      border-start-start-radius: 0;
      border-end-start-radius: 0;
    }
    &:where(:not([data-orientation="vertical"])) > .btn:not(:last-child) {
      border-start-end-radius: 0;
      border-end-end-radius: 0;
    }
    /* A separator JOINS the control (a split button: action | menu trigger):
       it is the one dividing line, so the buttons on either side keep their
       square inner corners and drop the border that touches it - 1px total,
       not border + rule + border with rounded inner corners (which read as
       two buttons with a gap). */
    &:where(:not([data-orientation="vertical"])) {
      & > .btn:has(+ [role="separator"]) { border-inline-end-width: 0; }
      & > [role="separator"] + .btn { border-inline-start-width: 0; }
    }
    /* Ensure hovered/focused button stacks above its neighbors */
    & > .btn:hover,
    & > .btn:focus-visible {
      position: relative;
      z-index: 1;
    }
    /* -- Separator ------------------------------------------- */
    & > [role="separator"] {
      width: 1px;
      align-self: stretch;
      background: var(--border);
      border: 0;
      margin: 0;
      padding: 0;
      flex-shrink: 0;
    }
    /* -- Vertical orientation -------------------------------- */
    &[data-orientation="vertical"] {
      flex-direction: column;
      align-items: stretch;
      & > .btn + .btn {
        margin-inline-start: 0;
        margin-top: -1px;
      }
      /* Square exactly the BLOCK corners that touch a neighbour - a
         non-first button squares its top edge, a non-last its bottom
         edge; the base .btn radius keeps every outer corner rounded on
         both sides. No per-corner "restore" overrides: the last block's
         restores re-rounded a corner the previous block had just
         squared, which (together with the ungated horizontal rules)
         produced the asymmetric Top/Middle corners of issue #13. */
      & > .btn:not(:first-child) {
        border-start-start-radius: 0;
        border-start-end-radius: 0;
      }
      & > .btn:not(:last-child) {
        border-end-start-radius: 0;
        border-end-end-radius: 0;
      }
      & > [role="separator"] {
        width: auto;
        height: 1px;
      }
      & > .btn:has(+ [role="separator"]) { border-block-end-width: 0; }
      & > [role="separator"] + .btn { border-block-start-width: 0; }
    }
  }
  @media (forced-colors: active) {
    .btn-group > [role="separator"] {
      background: ButtonText;
    }
  }
}

Comments, ideas or improvements? Edit this page's source on GitHub