diff --git a/.changeset/segmentedcontrol-responsive.md b/.changeset/segmentedcontrol-responsive.md
new file mode 100644
index 00000000000..36fa3a6caf3
--- /dev/null
+++ b/.changeset/segmentedcontrol-responsive.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": patch
+---
+
+SegmentedControl: Remove useResponsiveValue hook from fullWidth and variant props to use `getResponsiveAttributes` instead.
diff --git a/packages/react/src/SegmentedControl/SegmentedControl.module.css b/packages/react/src/SegmentedControl/SegmentedControl.module.css
index 2717c72816b..77270b6d227 100644
--- a/packages/react/src/SegmentedControl/SegmentedControl.module.css
+++ b/packages/react/src/SegmentedControl/SegmentedControl.module.css
@@ -1,4 +1,7 @@
.SegmentedControl {
+ /* TODO: use primitive `control.medium.size` when it is available instead of '32px' */
+ --segmented-control-icon-width: 32px;
+
display: inline-flex;
/* TODO: use primitive `control.{small|medium}.size` when it is available */
@@ -10,9 +13,114 @@
border: var(--borderWidth-thin) solid var(--controlTrack-borderColor-rest, transparent);
border-radius: var(--borderRadius-medium);
- &:where([data-full-width]) {
+ /* Responsive full-width */
+ &[data-full-width='true'] {
display: flex;
width: 100%;
+
+ --segmented-control-icon-width: 100%;
+ }
+
+ &[data-full-width='false'] {
+ display: inline-flex;
+ width: auto;
+
+ --segmented-control-icon-width: 32px;
+ }
+
+ @media (--viewportRange-narrow) {
+ &[data-full-width-narrow='true'] {
+ display: flex;
+ width: 100%;
+
+ --segmented-control-icon-width: 100%;
+ }
+
+ &[data-full-width-narrow='false'] {
+ display: inline-flex;
+ width: auto;
+
+ --segmented-control-icon-width: 32px;
+ }
+ }
+
+ @media (--viewportRange-regular) {
+ &[data-full-width-regular='true'] {
+ display: flex;
+ width: 100%;
+
+ --segmented-control-icon-width: 100%;
+ }
+
+ &[data-full-width-regular='false'] {
+ display: inline-flex;
+ width: auto;
+
+ --segmented-control-icon-width: 32px;
+ }
+ }
+
+ @media (--viewportRange-wide) {
+ &[data-full-width-wide='true'] {
+ display: flex;
+ width: 100%;
+
+ --segmented-control-icon-width: 100%;
+ }
+
+ &[data-full-width-wide='false'] {
+ display: inline-flex;
+ width: auto;
+
+ --segmented-control-icon-width: 32px;
+ }
+
+ &[data-full-width-regular='true']:not([data-full-width-wide='true']) {
+ display: inline-flex;
+ width: auto;
+
+ --segmented-control-icon-width: 32px;
+ }
+ }
+
+ /* Hide when dropdown variant is active */
+ &[data-variant='dropdown'] {
+ display: none;
+ }
+
+ /* Handle hideLabels variant - hide button text */
+ &[data-variant='hideLabels'] .Text {
+ display: none;
+ }
+
+ @media (--viewportRange-narrow) {
+ &[data-variant-narrow='dropdown'] {
+ display: none;
+ }
+
+ &[data-variant-narrow='hideLabels'] .Text {
+ display: none;
+ }
+ }
+
+ @media (--viewportRange-regular) {
+ &[data-variant-regular='dropdown'] {
+ display: none;
+ }
+
+ &[data-variant-regular='hideLabels'] .Text {
+ display: none;
+ }
+ }
+
+ @media (--viewportRange-wide) {
+ &[data-variant-wide='dropdown'] {
+ display: none;
+ }
+
+ &[data-variant-wide='hideLabels'] .Text {
+ display: none;
+ }
}
&:where([data-size='small']) {
@@ -22,6 +130,33 @@
}
}
+.DropdownContainer {
+ display: none;
+
+ /* Show when dropdown variant is active */
+ &[data-variant='dropdown'] {
+ display: block;
+ }
+
+ @media (--viewportRange-narrow) {
+ &[data-variant-narrow='dropdown'] {
+ display: block;
+ }
+ }
+
+ @media (--viewportRange-regular) {
+ &[data-variant-regular='dropdown'] {
+ display: block;
+ }
+ }
+
+ @media (--viewportRange-wide) {
+ &[data-variant-wide='dropdown'] {
+ display: block;
+ }
+ }
+}
+
.Item {
position: relative;
display: block;
@@ -138,12 +273,7 @@
}
.IconButton {
- /* TODO: use primitive `control.medium.size` when it is available instead of '32px' */
- width: 32px;
-
- .SegmentedControl:where([data-full-width]) & {
- width: 100%;
- }
+ width: var(--segmented-control-icon-width, 32px);
}
.Content {
diff --git a/packages/react/src/SegmentedControl/SegmentedControl.responsive.stories.tsx b/packages/react/src/SegmentedControl/SegmentedControl.responsive.stories.tsx
new file mode 100644
index 00000000000..5b1099d59b4
--- /dev/null
+++ b/packages/react/src/SegmentedControl/SegmentedControl.responsive.stories.tsx
@@ -0,0 +1,148 @@
+import type {Meta, StoryFn} from '@storybook/react-vite'
+import React from 'react'
+import {SegmentedControl} from './SegmentedControl'
+import {EyeIcon, FileCodeIcon, PeopleIcon} from '@primer/octicons-react'
+
+const meta: Meta = {
+ title: 'Components/SegmentedControl/Responsive Tests',
+ parameters: {
+ layout: 'padded',
+ controls: {expanded: true},
+ },
+}
+
+export default meta
+
+/**
+ * Test responsive fullWidth behavior.
+ * Resize the viewport to see the control change width at different breakpoints.
+ */
+export const FullWidthResponsive: StoryFn = () => (
+
+
Full width: yes (narrow) → no (regular + wide)
+
+
+ Preview
+
+ Raw
+ Blame
+
+
+)
+
+FullWidthResponsive.parameters = {
+ docs: {
+ description: {
+ story:
+ 'The control fills the full width on **narrow** viewports and uses inline width on **regular** and **wide** viewports.',
+ },
+ },
+}
+
+/**
+ * Test responsive variant behavior with hideLabels.
+ * Resize the viewport to see labels hide/show at different breakpoints.
+ */
+export const VariantHideLabelsResponsive: StoryFn = () => (
+
+
Labels: hidden (narrow) → visible (regular + wide)
+
+
+ Preview
+
+ Raw
+ Blame
+
+
+)
+
+VariantHideLabelsResponsive.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Labels are **hidden** (icon-only) on narrow viewports and **visible** on regular and wide viewports. Note: leadingVisual prop is required for hideLabels variant.',
+ },
+ },
+}
+
+/**
+ * Test responsive variant behavior with dropdown.
+ * Resize the viewport to see the control switch between dropdown and buttons.
+ */
+export const VariantDropdownResponsive: StoryFn = () => (
+
+
Variant: dropdown (narrow) → buttons (regular + wide)
+
+
+ Preview
+
+ Raw
+ Blame
+
+
+)
+
+VariantDropdownResponsive.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Renders as a **dropdown menu** on narrow viewports and as **segmented buttons** on regular and wide viewports.',
+ },
+ },
+}
+
+/**
+ * Test responsive variant behavior when only the narrow breakpoint is specified.
+ */
+export const VariantDropdownNarrowOnly: StoryFn = () => (
+
+
Variant: dropdown (narrow) → buttons (others inherit default)
+
+
+ Preview
+
+ Raw
+ Blame
+
+
+)
+
+VariantDropdownNarrowOnly.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Only the **narrow** breakpoint sets the dropdown variant; wider breakpoints fall back to the default segmented buttons.',
+ },
+ },
+}
+
+/**
+ * Test complex responsive behavior combining fullWidth and variant.
+ */
+export const ComplexResponsive: StoryFn = () => (
+
+
+ Complex: full-width + icon-only (narrow) → full-width + labels (regular) → inline + labels (wide)
+
+
+
+ Preview
+
+ Raw
+ Blame
+
+
+)
+
+ComplexResponsive.parameters = {
+ docs: {
+ description: {
+ story:
+ 'Complex: **full-width + icon-only** (narrow) → **full-width + labels** (regular) → **inline + labels** (wide)',
+ },
+ },
+}
diff --git a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx
index 0c1fd59a9b1..284cc9a1906 100644
--- a/packages/react/src/SegmentedControl/SegmentedControl.test.tsx
+++ b/packages/react/src/SegmentedControl/SegmentedControl.test.tsx
@@ -64,7 +64,7 @@ describe('SegmentedControl', () => {
})
it('renders the dropdown variant', () => {
- const {getByText} = render(
+ const {getByRole} = render(
{segmentData.map(({label}, index) => (
@@ -73,14 +73,14 @@ describe('SegmentedControl', () => {
))}
,
)
- const button = getByText(segmentData[1].label)
+ const button = getByRole('button', {name: `${segmentData[1].label}, File view`})
expect(button).toBeInTheDocument()
expect(button.closest('button')?.getAttribute('aria-haspopup')).toBe('true')
})
it('renders the hideLabels variant', () => {
- const {getByLabelText} = render(
+ const {getByRole} = render(
{segmentData.map(({label, icon}, index) => (
@@ -91,8 +91,8 @@ describe('SegmentedControl', () => {
)
for (const datum of segmentData) {
- const labelledButton = getByLabelText(datum.label)
- expect(labelledButton).toBeDefined()
+ const labelledButton = getByRole('button', {name: datum.iconLabel})
+ expect(labelledButton).toBeInTheDocument()
}
})
@@ -280,7 +280,7 @@ describe('SegmentedControl', () => {
,
)
- const button = component.getByText(segmentData[0].label)
+ const button = component.getByRole('button', {name: `${segmentData[0].label}, File view`})
fireEvent.click(button)
expect(handleChange).not.toHaveBeenCalled()
@@ -303,7 +303,7 @@ describe('SegmentedControl', () => {
,
)
- const button = component.getByText(segmentData[0].label)
+ const button = component.getByRole('button', {name: `${segmentData[0].label}, File view`})
fireEvent.click(button)
expect(handleClick).not.toHaveBeenCalled()
@@ -313,23 +313,6 @@ describe('SegmentedControl', () => {
expect(handleClick).toHaveBeenCalled()
})
- it('warns users if they try to use the hideLabels variant without a leadingVisual', () => {
- const spy = vi.spyOn(globalThis.console, 'warn').mockImplementation(() => {})
-
- render(
-
- {segmentData.map(({label}, index) => (
-
- {label}
-
- ))}
- ,
- )
-
- expect(spy).toHaveBeenCalledTimes(3)
- spy.mockRestore()
- })
-
it('supports deprecated leadingIcon prop for backward compatibility', () => {
const {getByText} = render(
diff --git a/packages/react/src/SegmentedControl/SegmentedControl.tsx b/packages/react/src/SegmentedControl/SegmentedControl.tsx
index 76d25fbb64c..fe2a9b5a81b 100644
--- a/packages/react/src/SegmentedControl/SegmentedControl.tsx
+++ b/packages/react/src/SegmentedControl/SegmentedControl.tsx
@@ -6,7 +6,7 @@ import SegmentedControlIconButton from './SegmentedControlIconButton'
import {ActionList} from '../ActionList'
import {ActionMenu} from '../ActionMenu'
import type {ResponsiveValue} from '../hooks/useResponsiveValue'
-import {useResponsiveValue} from '../hooks/useResponsiveValue'
+import {getResponsiveAttributes} from '../internal/utils/getResponsiveAttributes'
import type {WidthOnlyViewportRangeKeys} from '../utils/types/ViewportRangeKeys'
import {isElement} from 'react-is'
import classes from './SegmentedControl.module.css'
@@ -45,8 +45,7 @@ const Root: React.FC> = ({
React.Children.toArray(children).some(
child => React.isValidElement(child) && child.props.defaultSelected !== undefined,
)
- const responsiveVariant = useResponsiveValue(variant, 'default')
- const isFullWidth = useResponsiveValue(fullWidth, false)
+
const selectedSegments = React.Children.toArray(children).map(
child =>
React.isValidElement(child) &&
@@ -110,9 +109,13 @@ const Root: React.FC> = ({
)
}
- return responsiveVariant === 'dropdown' ? (
- // Render the 'dropdown' variant of the SegmentedControlButton or SegmentedControlIconButton
- <>
+ // Check if dropdown variant is used at any breakpoint
+ const responsiveVariant = typeof variant === 'object' ? variant : undefined
+ const hasDropdownVariant = responsiveVariant ? Object.values(responsiveVariant).includes('dropdown') : false
+
+ // Render dropdown variant if needed
+ const dropdownContent = hasDropdownVariant && (
+
{/*
The aria-label is only provided as a backup when the designer or engineer neglects to show a label for the SegmentedControl.
@@ -150,15 +153,18 @@ const Root: React.FC> = ({
- >
- ) : (
- // Render a segmented control
+
+ )
+
+ // Render segmented control (default or hideLabels variant)
+ const segmentedControlContent = (
@@ -186,43 +192,21 @@ const Root: React.FC> = ({
},
}
- // Render the 'hideLabels' variant of the SegmentedControlButton
- if (
- responsiveVariant === 'hideLabels' &&
- React.isValidElement(child) &&
- (child.type === Button || isSlot(child, Button))
- ) {
- const {
- 'aria-label': childAriaLabel,
- leadingVisual,
- leadingIcon,
- children: childPropsChildren,
- ...restChildProps
- } = child.props
- // Use leadingVisual if provided, otherwise fall back to leadingIcon
- const visual = leadingVisual ?? leadingIcon
- if (!visual) {
- // eslint-disable-next-line no-console
- console.warn('A `leadingVisual` or `leadingIcon` prop is required when hiding visible labels')
- } else {
- return (
-
- )
- }
- }
-
// Render the children as-is and add the shared child props
return React.cloneElement(child, sharedChildProps)
})}
)
+
+ // Return both variants when dropdown is used, otherwise just the segmented control
+ return hasDropdownVariant ? (
+ <>
+ {dropdownContent}
+ {segmentedControlContent}
+ >
+ ) : (
+ segmentedControlContent
+ )
}
Root.displayName = 'SegmentedControl'