;
+
+export const Base: Story = {
+ render: (args) => (
+
+
+
+
+
+ ),
+};
+
+export const Variant: Story = {
+ render: () => (
+
+ {buttonGroupPropVariant.map((variant) => (
+
+ }>Archive
+ }>Snooze
+ }>Delete
+
+ ))}
+
+ ),
+};
+
+export const Content: Story = {
+ render: () => (
+
+
+ }
+ endIcon={}
+ >
+ Archive
+
+ }
+ endIcon={}
+ >
+ Report
+
+ } endIcon={}>
+ Snooze
+
+
+
+ }>Archive
+ }>Report
+ }>Snooze
+
+
+
+
+
+
+
+ }
+ aria-label="Archive"
+ onlyIcon
+ />
+ }
+ aria-label="Report"
+ onlyIcon
+ />
+ } aria-label="Snooze" />
+
+
+ ),
+};
+
+export const MixedContent: Story = {
+ render: () => (
+
+ } endIcon={}>
+ Archive
+
+ }>Report
+
+ }
+ aria-label="More actions"
+ onlyIcon
+ />
+
+ ),
+};
+
+export const OnlyIcon: Story = {
+ render: () => (
+
+ {buttonGroupPropVariant.map((variant) => (
+
+ } aria-label="Decrease" onlyIcon />
+ } aria-label="Increase" onlyIcon />
+
+ ))}
+
+ ),
+};
+
+export const Orientation: Story = {
+ render: () => (
+
+ {buttonGroupPropVariant.map((variant) => (
+
+ } aria-label="Increase" onlyIcon />
+ } aria-label="Decrease" onlyIcon />
+
+ ))}
+
+ ),
+};
+
+export const Disabled: Story = {
+ render: () => (
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+};
+
+export const Loading: Story = {
+ render: () => (
+
+
+
+ }>
+ Snooze
+
+
+ ),
+};
+
+export const RootTag: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const SplitButton: Story = {
+ render: () => {
+ const splitButtonVariants: {
+ label: string;
+ variant: ButtonGroupPropVariant;
+ }[] = [
+ { label: 'Filled Contrast', variant: 'contrast-filled' },
+ { label: 'Filled Fade Contrast', variant: 'fade-contrast-filled' },
+ { label: 'Outline Fade Theme', variant: 'fade-theme-outline' },
+ { label: 'Outline Fade Contrast', variant: 'fade-contrast-outline' },
+ { label: 'Transparent Theme', variant: 'theme-transparent' },
+ { label: 'Transparent Contrast', variant: 'contrast-transparent' },
+ ];
+
+ return (
+
+ {splitButtonVariants.map(({ label, variant }) => (
+
+ {label}
+
+ }>Split Button
+
+
+
+ ))}
+
+ );
+ },
+};
diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.test.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.test.tsx
new file mode 100644
index 00000000..bde31716
--- /dev/null
+++ b/packages/components/src/components/ButtonGroup/ButtonGroup.test.tsx
@@ -0,0 +1,181 @@
+import { createRef } from 'react';
+
+import { screen, render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+
+import { Button } from '../Button';
+
+import {
+ ButtonGroup,
+ buttonGroupPropOrientation,
+ buttonGroupPropVariant,
+} from './index.js';
+
+describe('ButtonGroup', () => {
+ const baseProps = { 'data-testid': 'button-group' };
+
+ const getRoot = () => screen.getByTestId('button-group');
+
+ it('should receive ref', () => {
+ const ref = createRef();
+ const { container } = render();
+ const group = container.querySelector('div');
+
+ expect(ref.current).toBe(group);
+ });
+
+ it('should render the component with the correct tag', () => {
+ render();
+
+ expect(getRoot().tagName).toBe('SECTION');
+ });
+
+ it('should apply a custom class name', () => {
+ render();
+
+ expect(getRoot()).toHaveClass('custom');
+ });
+
+ it('should apply custom styles', () => {
+ render();
+
+ expect(getRoot()).toHaveStyle({ opacity: '0.5' });
+ });
+
+ it('should render children', () => {
+ render(
+
+
+
+
+ );
+
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ });
+
+ it('should have the group role', () => {
+ render();
+
+ expect(getRoot()).toHaveAttribute('role', 'group');
+ });
+
+ describe('check the orientation prop', () => {
+ it('should default to the horizontal orientation', () => {
+ render();
+
+ expect(getRoot()).toHaveAttribute('data-orientation', 'horizontal');
+ });
+
+ it.each(buttonGroupPropOrientation)(
+ 'should apply the orientation as a "%s"',
+ (orientation) => {
+ render();
+
+ expect(getRoot()).toHaveAttribute('data-orientation', orientation);
+ }
+ );
+ });
+
+ describe('check the variant prop', () => {
+ it('should default to the fade-contrast-outline variant', () => {
+ render(
+
+
+
+ );
+
+ expect(getRoot()).toHaveAttribute(
+ 'data-variant',
+ 'fade-contrast-outline'
+ );
+
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'data-variant',
+ 'fade-contrast-outline'
+ );
+ });
+
+ it.each(buttonGroupPropVariant)(
+ 'should pass the "%s" variant to the nested buttons',
+ (variant) => {
+ render(
+
+
+
+ );
+
+ expect(getRoot()).toHaveAttribute('data-variant', variant);
+
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'data-variant',
+ variant
+ );
+ }
+ );
+
+ it("should override the button's own variant", () => {
+ render(
+
+
+
+
+ );
+
+ screen.getAllByRole('button').forEach((button) => {
+ expect(button).toHaveAttribute('data-variant', 'fade-contrast-outline');
+ });
+ });
+
+ it('should not affect a button rendered outside of the group', () => {
+ render();
+
+ expect(screen.getByRole('button')).toHaveAttribute(
+ 'data-variant',
+ 'contrast-filled'
+ );
+ });
+ });
+
+ describe('check the isDisabled prop', () => {
+ it('should be enabled by default', () => {
+ render(
+
+
+
+ );
+
+ expect(getRoot()).not.toHaveAttribute('data-disabled');
+ expect(screen.getByRole('button')).not.toBeDisabled();
+ });
+
+ it('should disable every nested button', () => {
+ render(
+
+
+
+
+ );
+
+ expect(getRoot()).toHaveAttribute('data-disabled', 'true');
+
+ screen.getAllByRole('button').forEach((button) => {
+ expect(button).toBeDisabled();
+ expect(button).toHaveAttribute('data-disabled', 'true');
+ });
+ });
+
+ it('should disable a single button while the group stays enabled', () => {
+ render(
+
+
+
+
+ );
+
+ const [archive, remove] = screen.getAllByRole('button');
+
+ expect(archive).not.toBeDisabled();
+ expect(remove).toBeDisabled();
+ });
+ });
+});
diff --git a/packages/components/src/components/ButtonGroup/ButtonGroup.tsx b/packages/components/src/components/ButtonGroup/ButtonGroup.tsx
new file mode 100644
index 00000000..b4911279
--- /dev/null
+++ b/packages/components/src/components/ButtonGroup/ButtonGroup.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { useMemo } from 'react';
+import type { ComponentPropsWithRef, ElementType } from 'react';
+
+import { clsx, polymorphicForwardRef } from '@koobiq/react-core';
+
+import s from './ButtonGroup.module.css';
+import { ButtonGroupContext } from './ButtonGroupContext';
+import type { ButtonGroupBaseProps } from './types';
+
+/** The ButtonGroup joins buttons that belong together. */
+export const ButtonGroup = polymorphicForwardRef<'div', ButtonGroupBaseProps>(
+ (props, ref) => {
+ const {
+ as: Tag = 'div',
+ orientation = 'horizontal',
+ variant = 'fade-contrast-outline',
+ isDisabled,
+ className,
+ children,
+ ...other
+ } = props;
+
+ const contextValue = useMemo(
+ () => ({ variant, isDisabled }),
+ [variant, isDisabled]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ }
+);
+
+ButtonGroup.displayName = 'ButtonGroup';
+
+export type ButtonGroupProps =
+ ComponentPropsWithRef>;
diff --git a/packages/components/src/components/ButtonGroup/ButtonGroupContext.ts b/packages/components/src/components/ButtonGroup/ButtonGroupContext.ts
new file mode 100644
index 00000000..a01a461c
--- /dev/null
+++ b/packages/components/src/components/ButtonGroup/ButtonGroupContext.ts
@@ -0,0 +1,18 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+
+import type { ButtonGroupPropVariant } from './types';
+
+export type ButtonGroupContextProps = {
+ /** The variant applied to every nested button, overriding their own `variant`. */
+ variant?: ButtonGroupPropVariant;
+ /** If `true`, every nested button is disabled. */
+ isDisabled?: boolean;
+};
+
+export const ButtonGroupContext = createContext({});
+
+export function useButtonGroupContext() {
+ return useContext(ButtonGroupContext);
+}
diff --git a/packages/components/src/components/ButtonGroup/__stories__/styles.css b/packages/components/src/components/ButtonGroup/__stories__/styles.css
new file mode 100644
index 00000000..c1055939
--- /dev/null
+++ b/packages/components/src/components/ButtonGroup/__stories__/styles.css
@@ -0,0 +1,45 @@
+.split-button-demo {
+ display: grid;
+ grid-template-columns: repeat(2, max-content);
+ gap: var(--kbq-size-3xl) var(--kbq-size-6xl);
+}
+
+.split-button-demo__group > [data-slot='button']:not(:last-child) {
+ margin-inline-end: 0;
+ box-shadow: none;
+}
+
+.split-button-demo__group[data-variant='contrast-filled'] {
+ --split-button-divider-color: var(--kbq-line-on-contrast-fade);
+}
+
+.split-button-demo__group[data-variant='fade-contrast-filled'],
+.split-button-demo__group[data-variant='fade-contrast-outline'],
+.split-button-demo__group[data-variant='contrast-transparent'] {
+ --split-button-divider-color: var(--kbq-line-contrast-fade);
+}
+
+.split-button-demo__group[data-variant='fade-theme-outline'],
+.split-button-demo__group[data-variant='theme-transparent'] {
+ --split-button-divider-color: var(--kbq-line-theme-fade);
+}
+
+.split-button-demo__group > [data-slot='button']:last-child {
+ background-image: linear-gradient(
+ var(--split-button-divider-color),
+ var(--split-button-divider-color)
+ );
+ background-repeat: no-repeat;
+ background-position: 0 50%;
+ background-size: var(--kbq-size-border-width) var(--kbq-size-l);
+}
+
+.split-button-demo__group
+ > [data-slot='button']:last-child[aria-expanded='true'],
+.split-button-demo__group
+ > [data-slot='button']:last-child:is(:hover, :focus-visible),
+.split-button-demo__group
+ > [data-slot='button']:not(:last-child):is(:hover, :focus-visible)
+ + [data-slot='button']:last-child {
+ background-image: none;
+}
diff --git a/packages/components/src/components/ButtonGroup/index.ts b/packages/components/src/components/ButtonGroup/index.ts
new file mode 100644
index 00000000..8d1f8a4d
--- /dev/null
+++ b/packages/components/src/components/ButtonGroup/index.ts
@@ -0,0 +1,3 @@
+export * from './ButtonGroup';
+export * from './ButtonGroupContext';
+export * from './types';
diff --git a/packages/components/src/components/ButtonGroup/types.ts b/packages/components/src/components/ButtonGroup/types.ts
new file mode 100644
index 00000000..1ac586c2
--- /dev/null
+++ b/packages/components/src/components/ButtonGroup/types.ts
@@ -0,0 +1,35 @@
+import type { CSSProperties, ReactNode } from 'react';
+
+import { buttonPropVariant } from '../Button/types.js';
+
+export const buttonGroupPropOrientation = ['horizontal', 'vertical'] as const;
+
+export type ButtonGroupPropOrientation =
+ (typeof buttonGroupPropOrientation)[number];
+
+export const buttonGroupPropVariant = buttonPropVariant;
+
+export type ButtonGroupPropVariant = (typeof buttonGroupPropVariant)[number];
+
+export type ButtonGroupBaseProps = {
+ /** The content of the component. */
+ children?: ReactNode;
+ /**
+ * The orientation of the group.
+ * @default 'horizontal'
+ */
+ orientation?: ButtonGroupPropOrientation;
+ /**
+ * The variant applied to every nested button, overriding their own `variant`.
+ * @default 'fade-contrast-outline'
+ */
+ variant?: ButtonGroupPropVariant;
+ /** If `true`, every nested button is disabled. */
+ isDisabled?: boolean;
+ /** Additional CSS-classes. */
+ className?: string;
+ /** Inline styles. */
+ style?: CSSProperties;
+ /** Unique identifier for testing purposes. */
+ 'data-testid'?: string | number;
+};
diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts
index 5a00d1d6..2736181b 100644
--- a/packages/components/src/components/index.ts
+++ b/packages/components/src/components/index.ts
@@ -5,6 +5,7 @@ export * from './FormField';
export * from './IconItem';
export * from './Alert';
export * from './Button';
+export * from './ButtonGroup';
export * from './IconButton';
export * from './Typography';
export * from './Checkbox';
diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json
index babae623..b1b17b23 100644
--- a/tools/api-extractor/config.json
+++ b/tools/api-extractor/config.json
@@ -8,6 +8,7 @@
"Badge",
"Breadcrumbs",
"Button",
+ "ButtonGroup",
"ButtonToggleGroup",
"Calendar",
"Checkbox",
diff --git a/tools/public_api_guard/components/Button.api.md b/tools/public_api_guard/components/Button.api.md
index 461191c9..7550bda9 100644
--- a/tools/public_api_guard/components/Button.api.md
+++ b/tools/public_api_guard/components/Button.api.md
@@ -37,7 +37,7 @@ export type ButtonProps = ComponentPropsWithR
export type ButtonPropVariant = (typeof buttonPropVariant)[number];
// @public (undocumented)
-export const buttonPropVariant: readonly ["contrast-filled", "contrast-transparent", "fade-contrast-filled", "fade-contrast-outline", "fade-theme-outline", "theme-transparent"];
+export const buttonPropVariant: readonly ["contrast-filled", "fade-contrast-filled", "fade-contrast-outline", "fade-theme-outline", "contrast-transparent", "theme-transparent"];
// (No @packageDocumentation comment for this package)
diff --git a/tools/public_api_guard/components/ButtonGroup.api.md b/tools/public_api_guard/components/ButtonGroup.api.md
new file mode 100644
index 00000000..bab0c947
--- /dev/null
+++ b/tools/public_api_guard/components/ButtonGroup.api.md
@@ -0,0 +1,57 @@
+## API Report File for "koobiq-react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import type { ComponentPropsWithRef } from 'react';
+import { Context } from 'react';
+import type { CSSProperties } from 'react';
+import type { ElementType } from 'react';
+import { PolyForwardComponent } from '@koobiq/react-core';
+import type { ReactNode } from 'react';
+
+// @public
+export const ButtonGroup: PolyForwardComponent<"div", ButtonGroupBaseProps, ElementType>;
+
+// @public (undocumented)
+export type ButtonGroupBaseProps = {
+ children?: ReactNode;
+ orientation?: ButtonGroupPropOrientation;
+ variant?: ButtonGroupPropVariant;
+ isDisabled?: boolean;
+ className?: string;
+ style?: CSSProperties;
+ 'data-testid'?: string | number;
+};
+
+// @public (undocumented)
+export const ButtonGroupContext: Context;
+
+// @public (undocumented)
+export type ButtonGroupContextProps = {
+ variant?: ButtonGroupPropVariant;
+ isDisabled?: boolean;
+};
+
+// @public (undocumented)
+export type ButtonGroupPropOrientation = (typeof buttonGroupPropOrientation)[number];
+
+// @public (undocumented)
+export const buttonGroupPropOrientation: readonly ["horizontal", "vertical"];
+
+// @public (undocumented)
+export type ButtonGroupProps = ComponentPropsWithRef>;
+
+// @public (undocumented)
+export type ButtonGroupPropVariant = (typeof buttonGroupPropVariant)[number];
+
+// @public (undocumented)
+export const buttonGroupPropVariant: readonly ["contrast-filled", "fade-contrast-filled", "fade-contrast-outline", "fade-theme-outline", "contrast-transparent", "theme-transparent"];
+
+// @public (undocumented)
+export function useButtonGroupContext(): ButtonGroupContextProps;
+
+// (No @packageDocumentation comment for this package)
+
+```