diff --git a/.changeset/toggle-button-selected-icon.md b/.changeset/toggle-button-selected-icon.md
new file mode 100644
index 000000000..82179d6e5
--- /dev/null
+++ b/.changeset/toggle-button-selected-icon.md
@@ -0,0 +1,5 @@
+---
+'@launchpad-ui/components': minor
+---
+
+Add `icon` and `selectedIcon` props to `ToggleButton`. A selected button now shows `check-circle` by default, and selecting a button with a leading `icon` swaps that icon for the selected one rather than showing both. Pass `selectedIcon={null}` to opt out. `appearance="elevated"` shows no selected icon, since its raised surface already signals selection.
diff --git a/packages/components/__tests__/ToggleButton.spec.tsx b/packages/components/__tests__/ToggleButton.spec.tsx
index fd22f55ba..a8f485594 100644
--- a/packages/components/__tests__/ToggleButton.spec.tsx
+++ b/packages/components/__tests__/ToggleButton.spec.tsx
@@ -19,4 +19,71 @@ describe('ToggleButton', () => {
render(toggle);
expect(screen.getByRole('button')).toHaveAttribute('data-lp-variant', 'default');
});
+
+ describe('selected icon', () => {
+ it('renders a check when selected', () => {
+ render(toggle);
+ expect(screen.getByRole('button').querySelector('[data-icon="check-circle"]')).toBeInTheDocument();
+ });
+
+ it('renders no icon when not selected', () => {
+ render(toggle);
+ expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument();
+ });
+
+ it('renders a leading icon while not selected', () => {
+ render(toggle);
+
+ const button = screen.getByRole('button');
+ expect(button.querySelector('[data-icon="flask"]')).toBeInTheDocument();
+ expect(button.querySelector('[data-icon="check-circle"]')).not.toBeInTheDocument();
+ });
+
+ it('swaps the leading icon for the check when selected', () => {
+ render(
+
+ toggle
+ ,
+ );
+
+ const button = screen.getByRole('button');
+ expect(button.querySelector('[data-icon="check-circle"]')).toBeInTheDocument();
+ expect(button.querySelector('[data-icon="flask"]')).not.toBeInTheDocument();
+ });
+
+ it('supports a custom selected icon', () => {
+ render(
+
+ toggle
+ ,
+ );
+ expect(screen.getByRole('button').querySelector('[data-icon="flag"]')).toBeInTheDocument();
+ });
+
+ it('renders no icon when the selected icon is null', () => {
+ render(
+
+ toggle
+ ,
+ );
+ expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument();
+ });
+
+ it('renders no icon when selected and elevated', () => {
+ render(
+
+ toggle
+ ,
+ );
+ expect(screen.getByRole('button').querySelector('[data-icon]')).not.toBeInTheDocument();
+ });
+
+ it('hides the icon from assistive technology', () => {
+ render(toggle);
+ expect(screen.getByRole('button').querySelector('[data-icon="check-circle"]')).toHaveAttribute(
+ 'aria-hidden',
+ 'true',
+ );
+ });
+ });
});
diff --git a/packages/components/src/ToggleButton.tsx b/packages/components/src/ToggleButton.tsx
index 90bb14026..19e5144fc 100644
--- a/packages/components/src/ToggleButton.tsx
+++ b/packages/components/src/ToggleButton.tsx
@@ -6,6 +6,9 @@ import type { ToggleButtonProps as AriaToggleButtonProps } from 'react-aria-comp
import { ToggleButton as AriaToggleButton } from 'react-aria-components/ToggleButton';
import { cva } from 'class-variance-authority';
+import type { IconProps } from '@launchpad-ui/icons';
+import { Icon } from '@launchpad-ui/icons';
+
import type { ButtonVariants } from './Button';
import { buttonStyles } from './Button';
import { useLPContextProps } from './utils';
@@ -17,6 +20,14 @@ const toggleButtonElevatedStyles = cva(elevatedStyles.elevated);
interface ToggleButtonProps extends AriaToggleButtonProps, ButtonVariants {
/** Visual appearance of the toggle button. Use `"elevated"` inside a `ToggleButtonGroup` with `appearance="elevated"`. */
appearance?: 'default' | 'elevated';
+ /** Leading icon shown while the button is not selected. Replaced by `selectedIcon` on selection. */
+ icon?: IconProps['name'];
+ /**
+ * Leading icon shown while the button is selected. Defaults to `"check-circle"`, and to `null` when
+ * `appearance="elevated"` — the elevated appearance already signals selection through its raised surface.
+ * Pass `null` to render no icon when selected.
+ */
+ selectedIcon?: IconProps['name'] | null;
ref?: Ref;
}
@@ -25,11 +36,20 @@ const ToggleButtonContext = createContext {
const [mergedProps, mergedRef] = useLPContextProps(props, ref, ToggleButtonContext);
- const { appearance = 'default', size = 'medium', variant = 'default' } = mergedProps;
+ const {
+ appearance = 'default',
+ size = 'medium',
+ variant = 'default',
+ icon,
+ selectedIcon = appearance === 'elevated' ? null : 'check-circle',
+ } = mergedProps;
return (
{
? toggleButtonElevatedStyles({ ...renderProps, className })
: buttonStyles({ ...renderProps, size, variant, className }),
)}
- />
+ >
+ {composeRenderProps(mergedProps.children, (children, { isSelected }) => {
+ const name = isSelected ? selectedIcon : icon;
+
+ return (
+ <>
+ {name ? : null}
+ {children}
+ >
+ );
+ })}
+
);
};
diff --git a/packages/components/stories/ToggleButtonGroup.stories.tsx b/packages/components/stories/ToggleButtonGroup.stories.tsx
index 35328e565..5ec127483 100644
--- a/packages/components/stories/ToggleButtonGroup.stories.tsx
+++ b/packages/components/stories/ToggleButtonGroup.stories.tsx
@@ -1,5 +1,6 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
+import { userEvent, within } from 'storybook/test';
import { ToggleButton } from '../src/ToggleButton';
import { ToggleButtonGroup } from '../src/ToggleButtonGroup';
@@ -74,3 +75,61 @@ export const Icons: Story = {
orientation: 'vertical',
},
};
+
+export const SelectedIconSwap: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'A selected button shows `check-circle`. When a button also has a leading `icon`, selecting it swaps that icon for the selected one instead of showing both. Click through the options to see the swap.',
+ },
+ },
+ },
+ args: {
+ children: (
+ <>
+
+ First
+
+
+ Second
+
+
+ Third
+
+ >
+ ),
+ defaultSelectedKeys: ['first'],
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+
+ await userEvent.click(canvas.getByRole('radio', { name: 'Second' }));
+ },
+};
+
+export const WithoutSelectedIcon: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story: 'Pass `selectedIcon={null}` to keep a selected button icon-free.',
+ },
+ },
+ },
+ args: {
+ children: (
+ <>
+
+ First
+
+
+ Second
+
+
+ Third
+
+ >
+ ),
+ defaultSelectedKeys: ['first'],
+ },
+};
diff --git a/packages/components/stories/ToggleButtonGroupElevated.stories.tsx b/packages/components/stories/ToggleButtonGroupElevated.stories.tsx
index ed421793f..0f9b3327d 100644
--- a/packages/components/stories/ToggleButtonGroupElevated.stories.tsx
+++ b/packages/components/stories/ToggleButtonGroupElevated.stories.tsx
@@ -11,6 +11,14 @@ const meta: Meta = {
subcomponents: { ToggleButton } as Record>,
title: 'Components/Buttons/ToggleButton/ToggleButtonGroupElevated',
tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Unlike the default appearance, an elevated toggle button shows no icon when selected — the raised surface already makes the selection legible. Pass `selectedIcon` explicitly to opt back in.',
+ },
+ },
+ },
};
export default meta;