defuss-shadcn / Actions / button-group
Button GroupMOL
Groups related buttons with connected borders. Built on a <div> container that merges adjacent button edges. CSS only - no JavaScript.
On this page (6)
§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