diff --git a/.changeset/famous-moles-bow.md b/.changeset/famous-moles-bow.md
new file mode 100644
index 0000000000..32e339ee79
--- /dev/null
+++ b/.changeset/famous-moles-bow.md
@@ -0,0 +1,5 @@
+---
+"@primer/css": patch
+---
+
+Add `SegmentedControl` component
diff --git a/docs/src/stories/components/SegmentedControl/SegmentedControl.stories.jsx b/docs/src/stories/components/SegmentedControl/SegmentedControl.stories.jsx
new file mode 100644
index 0000000000..969f6b7284
--- /dev/null
+++ b/docs/src/stories/components/SegmentedControl/SegmentedControl.stories.jsx
@@ -0,0 +1,92 @@
+import React from 'react'
+import {SegmentedControlButtonTemplate} from './SegmentedControlButton.stories' // import stories for component compositions
+
+export default {
+ title: 'Components/SegmentedControl',
+ parameters: {
+ layout: 'padded'
+ },
+ excludeStories: ['BasicTemplate', 'IconsAndTextTemplate', 'IconsOnlyTemplate'],
+ controls: { expanded: true },
+ argTypes: {
+ ariaLabel: {
+ type: 'string',
+ description: 'Aria label',
+ },
+ fullWidth: {
+ control: {type: 'boolean'},
+ description: 'full width',
+ },
+ iconOnlyWhenNarrow: {
+ control: {type: 'boolean'},
+ description: 'icon only when narrow',
+ },
+ }
+}
+
+function classNames(fullWidth, iconOnlyWhenNarrow) {
+ const classNames = ['SegmentedControl'];
+
+ if (fullWidth) {
+ classNames.push("SegmentedControl--fullWidth")
+ }
+ if (iconOnlyWhenNarrow) {
+ classNames.push("SegmentedControl--iconOnly-whenNarrow")
+ }
+
+ return classNames.join(' ')
+}
+
+export const BasicTemplate = ({fullWidth, ariaLabel}) => (
+ <>
+
+
+
+
+
+
+ >
+)
+
+export const Basic = BasicTemplate.bind({})
+Basic.args = {
+ ariaLabel: "Label",
+ fullWidth: false,
+ iconOnlyWhenNarrow: false,
+}
+
+export const IconsAndTextTemplate = ({fullWidth, ariaLabel, iconOnlyWhenNarrow}) => (
+ <>
+
+
+
+
+
+
+ >
+)
+
+export const IconsAndText = IconsAndTextTemplate.bind({})
+IconsAndText.args = {
+ ariaLabel: "Label",
+ fullWidth: false,
+ iconOnlyWhenNarrow: false,
+}
+
+export const IconsOnlyTemplate = ({fullWidth, ariaLabel, iconOnlyWhenNarrow}) => (
+ <>
+
+
+
+
+
+
+ >
+)
+
+export const IconsOnly = IconsOnlyTemplate.bind({})
+IconsOnly.args = {
+ ariaLabel: "Label",
+ fullWidth: false,
+ iconOnlyWhenNarrow: false,
+}
diff --git a/docs/src/stories/components/SegmentedControl/SegmentedControlButton.stories.jsx b/docs/src/stories/components/SegmentedControl/SegmentedControlButton.stories.jsx
new file mode 100644
index 0000000000..1959b0bc38
--- /dev/null
+++ b/docs/src/stories/components/SegmentedControl/SegmentedControlButton.stories.jsx
@@ -0,0 +1,62 @@
+import React from 'react'
+import clsx from 'clsx'
+
+export default {
+ title: 'Components/SegmentedControl/SegmentedControlButton',
+ excludeStories: ['SegmentedControlButtonTemplate'],
+ layout: 'padded',
+
+ argTypes: {
+ selected: {
+ control: {type: 'boolean'},
+ description: 'Currently selected item',
+ },
+ text: {
+ defaultValue: 'Item',
+ type: 'string',
+ name: 'text',
+ description: 'Button text',
+ },
+ leadingVisual: {
+ defaultValue: false,
+ control: {type: 'boolean'},
+ description: 'Has icon'
+ },
+ iconOnly: {
+ defaultValue: false,
+ control: {type: 'boolean'},
+ description: 'Show icon only',
+ },
+ }
+}
+
+// build every component case here in the template (private api)
+export const SegmentedControlButtonTemplate = ({selected, text, leadingVisual, iconOnly }) => (
+ <>
+
+ >
+)
+
+// create a "playground" demo page that may set some defaults and allow story to access component controls
+export const Playground = SegmentedControlButtonTemplate.bind({})
+Playground.args = {
+ text: 'Preview',
+ leadingVisual: true,
+ selected: true,
+}
diff --git a/package.json b/package.json
index 3a1b435cd7..a3840a459f 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
"storybook": "cd docs && yarn && yarn storybook"
},
"dependencies": {
- "@primer/primitives": "^7.8.4"
+ "@primer/primitives": "^7.9.0"
},
"devDependencies": {
"@changesets/changelog-github": "0.4.5",
diff --git a/src/core/index.scss b/src/core/index.scss
index bc704ee041..dfba3821a7 100644
--- a/src/core/index.scss
+++ b/src/core/index.scss
@@ -22,6 +22,7 @@
@import '../links/index.scss';
@import '../navigation/index.scss';
@import '../pagination/index.scss';
+@import '../segmented-control/index.scss';
@import '../tooltips/index.scss';
@import '../truncate/index.scss';
@import '../overlay/index.scss';
diff --git a/src/segmented-control/README.md b/src/segmented-control/README.md
new file mode 100644
index 0000000000..059a139a3b
--- /dev/null
+++ b/src/segmented-control/README.md
@@ -0,0 +1,25 @@
+---
+bundle: "segmented-control"
+generated: true
+---
+
+# Primer CSS: `segmented-control` bundle
+
+## Usage
+
+Primer CSS source files are written in [SCSS]. To include this Primer CSS module in your own build, ensure that your `node_modules` directory is listed in your Sass include paths, then import it with:
+
+```scss
+@import "@primer/css/segmented-control/index.scss";
+```
+
+## Build
+
+The `@primer/css` npm package includes a standalone CSS build of this module in `dist/segmented-control.css`.
+
+## License
+
+[MIT](https://github.com/primer/css/blob/main/LICENSE) © [GitHub](https://github.com/)
+
+
+[scss]: https://sass-lang.com/documentation/syntax#scss
diff --git a/src/segmented-control/index.scss b/src/segmented-control/index.scss
new file mode 100644
index 0000000000..af2edf1547
--- /dev/null
+++ b/src/segmented-control/index.scss
@@ -0,0 +1,3 @@
+// support files
+@import '../support/index.scss';
+@import './segmented-control.scss';
diff --git a/src/segmented-control/segmented-control.scss b/src/segmented-control/segmented-control.scss
new file mode 100644
index 0000000000..811bf9ef24
--- /dev/null
+++ b/src/segmented-control/segmented-control.scss
@@ -0,0 +1,159 @@
+// SegmentedControl
+
+.SegmentedControl {
+ display: inline-flex;
+ background-color: var(--color-segmented-control-bg);
+ // stylelint-disable-next-line primer/borders
+ border-radius: var(--primer-borderRadius-medium, $border-radius);
+ // stylelint-disable-next-line primer/box-shadow
+ box-shadow: var(--primer-borderInset-thin, inset 0 0 0 $border-width) var(--color-border-default);
+}
+
+// Button -----------------------------------------
+
+.SegmentedControl-button {
+ position: relative;
+ display: inline-flex;
+ height: var(--primer-control-medium-size, 32px);
+ // stylelint-disable-next-line primer/spacing
+ padding: calc(var(--primer-control-xsmall-paddingInline-condensed, 4px) - var(--primer-borderWidth-thin, 1px));
+ // stylelint-disable-next-line primer/typography
+ font-size: var(--primer-text-body-size-medium, $body-font-size);
+ color: var(--color-fg-default);
+ background-color: transparent;
+ // stylelint-disable-next-line primer/borders
+ border: var(--primer-borderWidth-thin, $border-width) $border-style transparent;
+ // stylelint-disable-next-line primer/borders
+ border-radius: var(--primer-borderRadius-medium, $border-radius);
+
+ &:not(.SegmentedControl-button--selected):hover .SegmentedControl-content {
+ background-color: var(--color-segmented-control-button-hover-bg);
+ transition-duration: var(--primer-duration-fast, 80ms);
+ }
+
+ &:not(.SegmentedControl-button--selected):active .SegmentedControl-content {
+ background-color: var(--color-segmented-control-button-active-bg);
+ transition-duration: 0;
+ }
+
+ // Selected
+
+ &.SegmentedControl-button--selected {
+ // stylelint-disable-next-line primer/typography
+ font-weight: var(--base-text-weight-semibold, $font-weight-bold);
+ background-color: var(--color-btn-bg);
+ border-color: var(--color-segmented-control-button-selected-border);
+ }
+
+ // Divider
+
+ // stylelint-disable-next-line scss/selector-no-redundant-nesting-selector
+ & + .SegmentedControl-button::before {
+ position: absolute;
+ inset: var(--primer-borderWidth-thin, 1px) 0 0 calc(var(--primer-borderWidth-thin, 1px) * -1);
+ height: var(--primer-text-body-size-large, 16px);
+ // stylelint-disable-next-line primer/spacing
+ margin-top: var(--primer-control-medium-paddingBlock, 6px);
+ content: '';
+ // stylelint-disable-next-line primer/borders
+ border-left: var(--primer-borderWidth-thin, $border-width) $border-style var(--color-border-default);
+ transition: border-color var(--primer-duration-medium, 160ms) cubic-bezier(0.3, 0.1, 0.5, 1);
+ }
+
+ &.SegmentedControl-button--selected::before,
+ &.SegmentedControl-button--selected + .SegmentedControl-button::before {
+ border-color: transparent;
+ }
+}
+
+// Content -----------------------------------------
+
+.SegmentedControl-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--primer-control-medium-gap, $spacer-2);
+ height: 100%;
+ // stylelint-disable-next-line primer/spacing
+ padding: 0 var(--primer-control-medium-paddingInline-condensed, 8px);
+ // stylelint-disable-next-line primer/borders
+ border-radius: var(--primer-borderRadius-medium, $border-radius);
+ transition: background-color var(--primer-duration-medium, 160ms) cubic-bezier(0.3, 0.1, 0.5, 1);
+}
+
+// Leading visual -----------------------------------------
+
+.SegmentedControl-leadingVisual {
+ color: var(--color-fg-muted);
+}
+
+// Text -----------------------------------------
+
+.SegmentedControl-text {
+ // renders a visibly hidden "copy" of the text in bold, reserving box space for when text becomes bold on selected
+ &[data-content]::before {
+ display: block;
+ height: 0;
+ // stylelint-disable-next-line primer/typography
+ font-weight: var(--base-text-weight-semibold, $font-weight-bold);
+ visibility: hidden;
+ content: attr(data-content);
+ }
+}
+
+// Variants -----------------------------------------
+
+// fullWidth
+.SegmentedControl--fullWidth {
+ display: flex;
+
+ .SegmentedControl-button {
+ flex: 1;
+ justify-content: center;
+ }
+}
+
+// Icon only
+.SegmentedControl-button--iconOnly {
+ width: var(--primer-control-medium-size, 32px);
+
+ .SegmentedControl-content {
+ padding: 0;
+ flex: 1;
+ }
+}
+
+// Icon only when narrow
+@media (max-width: $width-md) {
+ .SegmentedControl--iconOnly-whenNarrow {
+ .SegmentedControl-button {
+ width: var(--primer-control-medium-size, 32px);
+ }
+
+ .SegmentedControl-content {
+ padding: 0;
+ flex: 1;
+ }
+
+ .SegmentedControl-text {
+ display: none;
+ }
+ }
+}
+
+// Increase touch target
+@media (pointer: coarse) {
+ .SegmentedControl-button {
+ min-width: var(--primer-control-minTarget-coarse, 44px);
+
+ &::after {
+ @include minTouchTarget($min-height: var(--primer-control-minTarget-coarse, 44px));
+ }
+ }
+
+ // reset for icon-only buttons
+ .SegmentedControl-button--iconOnly,
+ .SegmentedControl--iconOnly-whenNarrow .SegmentedControl-button {
+ min-width: unset;
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index decc893307..df5f8a40cc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1091,7 +1091,7 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
-"@primer/primitives@^7.8.4":
+"@primer/primitives@^7.9.0":
version "7.9.0"
resolved "https://registry.yarnpkg.com/@primer/primitives/-/primitives-7.9.0.tgz#c8a27287488c8308b1715a7d73214629c331544a"
integrity sha512-ZHHfwB0z0z6nDJp263gyGIClYDy+rl0nwqyi4qhcv3Cxhkmtf+If2KVjr6FQqBBFfi1wQwUzaax2FBvfEMFBnw==