Skip to content

feat(button-group): add ButtonGroup component - #640

Open
stritt wants to merge 5 commits into
mainfrom
button-group
Open

feat(button-group): add ButtonGroup component#640
stritt wants to merge 5 commits into
mainfrom
button-group

Conversation

@stritt

@stritt stritt commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a ButtonGroup component that visually joins related buttons into a single segmented control — action rows, icon toolbars, and split buttons (a primary action next to a dropdown trigger).

Motivated by a hand-rolled split-button snippet that flattened corners manually and used raw Tailwind colors (border-blue-700 dark:border-blue-600), which violate kumo's styling rules. ButtonGroup makes this a first-class, reusable component built entirely on semantic tokens.

Design

  • Layout-only wrapper: children keep their own variant, size, and shape. The group flattens inner corners and overlaps borders (-1px) so adjacent buttons share a single seam — the seam color comes from each button's own ring, so it works for every variant without hardcoded colors.
  • Orientation: horizontal (default) and vertical.
  • A11y: role="group" by default, overridable (e.g. role="toolbar" with aria-label). Interacted buttons lift via z-index so focus rings aren't clipped by the overlap.
  • Tokens only: no raw colors, no dark: — passes the custom color lint.

What's included

  • Component + required variant exports (KUMO_BUTTON_GROUP_VARIANTS, _DEFAULT_VARIANTS, _STYLING), forwardRef, displayName
  • 9 unit tests (render, role default/override, orientation, className merge, ref forwarding)
  • Docs page (/components/button-group) with 6 demos (basic, split button, icon toolbar, icons+labels, vertical, sizes)
  • HomeGrid showcase entry + sidebar nav entry
  • Registry category mapping (Action) + changeset (minor)

Testing

  • pnpm --filter @cloudflare/kumo test button-group → 9/9 pass

  • pnpm --filter @cloudflare/kumo typecheck → clean

  • pnpm --filter @cloudflare/kumo lint → no button-group issues

  • pnpm --filter @cloudflare/kumo build + docs build (72 pages, button-group page renders)

  • Reviews

  • bonk has reviewed the change

  • automated review not possible because: new component with visual/interaction behavior best verified by a human in the preview link

  • Tests

  • Tests included/updated

Joins related buttons into a single segmented control. Layout-only: children
keep their own variant, size, and shape, while ButtonGroup flattens inner
corners and overlaps borders so adjacent buttons share one seam.

- Horizontal (default) and vertical orientation
- Works for action rows, icon toolbars, and split buttons (primary action +
  dropdown trigger)
- role="group" by default, overridable (e.g. role="toolbar")
- Uses only kumo semantic tokens (no raw colors, no dark: variant)

Includes unit tests, docs page + demos, HomeGrid showcase entry, sidebar nav
entry, registry category mapping, and a changeset.
@pkg-pr-new

pkg-pr-new Bot commented Jul 17, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@cloudflare/kumo@640

commit: 2b44260

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Docs Preview

View docs preview

Commit: 8f0ded5

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor
Visual Regression Report — 26 changed, 16 unchanged

26 screenshot(s) with visual changes:

Button / Variant: Primary

181 px (0.2%) changed

Before After Diff
Before After Diff

Button / Variant: Destructive

3,539 px (4%) changed

Before After Diff
Before After Diff

Button / Variant: Secondary Destructive

1,121 px (1.27%) changed

Before After Diff
Before After Diff

Button / With Icon

904 px (1.02%) changed

Before After Diff
Before After Diff

Button / Icon Only

112 px (0.13%) changed

Before After Diff
Before After Diff

Button / Loading State

198 px (0.22%) changed

Before After Diff
Before After Diff

Button / Link as Button

1,392 px (1.57%) changed

Before After Diff
Before After Diff

Dialog / Dialog With Actions

297 px (0.34%) changed

Before After Diff
Before After Diff

Dialog / Dialog Basic

405 px (0.46%) changed

Before After Diff
Before After Diff

Dialog / Dialog Confirmation

855 px (0.97%) changed

Before After Diff
Before After Diff

Dialog / Custom Max Width

954 px (1.08%) changed

Before After Diff
Before After Diff

Dialog / Dialog With Select

500 px (0.56%) changed

Before After Diff
Before After Diff

Dialog / Dialog With Combobox

480 px (0.54%) changed

Before After Diff
Before After Diff

Dialog / Dialog With Dropdown

487 px (0.55%) changed

Before After Diff
Before After Diff

Select / Select Basic

859 px (0.97%) changed

Before After Diff
Before After Diff

Select / Select Sizes

3,065 px (1.89%) changed

Before After Diff
Before After Diff

Select / Select Without Label

721 px (0.81%) changed

Before After Diff
Before After Diff

Select / Select With Error

1,979 px (1.9%) changed

Before After Diff
Before After Diff

Select / Select Loading

0 px (0%) changed

Before After Diff
Before After Diff

Select / Select Complex

1,655 px (1.59%) changed

Before After Diff
Before After Diff

Select / Select Disabled Options

1,426 px (1.61%) changed

Before After Diff
Before After Diff

Select / Select Disabled Items

771 px (0.87%) changed

Before After Diff
Before After Diff

Select / Select Grouped

951 px (1.07%) changed

Before After Diff
Before After Diff

Select / Select Grouped With Disabled

999 px (1.13%) changed

Before After Diff
Before After Diff

Select / Select Long List

1,354 px (1.3%) changed

Before After Diff
Before After Diff

Select (Open)

1,082 px (0%) changed

Before After Diff
Before After Diff
16 screenshot(s) unchanged
  • Button / Basic
  • Button / Variant: Secondary
  • Button / Variant: Ghost
  • Button / Variant: Outline
  • Button / Sizes
  • Button / Disabled State
  • Button / Title
  • Button / Disabled Link
  • Dialog / Dialog Sizes
  • Dialog / Dialog Alert
  • Dialog (Open)
  • Select / Select With Description
  • Select / Select Placeholder
  • Select / Select With Tooltip
  • Select / Select Custom Rendering
  • Select / Select Multiple

Generated by Kumo Visual Regression

stritt added 3 commits July 17, 2026 17:07
…t corners

- Remove vertical orientation; ButtonGroup is horizontal-only
- Fix seam jumping on hover: only lift z-index on keyboard focus, never on
  hover, so the shared 1px seam stays put (later sibling paints on top)
- Make corner rounding robust to runtime-injected elements (dropdown popup /
  backdrop / focus guards) by scoping the join selectors to button/a via
  :*-of-type, so outer corners stay rounded when a menu opens
…ne control

Each kumo Button ships its own shadow-xs. Inside a group those drop shadows
overlap at the seams, making the middle button look elevated/boxed and leaving
a clipped-corner artifact on its trailing edge. Drop child shadows within the
group; the shared rings provide all the definition needed.
… dark box

Root cause of the boxed/dark middle segment: kumo's Button draws a near-black
ring on *any* focus (focus:ring-kumo-focus/50), including mouse clicks. Inside a
segmented group that renders as a jarring dark box on the clicked segment.

- Reset the ring to the resting line color for mouse focus
  (:focus:not(:focus-visible)); the fill/selected state is the feedback
- Keep the keyboard focus-visible ring (lifted) for a11y

Verified in a real browser across resting / hover / mouse-click / keyboard-focus
for the segmented control, the primary split button (incl. open dropdown), and
the icon toolbar.

@mattrothenberg mattrothenberg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this. We should probably use Base UI's Toolbar primitive here, no? https://base-ui.com/react/components/toolbar

@ArshVermaGit ArshVermaGit left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me. The component is implemented cleanly, with clear docs, demos, and test coverage. It fits well with the existing component library. LGTM.

@mattrothenberg
mattrothenberg dismissed their stale review July 20, 2026 15:28

OK, my suggestion is not good here. Gonna offer a different idea instead

@mattrothenberg

Copy link
Copy Markdown
Collaborator

I took another look and I think I was being a little too broad with my original suggestion. I don't think every ButtonGroup needs to be built on Base UI's Toolbar, especially a split button where normal Tab behavior makes sense.

The part I am still worried about is encouraging this:

<ButtonGroup role="toolbar" aria-label="Formatting">
  {/* buttons */}
</ButtonGroup>

Adding the role also opts us into the toolbar keyboard contract: one Tab stop, arrow-key navigation between controls, roving tabindex, etc. ButtonGroup doesn't implement that behavior, so it would be easy for consumers to create something that looks and announces itself as a toolbar without actually behaving like one.

Could we keep ButtonGroup focused on visual grouping and split-button use cases, and point people to the existing Base UI-backed Toolbar for actual toolbars? Roughly:

// Related actions / split button
<ButtonGroup>
  <Button>Deploy</Button>
  <Button aria-label="More deployment options">...</Button>
</ButtonGroup>

// A real toolbar with toolbar keyboard behavior
<Toolbar aria-label="Text formatting">
  <Toolbar.Button aria-label="Bold">...</Toolbar.Button>
  <Toolbar.Button aria-label="Italic">...</Toolbar.Button>
  <Toolbar.Button aria-label="Underline">...</Toolbar.Button>
</Toolbar>

So I think the component itself is reasonable; I would just remove the role="toolbar" recommendation and the toolbar example from these docs.

Addresses review feedback about overlap with a toolbar.

- Remove toolbar-flavored usage: ButtonGroup no longer sets/encourages
  role="toolbar"; it renders a plain role="group" (correct for a split button)
- Drop the icon-toolbar, segmented, and action-row demos; ButtonGroup is now
  documented around the split-button pattern (primary action + dropdown)
- Docs: add a 'When to use' section directing multi-button/input grouping to
  Toolbar (which is already built on Base UI's Toolbar primitive)
- Update JSDoc, tests, and changeset accordingly

Rationale: Base UI's Toolbar.Group requires a Toolbar.Root ancestor (roaming
tabindex / arrow-key nav), which is wrong for a standalone split button. Real
toolbars should use kumo Toolbar; ButtonGroup stays a minimal role=group join.
@stritt

stritt commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

@mattrothenberg re: using Base UI's Toolbar primitive here — dug into this and reworked the component around it:

kumo's Toolbar is already built on Base UI's Toolbar primitive (@base-ui/react/toolbar). So rather than have ButtonGroup reinvent a toolbar, I've narrowed its scope:

  • ButtonGroup is now documented/scoped to the split button pattern (a primary action + a dropdown trigger). It renders a plain role="group".
  • Removed the toolbar-flavored demos (the role="toolbar" icon set, segmented rows, etc.).
  • Added a "When to use" section that directs grouping multiple independent buttons/inputs to Toolbar — i.e. the Base-UI-backed primitive you pointed to.

I deliberately did not build ButtonGroup on Base UI's Toolbar.Group, because that part calls useToolbarRootContext() and requires a Toolbar.Root ancestor — which imposes roaming-tabindex / arrow-key navigation. That's correct for a toolbar but wrong for a standalone split button, which should keep normal tab focus. Toolbar.Group renders role="group" anyway, so ButtonGroup matches its semantics without the inappropriate root requirement.

Net: real toolbars → Toolbar (Base UI primitive); tightly-coupled split buttons → ButtonGroup. Lmk if you'd still prefer it folded into Toolbar entirely.

@mattrothenberg

Copy link
Copy Markdown
Collaborator

@stritt 100% agree. Let's proceed with this approach!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants