diff --git a/.changeset/rude-trains-marry.md b/.changeset/rude-trains-marry.md
new file mode 100644
index 0000000000..0246ff6c9e
--- /dev/null
+++ b/.changeset/rude-trains-marry.md
@@ -0,0 +1,5 @@
+---
+'@backstage/ui': minor
+---
+
+Introduce new `ToggleButton` & `ToggleButtonGroup` components in Backstage UI
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..19034f0ac5
--- /dev/null
+++ b/docs-ui/src/content/toggle-button-group.mdx
@@ -0,0 +1,126 @@
+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,
+ toggleButtonGroupSurfacesSnippet,
+} 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
+
+### Surfaces
+
+}
+ code={toggleButtonGroupSurfacesSnippet}
+/>
+
+### 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..b8f12941de
--- /dev/null
+++ b/docs-ui/src/content/toggle-button-group.props.ts
@@ -0,0 +1,142 @@
+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
+ Birds
+`;
+
+export const toggleButtonGroupSingleSnippet = `
+ Dogs
+ Cats
+ Birds
+`;
+
+export const toggleButtonGroupMultipleSnippet = `
+ Frontend
+ Backend
+ Platform
+`;
+
+export const toggleButtonGroupVerticalSnippet = `
+ Morning
+ Afternoon
+ Evening
+`;
+
+export const toggleButtonGroupDisabledSnippet = `
+ Cat
+ Dog
+ Bird
+`;
+
+export const toggleButtonGroupDisallowEmptySnippet = `
+ One
+ Two
+ Three
+`;
+
+export const toggleButtonGroupIconsSnippet = `import { RiCloudLine, RiStarFill, RiStarLine, RiArrowRightSLine } from '@remixicon/react';
+
+
+ } />
+ }
+ />
+ }>
+ Star
+
+ }>
+ Next
+
+`;
+
+export const toggleButtonGroupIconsOnlySnippet = `import { RiCloudLine, RiStarLine, RiArrowRightSLine } from '@remixicon/react';
+
+
+ } />
+ } />
+ } />
+`;
+
+export const toggleButtonGroupSurfacesSnippet = `
+
+ Default
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ On Surface 0
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ On Surface 1
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ On Surface 2
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ On Surface 3
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+`;
diff --git a/docs-ui/src/content/toggle-button.mdx b/docs-ui/src/content/toggle-button.mdx
new file mode 100644
index 0000000000..78750ec365
--- /dev/null
+++ b/docs-ui/src/content/toggle-button.mdx
@@ -0,0 +1,106 @@
+import { PropsTable } from '@/components/PropsTable';
+import { Snippet } from '@/components/Snippet';
+import { CodeBlock } from '@/components/CodeBlock';
+import { ToggleButtonSnippet } from '@/snippets/stories-snippets';
+import {
+ toggleButtonPropDefs,
+ toggleButtonUsageSnippet,
+ toggleButtonSurfacesSnippet,
+ toggleButtonSizesSnippet,
+ toggleButtonIconsSnippet,
+ toggleButtonDisabledSnippet,
+ toggleButtonControlledSnippet,
+ toggleButtonFunctionChildrenSnippet,
+} 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={toggleButtonUsageSnippet}
+/>
+
+## Usage
+
+
+
+## API reference
+
+
+
+## Examples
+
+### Surfaces
+
+}
+ code={toggleButtonSurfacesSnippet}
+/>
+
+### Sizes
+
+}
+ code={toggleButtonSizesSnippet}
+/>
+
+### With Icons
+
+}
+ code={toggleButtonIconsSnippet}
+/>
+
+### Disabled
+
+}
+ code={toggleButtonDisabledSnippet}
+/>
+
+### Controlled
+
+}
+ code={toggleButtonControlledSnippet}
+/>
+
+### Dynamic Content with Function Children
+
+The `children` prop can be a function that receives render props, allowing you to dynamically customize the button content based on component state (such as `isSelected`, `isDisabled`, `isHovered`, etc.).
+
+}
+ code={toggleButtonFunctionChildrenSnippet}
+/>
+
+
+
+
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..9115b29603
--- /dev/null
+++ b/docs-ui/src/content/toggle-button.props.ts
@@ -0,0 +1,195 @@
+import {
+ classNamePropDefs,
+ stylePropDefs,
+ type PropDef,
+} from '@/utils/propDefs';
+
+export const toggleButtonPropDefs: Record = {
+ size: {
+ type: 'enum',
+ values: ['small', 'medium'],
+ default: 'small',
+ responsive: true,
+ },
+ onSurface: {
+ type: 'enum',
+ values: ['0', '1', '2', '3', 'danger', 'warning', 'success', 'auto'],
+ description: 'Surface level this toggle is placed on',
+ 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', '(values: ToggleButtonRenderProps) => ReactNode'],
+ description:
+ 'The children of the component. A function may be provided to alter the children based on component state (such as `isSelected`, `isDisabled`, `isHovered`, etc.).',
+ },
+ ...classNamePropDefs,
+ ...stylePropDefs,
+};
+
+export const toggleButtonUsageSnippet = `import { ToggleButton } from '@backstage/ui';
+
+Toggle`;
+
+export const toggleButtonSurfacesSnippet = `
+
+ Default
+
+ Toggle
+
+
+
+ On Surface 0
+
+ Toggle
+
+
+
+ On Surface 1
+
+ Toggle
+
+
+
+ On Surface 2
+
+ Toggle
+
+
+
+ On Surface 3
+
+ Toggle
+
+
+`;
+
+export const toggleButtonSizesSnippet = `
+ Small
+ Medium
+`;
+
+export const toggleButtonIconsSnippet = `import { RiStarLine, RiStarFill, RiCheckLine } from '@remixicon/react';
+
+
+ }>Favorite
+ } defaultSelected>Starred
+ }>Confirm
+`;
+
+export const toggleButtonDisabledSnippet = `
+ Disabled
+ Selected
+`;
+
+export const toggleButtonControlledSnippet = `import { useState } from 'react';
+import { RiStarFill, RiStarLine } from '@remixicon/react';
+
+const [selected, setSelected] = useState(false);
+
+ : }
+>
+ {selected ? 'Starred' : 'Not starred'}
+`;
+
+export const toggleButtonFunctionChildrenSnippet = `import { RiStarFill, RiStarLine } from '@remixicon/react';
+
+
+ Example 1: Selection State
+
+
+ {({ isSelected }) => (isSelected ? '✓ Selected' : 'Not Selected')}
+
+
+ {({ isSelected }) => (isSelected ? '✓ Selected' : 'Not Selected')}
+
+
+
+
+
+ Example 2: Multiple States
+
+
+ {({ isSelected, isHovered }) => {
+ const states = [];
+ if (isSelected) states.push('on');
+ else states.push('off');
+ if (isHovered) states.push('hovered');
+ return \`Email (\${states.join(', ')})\`;
+ }}
+
+
+ {({ isSelected, isHovered }) => {
+ const states = [];
+ if (isSelected) states.push('on');
+ else states.push('off');
+ if (isHovered) states.push('hovered');
+ return \`Push (\${states.join(', ')})\`;
+ }}
+
+
+
+
+
+ Example 3: Conditional Icons
+
+
+ {({ isSelected }) => (
+ <>
+ {isSelected ? : }
+ {isSelected ? 'Starred' : 'Star'}
+ >
+ )}
+
+
+
+
+
+ Example 4: Status Indicators
+
+
+ {({ isSelected }) => (
+
+
+ Active
+
+ )}
+
+
+ {({ isSelected }) => (
+
+
+ Inactive
+
+ )}
+
+
+
+`;
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/report.api.md b/packages/ui/report.api.md
index 35edc52d09..b7b6604421 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -51,6 +51,8 @@ import type { TagGroupProps as TagGroupProps_2 } from 'react-aria-components';
import type { TagListProps } from 'react-aria-components';
import type { TagProps as TagProps_2 } from 'react-aria-components';
import type { TextFieldProps as TextFieldProps_2 } from 'react-aria-components';
+import type { ToggleButtonGroupProps as ToggleButtonGroupProps_2 } from 'react-aria-components';
+import type { ToggleButtonProps as ToggleButtonProps_2 } from 'react-aria-components';
import { TooltipProps as TooltipProps_2 } from 'react-aria-components';
import { TooltipTriggerComponentProps } from 'react-aria-components';
@@ -1814,6 +1816,55 @@ export type TextVariants =
// @public (undocumented)
export type TextWeights = 'regular' | 'bold';
+// @public (undocumented)
+export const ToggleButton: ForwardRefExoticComponent<
+ ToggleButtonProps & RefAttributes
+>;
+
+// @public
+export const ToggleButtonDefinition: {
+ readonly classNames: {
+ readonly root: 'bui-ToggleButton';
+ readonly content: 'bui-ToggleButtonContent';
+ };
+ readonly dataAttributes: {
+ readonly size: readonly ['small', 'medium'];
+ };
+};
+
+// @public (undocumented)
+export const ToggleButtonGroup: ForwardRefExoticComponent<
+ ToggleButtonGroupProps & RefAttributes
+>;
+
+// @public
+export const ToggleButtonGroupDefinition: {
+ readonly classNames: {
+ readonly root: 'bui-ToggleButtonGroup';
+ };
+ readonly dataAttributes: {
+ readonly orientation: readonly ['horizontal', 'vertical'];
+ };
+};
+
+// @public (undocumented)
+export interface ToggleButtonGroupProps
+ extends Omit {
+ // (undocumented)
+ orientation?: NonNullable;
+}
+
+// @public
+export interface ToggleButtonProps extends ToggleButtonProps_2 {
+ // (undocumented)
+ iconEnd?: ReactElement;
+ // (undocumented)
+ iconStart?: ReactElement;
+ onSurface?: Responsive;
+ // (undocumented)
+ size?: 'small' | 'medium' | Partial>;
+}
+
// @public (undocumented)
export const Tooltip: ForwardRefExoticComponent<
TooltipProps & RefAttributes
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..e23c4d53eb
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/ToggleButton.module.css
@@ -0,0 +1,109 @@
+/*
+ * 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;
+
+ &[data-selected],
+ &[data-pressed] {
+ background: var(--bui-bg-solid);
+ color: var(--bui-fg-solid);
+ }
+
+ &:not([data-selected])[data-hovered] {
+ background: var(--bui-bg-surface-2);
+ }
+
+ &[data-disabled] {
+ background: var(--bui-bg-neutral-on-surface-0-disabled);
+ color: var(--bui-fg-disabled);
+ }
+
+ &[data-disabled][data-hovered] {
+ background: var(--bui-bg-neutral-on-surface-0-disabled);
+ }
+
+ &[data-disabled][data-selected] {
+ background: var(--bui-bg-solid-disabled);
+ color: var(--bui-fg-disabled);
+ }
+ }
+
+ .bui-ToggleButton[data-focus-visible] {
+ outline: none;
+ box-shadow: inset 0 0 0 2px var(--bui-ring);
+ }
+
+ .bui-ToggleButton[data-disabled] {
+ cursor: not-allowed;
+ }
+
+ .bui-ToggleButton[data-pressed] {
+ transform: scale(0.98);
+ }
+
+ .bui-ToggleButton[data-disabled][data-pressed] {
+ transform: none;
+ }
+
+ .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..14183e6ede
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx
@@ -0,0 +1,305 @@
+/*
+ * 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 { Flex } from '../Flex';
+import { Text } from '../Text';
+import { useState } from 'react';
+import {
+ RiCheckLine,
+ RiStarFill,
+ RiStarLine,
+ RiCloudLine,
+ RiArrowRightSLine,
+} from '@remixicon/react';
+
+const meta = preview.meta({
+ title: 'Backstage UI/ToggleButton',
+ component: ToggleButton,
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['small', 'medium'],
+ },
+ },
+});
+
+export const Default = meta.story({
+ args: {
+ children: 'Toggle',
+ },
+});
+
+export const Surfaces = meta.story({
+ args: {
+ children: 'Toggle',
+ },
+ parameters: {
+ argTypes: {
+ size: {
+ control: false,
+ },
+ },
+ },
+ render: () => (
+
+
+ Default
+
+ Toggle
+
+
+
+ On Surface 0
+
+ Toggle
+
+
+
+ On Surface 1
+
+ Toggle
+
+
+
+ On Surface 2
+
+ Toggle
+
+
+
+ On Surface 3
+
+ Toggle
+
+
+
+ ),
+});
+
+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 Disabled = meta.story({
+ render: () => (
+
+ Disabled
+
+ Selected
+
+
+ ),
+});
+
+export const Controlled = meta.story({
+ render: () => {
+ const [selected, setSelected] = useState(false);
+ return (
+
+ : }
+ >
+ {selected ? 'Starred' : 'Not starred'}
+
+ State: {selected ? 'selected' : 'unselected'}
+
+ );
+ },
+});
+
+export const FunctionChildren = meta.story({
+ render: () => {
+ const [disabled, setDisabled] = useState(false);
+ return (
+
+
+
+ {({ isDisabled, isSelected }) =>
+ isDisabled
+ ? `Disabled ${isSelected ? '(Selected)' : '(Unselected)'}`
+ : `Enabled ${isSelected ? '(Selected)' : '(Unselected)'}`
+ }
+
+ setDisabled(!disabled)}
+ >
+ {disabled ? 'Enable' : 'Disable'}
+
+
+
+ Toggle the button to change the disabled state and see text update
+
+
+ );
+ },
+});
+
+export const DynamicContent = meta.story({
+ render: () => {
+ return (
+
+
+ Example 1: Selection State
+
+
+ {({ isSelected }) => (isSelected ? '✓ Selected' : 'Not Selected')}
+
+
+ {({ isSelected }) => (isSelected ? '✓ Selected' : 'Not Selected')}
+
+
+
+
+
+ Example 2: Multiple States
+
+
+ {({ isSelected, isHovered }) => {
+ const states = [];
+ if (isSelected) states.push('on');
+ else states.push('off');
+ if (isHovered) states.push('hovered');
+ return `Email (${states.join(', ')})`;
+ }}
+
+
+ {({ isSelected, isHovered }) => {
+ const states = [];
+ if (isSelected) states.push('on');
+ else states.push('off');
+ if (isHovered) states.push('hovered');
+ return `Push (${states.join(', ')})`;
+ }}
+
+
+
+
+
+ Example 3: Conditional Icons
+
+
+ {({ isSelected }) => (
+ <>
+ {isSelected ? : }
+ {isSelected ? 'Starred' : 'Star'}
+ >
+ )}
+
+
+
+
+
+ Example 4: Status Indicators
+
+
+ {({ isSelected }) => (
+
+
+ Active
+
+ )}
+
+
+ {({ isSelected }) => (
+
+
+ Inactive
+
+ )}
+
+
+
+
+ );
+ },
+});
diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.tsx
new file mode 100644
index 0000000000..9c96304a80
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/ToggleButton.tsx
@@ -0,0 +1,71 @@
+/*
+ * 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';
+import { useSurface } from '../../hooks/useSurface';
+
+/** @public */
+export const ToggleButton = forwardRef(
+ (props: ToggleButtonProps, ref: Ref) => {
+ const { classNames, dataAttributes, cleanedProps } = useStyles(
+ ToggleButtonDefinition,
+ {
+ size: 'small',
+ ...props,
+ },
+ );
+
+ const { children, className, iconStart, iconEnd, onSurface, ...rest } =
+ cleanedProps;
+
+ const { surface } = useSurface({ onSurface });
+
+ return (
+
+ {renderProps => {
+ // If children is a function, call it with render props; otherwise use children as-is
+ const renderedChildren =
+ typeof children === 'function' ? children(renderProps) : children;
+
+ return (
+
+ {iconStart}
+ {renderedChildren}
+ {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..98c22fef35
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/definition.ts
@@ -0,0 +1,31 @@
+/*
+ * 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,
+ },
+} 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..de70bd3c29
--- /dev/null
+++ b/packages/ui/src/components/ToggleButton/types.ts
@@ -0,0 +1,33 @@
+/*
+ * 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 } from 'react';
+import type { ToggleButtonProps as AriaToggleButtonProps } from 'react-aria-components';
+import type { Responsive, Surface } from '../../types';
+
+/**
+ * Properties for {@link ToggleButton}
+ *
+ * @public
+ */
+export interface ToggleButtonProps extends AriaToggleButtonProps {
+ size?: 'small' | 'medium' | Partial>;
+ iconStart?: ReactElement;
+ iconEnd?: ReactElement;
+ /** Surface the toggle button is placed on. Defaults to context surface if available */
+ onSurface?: Responsive;
+}
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..90bedd1756
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css
@@ -0,0 +1,109 @@
+/*
+ * 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);
+ width: fit-content;
+ }
+
+ .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;
+ }
+
+ /* 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..30e53f0669
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx
@@ -0,0 +1,265 @@
+/*
+ * 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 type { Selection } from 'react-aria-components';
+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 Surfaces = meta.story({
+ args: {
+ selectionMode: 'single',
+ defaultSelectedKeys: ['option1'],
+ },
+ parameters: {
+ argTypes: {
+ selectionMode: {
+ control: false,
+ },
+ },
+ },
+ render: () => (
+
+
+ Default
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ On Surface 0
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ On Surface 1
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ On Surface 2
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ On Surface 3
+
+
+ Option 1
+ Option 2
+ Option 3
+
+
+
+
+ ),
+});
+
+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 (
+
+
+ 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..43449f8472
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx
@@ -0,0 +1,50 @@
+/*
+ * 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,
+ {
+ ...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..7a57708750
--- /dev/null
+++ b/packages/ui/src/components/ToggleButtonGroup/types.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 { ToggleButtonGroupProps as AriaToggleButtonGroupProps } from 'react-aria-components';
+
+/** @public */
+export interface ToggleButtonGroupProps
+ extends Omit {
+ orientation?: NonNullable;
+}
diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts
index a0dc412697..6c470e4d37 100644
--- a/packages/ui/src/definitions.ts
+++ b/packages/ui/src/definitions.ts
@@ -47,6 +47,8 @@ export { SearchFieldDefinition } from './components/SearchField/definition';
export { SelectDefinition } from './components/Select/definition';
export { SkeletonDefinition } from './components/Skeleton/definition';
export { SwitchDefinition } from './components/Switch/definition';
+export { ToggleButtonDefinition } from './components/ToggleButton/definition';
+export { ToggleButtonGroupDefinition } from './components/ToggleButtonGroup/definition';
export { TableDefinition } from './components/Table/definition';
export { TablePaginationDefinition } from './components/TablePagination/definition';
export { TabsDefinition } from './components/Tabs/definition';
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 03ae3245e3..1c8529f1e6 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -52,6 +52,8 @@ export * from './components/Link';
export * from './components/Select';
export * from './components/Skeleton';
export * from './components/Switch';
+export * from './components/ToggleButton';
+export * from './components/ToggleButtonGroup';
export * from './components/VisuallyHidden';
// Types