diff --git a/docs-ui/src/content/toggle-button-group.mdx b/docs-ui/src/content/toggle-button-group.mdx
new file mode 100644
index 0000000000..fa979d65fe
--- /dev/null
+++ b/docs-ui/src/content/toggle-button-group.mdx
@@ -0,0 +1,115 @@
+import { PropsTable } from '@/components/PropsTable';
+import { Snippet } from '@/components/Snippet';
+import { CodeBlock } from '@/components/CodeBlock';
+import {
+ toggleButtonGroupPropDefs,
+ toggleButtonGroupUsageSnippet,
+ toggleButtonGroupSingleSnippet,
+ toggleButtonGroupMultipleSnippet,
+ toggleButtonGroupVerticalSnippet,
+ toggleButtonGroupDisabledSnippet,
+ toggleButtonGroupDisallowEmptySnippet,
+ toggleButtonGroupIconsSnippet,
+ toggleButtonGroupIconsOnlySnippet,
+} from './toggle-button-group.props';
+import { ToggleButtonGroupSnippet } from '@/snippets/stories-snippets';
+import { PageTitle } from '@/components/PageTitle';
+import { Theming } from '@/components/Theming';
+import { ChangelogComponent } from '@/components/ChangelogComponent';
+import { ToggleButtonGroupDefinition } from '../utils/definitions';
+
+
+
+}
+ code={toggleButtonGroupSingleSnippet}
+/>
+
+## Usage
+
+
+
+## API reference
+
+
+
+## Examples
+
+### Single Selection
+
+}
+ code={toggleButtonGroupSingleSnippet}
+/>
+
+### Multiple Selection
+
+}
+ code={toggleButtonGroupMultipleSnippet}
+/>
+
+### Icons and Text
+
+}
+ code={toggleButtonGroupIconsSnippet}
+/>
+
+### Icons Only
+
+}
+ code={toggleButtonGroupIconsOnlySnippet}
+/>
+
+### Disallow Empty Selection
+
+}
+ code={toggleButtonGroupDisallowEmptySnippet}
+/>
+
+### Vertical Orientation
+
+}
+ code={toggleButtonGroupVerticalSnippet}
+/>
+
+### Disabled
+
+}
+ code={toggleButtonGroupDisabledSnippet}
+/>
+
+
+
+
diff --git a/docs-ui/src/content/toggle-button-group.props.ts b/docs-ui/src/content/toggle-button-group.props.ts
new file mode 100644
index 0000000000..94299f71a3
--- /dev/null
+++ b/docs-ui/src/content/toggle-button-group.props.ts
@@ -0,0 +1,75 @@
+import {
+ classNamePropDefs,
+ stylePropDefs,
+ type PropDef,
+} from '@/utils/propDefs';
+
+export const toggleButtonGroupPropDefs: Record = {
+ selectionMode: {
+ type: 'enum',
+ values: ['single', 'multiple'],
+ default: 'single',
+ },
+ orientation: {
+ type: 'enum',
+ values: ['horizontal', 'vertical'],
+ default: 'horizontal',
+ responsive: true,
+ },
+ selectedKeys: { type: 'enum', values: ['Iterable'] },
+ defaultSelectedKeys: { type: 'enum', values: ['Iterable'] },
+ onSelectionChange: { type: 'enum', values: ['(keys) => void'] },
+ isDisabled: { type: 'boolean', default: 'false' },
+ disallowEmptySelection: { type: 'boolean', default: 'false' },
+ ...classNamePropDefs,
+ ...stylePropDefs,
+};
+
+export const toggleButtonGroupUsageSnippet = `import { ToggleButtonGroup, ToggleButton } from '@backstage/ui';
+
+
+ Dogs
+ Cats
+`;
+
+export const toggleButtonGroupSingleSnippet = `
+ Apples
+ Oranges
+ Bananas
+`;
+
+export const toggleButtonGroupMultipleSnippet = `
+ Frontend
+ Backend
+ Platform
+`;
+
+export const toggleButtonGroupVerticalSnippet = `
+ Low
+ Medium
+ High
+`;
+
+export const toggleButtonGroupDisabledSnippet = `
+ Cat
+ Dog
+ Bird
+`;
+
+export const toggleButtonGroupDisallowEmptySnippet = `
+ One
+ Two
+ Three
+`;
+
+export const toggleButtonGroupIconsSnippet = `
+ Cloud
+ Starred
+ Next
+`;
+
+export const toggleButtonGroupIconsOnlySnippet = `
+
+
+
+`;
diff --git a/docs-ui/src/content/toggle-button.mdx b/docs-ui/src/content/toggle-button.mdx
new file mode 100644
index 0000000000..2ec644cdd5
--- /dev/null
+++ b/docs-ui/src/content/toggle-button.mdx
@@ -0,0 +1,94 @@
+import { PropsTable } from '@/components/PropsTable';
+import { Snippet } from '@/components/Snippet';
+import { CodeBlock } from '@/components/CodeBlock';
+import { ToggleButtonSnippet } from '@/snippets/stories-snippets';
+import {
+ toggleButtonPropDefs,
+ toggleButtonUsageSnippet,
+ toggleButtonVariantsSnippet,
+ toggleButtonSizesSnippet,
+ toggleButtonIconsSnippet,
+ toggleButtonDisabledSnippet,
+ toggleButtonControlledSnippet,
+} from './toggle-button.props';
+import { PageTitle } from '@/components/PageTitle';
+import { Theming } from '@/components/Theming';
+import { ChangelogComponent } from '@/components/ChangelogComponent';
+import { ToggleButtonDefinition } from '../utils/definitions';
+
+
+
+}
+ code={toggleButtonVariantsSnippet}
+/>
+
+## Usage
+
+
+
+## API reference
+
+
+
+## Examples
+
+### Variants
+
+}
+ code={toggleButtonVariantsSnippet}
+/>
+
+### Sizes
+
+}
+ code={toggleButtonSizesSnippet}
+/>
+
+### With Icons
+
+}
+ code={toggleButtonIconsSnippet}
+/>
+
+### Disabled
+
+}
+ code={toggleButtonDisabledSnippet}
+/>
+
+### Controlled
+
+}
+ code={toggleButtonControlledSnippet}
+/>
+
+
+
+
+
diff --git a/docs-ui/src/content/toggle-button.props.ts b/docs-ui/src/content/toggle-button.props.ts
new file mode 100644
index 0000000000..71625e7e6f
--- /dev/null
+++ b/docs-ui/src/content/toggle-button.props.ts
@@ -0,0 +1,59 @@
+import {
+ classNamePropDefs,
+ stylePropDefs,
+ type PropDef,
+} from '@/utils/propDefs';
+
+export const toggleButtonPropDefs: Record = {
+ variant: {
+ type: 'enum',
+ values: ['primary', 'secondary'],
+ default: 'primary',
+ responsive: true,
+ },
+ size: {
+ type: 'enum',
+ values: ['small', 'medium'],
+ default: 'small',
+ responsive: true,
+ },
+ iconStart: { type: 'enum', values: ['ReactNode'] },
+ iconEnd: { type: 'enum', values: ['ReactNode'] },
+ isSelected: { type: 'boolean' },
+ defaultSelected: { type: 'boolean' },
+ onChange: { type: 'enum', values: ['(isSelected: boolean) => void'] },
+ isDisabled: { type: 'boolean', default: 'false' },
+ children: { type: 'enum', values: ['ReactNode'] },
+ ...classNamePropDefs,
+ ...stylePropDefs,
+};
+
+export const toggleButtonUsageSnippet = `import { ToggleButton } from '@backstage/ui';
+
+Toggle`;
+
+export const toggleButtonVariantsSnippet = `
+ Primary
+ Secondary
+`;
+
+export const toggleButtonSizesSnippet = `
+ Small
+ Medium
+`;
+
+export const toggleButtonIconsSnippet = `
+ Favorite
+ Confirm
+`;
+
+export const toggleButtonDisabledSnippet = `
+ Disabled
+ Selected
+`;
+
+export const toggleButtonControlledSnippet = `const [selected, setSelected] = useState(false);
+
+
+ {selected ? 'On' : 'Off'}
+`;
diff --git a/docs-ui/src/snippets/stories-snippets.tsx b/docs-ui/src/snippets/stories-snippets.tsx
index 0507c85c9f..ba722a5edb 100644
--- a/docs-ui/src/snippets/stories-snippets.tsx
+++ b/docs-ui/src/snippets/stories-snippets.tsx
@@ -30,6 +30,8 @@ import * as TableStories from '../../../packages/ui/src/components/Table/stories
import * as TagGroupStories from '../../../packages/ui/src/components/TagGroup/TagGroup.stories';
import * as PasswordFieldStories from '../../../packages/ui/src/components/PasswordField/PasswordField.stories';
import * as VisuallyHiddenStories from '../../../packages/ui/src/components/VisuallyHidden/VisuallyHidden.stories';
+import * as ToggleButtonStories from '../../../packages/ui/src/components/ToggleButton/ToggleButton.stories';
+import * as ToggleButtonGroupStories from '../../../packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories';
// Helper function to create snippet components
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -75,6 +77,10 @@ export const HeaderSnippet = createSnippetComponent(HeaderStories);
export const HeaderPageSnippet = createSnippetComponent(HeaderPageStories);
export const TableSnippet = createSnippetComponent(TableStories);
export const TagGroupSnippet = createSnippetComponent(TagGroupStories);
+export const ToggleButtonSnippet = createSnippetComponent(ToggleButtonStories);
+export const ToggleButtonGroupSnippet = createSnippetComponent(
+ ToggleButtonGroupStories,
+);
export const VisuallyHiddenSnippet = createSnippetComponent(
VisuallyHiddenStories,
);
diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts
index de369043d1..b20725142c 100644
--- a/docs-ui/src/utils/data.ts
+++ b/docs-ui/src/utils/data.ts
@@ -116,6 +116,14 @@ export const components: Page[] = [
title: 'TextField',
slug: 'text-field',
},
+ {
+ title: 'ToggleButton',
+ slug: 'toggle-button',
+ },
+ {
+ title: 'ToggleButtonGroup',
+ slug: 'toggle-button-group',
+ },
{
title: 'Tooltip',
slug: 'tooltip',
diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.module.css b/packages/ui/src/components/ToggleButton/ToggleButton.module.css
new file mode 100644
index 0000000000..ad3b173f04
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/ToggleButton.module.css
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@layer tokens, base, components, utilities;
+
+@layer components {
+ .bui-ToggleButton {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--bui-space-1_5);
+ border: none;
+ border-radius: var(--bui-radius-2);
+ background: var(--bui-bg-surface-1);
+ color: var(--bui-fg-primary);
+ font-family: var(--bui-font-regular);
+ font-weight: var(--bui-font-weight-bold);
+ padding: 0 var(--bui-space-2);
+ cursor: pointer;
+ transition: background-color 150ms ease, box-shadow 150ms ease,
+ color 150ms ease, transform 100ms ease;
+ }
+
+ .bui-ToggleButton[data-variant='secondary'] {
+ background: transparent;
+ color: var(--bui-fg-primary);
+ box-shadow: inset 0 0 0 1px var(--bui-border);
+ }
+
+ .bui-ToggleButton[data-variant='primary'][data-selected],
+ .bui-ToggleButton[data-variant='primary'][data-pressed] {
+ background: var(--bui-bg-solid);
+ color: var(--bui-fg-solid);
+ }
+
+ .bui-ToggleButton[data-variant='primary']:not([data-selected])[data-hovered] {
+ background: var(--bui-bg-surface-2);
+ }
+
+ .bui-ToggleButton[data-variant='secondary'][data-selected],
+ .bui-ToggleButton[data-variant='secondary'][data-pressed] {
+ background: var(--bui-bg-surface-2);
+ box-shadow: inset 0 0 0 1px var(--bui-border-hover);
+ }
+
+ .bui-ToggleButton[data-focus-visible] {
+ outline: 2px solid var(--bui-ring);
+ outline-offset: 2px;
+ }
+
+ .bui-ToggleButton[data-disabled] {
+ cursor: not-allowed;
+ color: var(--bui-fg-disabled);
+ background: var(--bui-bg-disabled);
+ box-shadow: inset 0 0 0 1px var(--bui-border-disabled);
+ }
+
+ .bui-ToggleButton[data-pressed]:not([data-disabled]) {
+ transform: scale(0.98);
+ }
+
+ .bui-ToggleButton[data-size='small'] {
+ height: 2rem;
+ font-size: var(--bui-font-size-3);
+ padding: 0 var(--bui-space-2);
+
+ svg {
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+
+ .bui-ToggleButton[data-size='medium'] {
+ height: 2.5rem;
+ font-size: var(--bui-font-size-4);
+ padding: 0 var(--bui-space-3);
+
+ svg {
+ width: 1.25rem;
+ height: 1.25rem;
+ }
+ }
+
+ .bui-ToggleButtonContent {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--bui-space-1_5);
+ height: 100%;
+ width: 100%;
+ }
+}
diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx
new file mode 100644
index 0000000000..55395542e3
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx
@@ -0,0 +1,226 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import preview from '../../../../../.storybook/preview';
+import { ToggleButton } from './ToggleButton';
+import { ToggleButtonGroup } from '../ToggleButtonGroup/ToggleButtonGroup';
+import { Flex } from '../Flex';
+import { Text } from '../Text';
+import { useState } from 'react';
+import {
+ RiCheckLine,
+ RiHeartLine,
+ RiStarFill,
+ RiStarLine,
+ RiCloudLine,
+ RiArrowRightSLine,
+} from '@remixicon/react';
+
+const meta = preview.meta({
+ title: 'Backstage UI/ToggleButton',
+ component: ToggleButton,
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['small', 'medium'],
+ },
+ variant: {
+ control: 'select',
+ options: ['primary', 'secondary'],
+ },
+ },
+});
+
+export const Default = meta.story({
+ args: {
+ children: 'Toggle',
+ },
+});
+
+export const Variants = meta.story({
+ args: {
+ children: 'Toggle',
+ },
+ parameters: {
+ argTypes: {
+ variant: {
+ control: false,
+ },
+ },
+ },
+ render: () => (
+
+ Primary
+ Secondary
+
+ ),
+});
+
+export const Sizes = meta.story({
+ args: {
+ children: 'Toggle',
+ },
+ parameters: {
+ argTypes: {
+ size: {
+ control: false,
+ },
+ },
+ },
+ render: () => (
+
+ Small
+ Medium
+
+ ),
+});
+
+export const WithIcons = meta.story({
+ args: {
+ children: 'Favorite',
+ },
+ render: args => (
+
+ } />
+ } defaultSelected />
+ } />
+ } />
+
+ ),
+});
+
+export const IconsOnly = meta.story({
+ render: () => (
+
+ } />
+ }
+ />
+ } />
+
+ ),
+});
+
+export const IconsAndText = meta.story({
+ render: () => (
+
+ }>Cloud
+ }>Star
+ }>Next
+
+ ),
+});
+
+export const MixedIcons = meta.story({
+ render: () => (
+
+ }>
+ Cloud
+
+ } />
+ }>
+ Next
+
+
+ ),
+});
+
+export const Disabled = meta.story({
+ render: () => (
+
+
+ Primary
+
+
+ Secondary
+
+
+ Selected
+
+
+ ),
+});
+
+export const Controlled = meta.story({
+ render: () => {
+ const [selected, setSelected] = useState(false);
+ return (
+
+ : }
+ >
+ {selected ? 'Starred' : 'Not starred'}
+
+ State: {selected ? 'selected' : 'unselected'}
+
+ );
+ },
+});
+
+export const GroupSingle = meta.story({
+ render: () => (
+
+ Apples
+ Oranges
+ Bananas
+
+ ),
+});
+
+export const GroupMultiple = meta.story({
+ render: () => (
+
+ Dogs
+ Cats
+ Rabbits
+
+ ),
+});
+
+export const GroupWithIcons = meta.story({
+ render: () => (
+
+ }>
+ Chill
+
+ }>
+ Focus
+
+ }>
+ Party
+
+
+ ),
+});
+
+export const VerticalGroup = meta.story({
+ render: () => (
+
+ Low
+ Medium
+ High
+
+ ),
+});
diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.tsx
new file mode 100644
index 0000000000..e688b29c96
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/ToggleButton.tsx
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import clsx from 'clsx';
+import { forwardRef, Ref } from 'react';
+import { ToggleButton as AriaToggleButton } from 'react-aria-components';
+import type { ToggleButtonProps } from './types';
+import { useStyles } from '../../hooks/useStyles';
+import { ToggleButtonDefinition } from './definition';
+import styles from './ToggleButton.module.css';
+
+/** @public */
+export const ToggleButton = forwardRef(
+ (props: ToggleButtonProps, ref: Ref) => {
+ const { classNames, dataAttributes, cleanedProps } = useStyles(
+ ToggleButtonDefinition,
+ {
+ size: 'small',
+ variant: 'primary',
+ ...props,
+ },
+ );
+
+ const { children, className, iconStart, iconEnd, ...rest } = cleanedProps;
+
+ return (
+
+
+ {iconStart}
+ {children}
+ {iconEnd}
+
+
+ );
+ },
+);
+
+ToggleButton.displayName = 'ToggleButton';
diff --git a/packages/ui/src/components/ToggleButton/definition.ts b/packages/ui/src/components/ToggleButton/definition.ts
new file mode 100644
index 0000000000..78e026eed8
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/definition.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ComponentDefinition } from '../../types';
+
+/**
+ * Component definition for ToggleButton
+ * @public
+ */
+export const ToggleButtonDefinition = {
+ classNames: {
+ root: 'bui-ToggleButton',
+ content: 'bui-ToggleButtonContent',
+ },
+ dataAttributes: {
+ size: ['small', 'medium'] as const,
+ variant: ['primary', 'secondary'] as const,
+ },
+} as const satisfies ComponentDefinition;
diff --git a/packages/ui/src/components/ToggleButton/index.ts b/packages/ui/src/components/ToggleButton/index.ts
new file mode 100644
index 0000000000..2d50f072e4
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './ToggleButton';
+export * from './types';
+export { ToggleButtonDefinition } from './definition';
diff --git a/packages/ui/src/components/ToggleButton/types.ts b/packages/ui/src/components/ToggleButton/types.ts
new file mode 100644
index 0000000000..9bffb72c83
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/types.ts
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Breakpoint } from '../..';
+import type { ReactElement, ReactNode } from 'react';
+import type { ToggleButtonProps as AriaToggleButtonProps } from 'react-aria-components';
+
+/**
+ * Properties for {@link ToggleButton}
+ *
+ * @public
+ */
+export interface ToggleButtonProps extends AriaToggleButtonProps {
+ size?: 'small' | 'medium' | Partial>;
+ variant?:
+ | 'primary'
+ | 'secondary'
+ | Partial>;
+ iconStart?: ReactElement;
+ iconEnd?: ReactElement;
+ children?: ReactNode;
+}
diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css
new file mode 100644
index 0000000000..173679f64b
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@layer tokens, base, components, utilities;
+
+@layer components {
+ .bui-ToggleButtonGroup {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: nowrap;
+ border-radius: var(--bui-radius-2);
+ overflow: hidden;
+ box-shadow: inset 0 0 0 1px var(--bui-border);
+ }
+
+ .bui-ToggleButtonGroup[data-orientation='vertical'] {
+ flex-direction: column;
+ align-items: stretch;
+ width: fit-content;
+ }
+
+ .bui-ToggleButtonGroup > :global(.bui-ToggleButton) {
+ border-radius: 0;
+ margin: 0;
+ box-shadow: none;
+ border: 0;
+ }
+
+ .bui-ToggleButtonGroup :global(.bui-ToggleButton)[data-variant='secondary'] {
+ box-shadow: none;
+ }
+
+ /* Horizontal radius rules (default orientation) */
+ .bui-ToggleButtonGroup:not([data-orientation='vertical'])
+ > :global(.bui-ToggleButton) {
+ border-radius: 0;
+ }
+
+ .bui-ToggleButtonGroup:not([data-orientation='vertical'])
+ > :global(.bui-ToggleButton):first-child {
+ border-radius: var(--bui-radius-2) 0 0 var(--bui-radius-2);
+ }
+
+ .bui-ToggleButtonGroup:not([data-orientation='vertical'])
+ > :global(.bui-ToggleButton):last-child {
+ border-radius: 0 var(--bui-radius-2) var(--bui-radius-2) 0;
+ }
+
+ .bui-ToggleButtonGroup:not([data-orientation='vertical'])
+ > :global(.bui-ToggleButton):only-child {
+ border-radius: var(--bui-radius-2);
+ }
+
+ /* Horizontal dividers */
+ .bui-ToggleButtonGroup:not([data-orientation='vertical'])
+ :global(.bui-ToggleButton)
+ + :global(.bui-ToggleButton) {
+ border-left: 1px solid var(--bui-border);
+ }
+
+ /* Vertical dividers */
+ .bui-ToggleButtonGroup[data-orientation='vertical']
+ :global(.bui-ToggleButton) {
+ width: 100%;
+ }
+
+ .bui-ToggleButtonGroup[data-orientation='vertical']
+ :global(.bui-ToggleButton)
+ + :global(.bui-ToggleButton) {
+ border-top: 1px solid var(--bui-border);
+ }
+
+ /* Vertical radius rules */
+ .bui-ToggleButtonGroup[data-orientation='vertical']
+ > :global(.bui-ToggleButton) {
+ border-radius: 0;
+ }
+
+ .bui-ToggleButtonGroup[data-orientation='vertical']
+ > :global(.bui-ToggleButton):first-child {
+ border-radius: var(--bui-radius-2) var(--bui-radius-2) 0 0;
+ }
+
+ .bui-ToggleButtonGroup[data-orientation='vertical']
+ > :global(.bui-ToggleButton):last-child {
+ border-radius: 0 0 var(--bui-radius-2) var(--bui-radius-2);
+ }
+
+ .bui-ToggleButtonGroup[data-orientation='vertical']
+ > :global(.bui-ToggleButton):only-child {
+ border-radius: var(--bui-radius-2);
+ }
+
+ /* Focus ring on group surface */
+ .bui-ToggleButtonGroup:focus-visible {
+ outline: 2px solid var(--bui-ring);
+ outline-offset: 2px;
+ }
+}
diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx
new file mode 100644
index 0000000000..2294069f71
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import preview from '../../../../../.storybook/preview';
+import { ToggleButtonGroup } from './ToggleButtonGroup';
+import { ToggleButton } from '../ToggleButton/ToggleButton';
+import { Flex } from '../Flex';
+import { Text } from '../Text';
+import { useState } from 'react';
+import {
+ RiCloudLine,
+ RiStarLine,
+ RiStarFill,
+ RiArrowRightSLine,
+} from '@remixicon/react';
+
+const meta = preview.meta({
+ title: 'Backstage UI/ToggleButtonGroup',
+ component: ToggleButtonGroup,
+ argTypes: {
+ selectionMode: {
+ control: 'select',
+ options: ['single', 'multiple'],
+ },
+ orientation: {
+ control: 'select',
+ options: ['horizontal', 'vertical'],
+ },
+ },
+});
+
+export const SingleSelection = meta.story({
+ args: {
+ selectionMode: 'single',
+ defaultSelectedKeys: ['dogs'],
+ },
+ render: args => (
+
+ Dogs
+ Cats
+ Birds
+
+ ),
+});
+
+export const MultipleSelection = meta.story({
+ args: {
+ selectionMode: 'multiple',
+ defaultSelectedKeys: ['frontend'],
+ },
+ render: args => (
+
+ Frontend
+ Backend
+ Platform
+
+ ),
+});
+
+export const DisabledGroup = meta.story({
+ args: {
+ selectionMode: 'single',
+ isDisabled: true,
+ },
+ render: args => (
+
+ Cat
+ Dog
+ Bird
+
+ ),
+});
+
+export const DisallowEmptySelection = meta.story({
+ args: {
+ selectionMode: 'single',
+ disallowEmptySelection: true,
+ defaultSelectedKeys: ['one'],
+ },
+ render: args => (
+
+ One
+ Two
+ Three
+
+ ),
+});
+
+export const MixedDisabled = meta.story({
+ render: () => (
+
+ One
+
+ Two
+
+ Three
+
+ ),
+});
+
+export const Orientation = meta.story({
+ args: {
+ orientation: 'vertical',
+ },
+ render: args => (
+
+ Morning
+ Afternoon
+ Evening
+
+ ),
+});
+
+export const ControlledGroup = meta.story({
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set(['beta']));
+
+ return (
+
+ setSelectedKeys(new Set(keys))}
+ >
+ Alpha
+ Beta
+ Gamma
+
+ Selected: {[...selectedKeys].join(', ') || 'none'}
+
+ );
+ },
+});
+
+export const WithIcons = meta.story({
+ args: {
+ selectionMode: 'single',
+ },
+
+ render: () => (
+
+ } />
+ }
+ />
+ }>
+ Star
+
+ }>
+ Next
+
+
+ ),
+});
+
+export const IconsOnly = meta.story({
+ render: () => (
+
+ } />
+ } />
+ } />
+
+ ),
+});
diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx
new file mode 100644
index 0000000000..26b4eb51c8
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import clsx from 'clsx';
+import { forwardRef, Ref } from 'react';
+import { ToggleButtonGroup as AriaToggleButtonGroup } from 'react-aria-components';
+import type { ToggleButtonGroupProps } from './types';
+import { useStyles } from '../../hooks/useStyles';
+import { ToggleButtonGroupDefinition } from './definition';
+import styles from './ToggleButtonGroup.module.css';
+
+/** @public */
+export const ToggleButtonGroup = forwardRef(
+ (props: ToggleButtonGroupProps, ref: Ref) => {
+ const { classNames, dataAttributes, cleanedProps } = useStyles(
+ ToggleButtonGroupDefinition,
+ {
+ orientation: 'horizontal',
+ ...props,
+ },
+ );
+
+ const { className, children, ...rest } = cleanedProps;
+
+ return (
+
+ {children}
+
+ );
+ },
+);
+
+ToggleButtonGroup.displayName = 'ToggleButtonGroup';
diff --git a/packages/ui/src/components/ToggleButtonGroup/definition.ts b/packages/ui/src/components/ToggleButtonGroup/definition.ts
new file mode 100644
index 0000000000..7acf6828f0
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/definition.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { ComponentDefinition } from '../../types';
+
+/**
+ * Component definition for ToggleButtonGroup
+ * @public
+ */
+export const ToggleButtonGroupDefinition = {
+ classNames: {
+ root: 'bui-ToggleButtonGroup',
+ },
+ dataAttributes: {
+ orientation: ['horizontal', 'vertical'] as const,
+ },
+} as const satisfies ComponentDefinition;
diff --git a/packages/ui/src/components/ToggleButtonGroup/index.ts b/packages/ui/src/components/ToggleButtonGroup/index.ts
new file mode 100644
index 0000000000..63893ec3f4
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './ToggleButtonGroup';
+export * from './types';
+export { ToggleButtonGroupDefinition } from './definition';
diff --git a/packages/ui/src/components/ToggleButtonGroup/types.ts b/packages/ui/src/components/ToggleButtonGroup/types.ts
new file mode 100644
index 0000000000..d0de983f66
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/types.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { Breakpoint } from '../..';
+import type { ToggleButtonGroupProps as AriaToggleButtonGroupProps } from 'react-aria-components';
+
+/** @public */
+export interface ToggleButtonGroupProps
+ extends AriaToggleButtonGroupProps