diff --git a/.changeset/navlist-heading-slot.md b/.changeset/navlist-heading-slot.md
new file mode 100644
index 00000000000..4e2cb3e7558
--- /dev/null
+++ b/.changeset/navlist-heading-slot.md
@@ -0,0 +1,9 @@
+---
+'@primer/react': minor
+---
+
+Add a `NavList.Heading` slot that names the navigation region. It renders an `h2`
+by default (configurable to `h3` via `as`), supports a `visuallyHidden` variant,
+labels the `nav` landmark via `aria-labelledby`, and makes `NavList.Group`
+headings default to one level deeper (`h3`, or `h4` under an `h3` heading) for a
+correct heading hierarchy.
diff --git a/packages/react/src/NavList/NavList.docs.json b/packages/react/src/NavList/NavList.docs.json
index f44605ffe60..6ea9ab1e73f 100644
--- a/packages/react/src/NavList/NavList.docs.json
+++ b/packages/react/src/NavList/NavList.docs.json
@@ -48,6 +48,46 @@
}
],
"subcomponents": [
+ {
+ "name": "NavList.Heading",
+ "props": [
+ {
+ "name": "as",
+ "type": "'h2' | 'h3'",
+ "defaultValue": "\"h2\"",
+ "required": false,
+ "description": "Semantic heading level for the NavList. Also sets the default level for child group headings, which render one level deeper (h3 under an h2, h4 under an h3). Constrained to h2/h3 so the hierarchy stays shallow."
+ },
+ {
+ "name": "visuallyHidden",
+ "type": "boolean",
+ "defaultValue": "false",
+ "required": false,
+ "description": "Visually hide the heading while keeping it available to assistive technology and as the accessible name for the `nav` landmark."
+ },
+ {
+ "name": "children",
+ "type": "ReactNode",
+ "required": true,
+ "description": "The text rendered as the NavList's heading."
+ },
+ {
+ "name": "id",
+ "type": "string",
+ "description": "Custom id for the heading element. When set, it is used as the `aria-labelledby` target that names the `nav` landmark; otherwise a generated id is used."
+ },
+ {
+ "name": "className",
+ "type": "string",
+ "description": "Custom CSS class name."
+ },
+ {
+ "name": "style",
+ "type": "React.CSSProperties",
+ "description": "Custom CSS styles."
+ }
+ ]
+ },
{
"name": "NavList.Item",
"props": [
@@ -194,10 +234,9 @@
},
{
"name": "as",
- "description": "Heading level for the group heading. Sets the semantic heading tag.",
+ "description": "Heading level for the group heading. Sets the semantic heading tag. Defaults to one level below a `NavList.Heading` (h3 under an h2, h4 under an h3), or `h3` when there is no `NavList.Heading`.",
"required": false,
- "type": "'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'",
- "defaultValue": "\"h3\""
+ "type": "'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'"
}
]
},
diff --git a/packages/react/src/NavList/NavList.features.stories.tsx b/packages/react/src/NavList/NavList.features.stories.tsx
index e575ffe71f6..fb3983f64c0 100644
--- a/packages/react/src/NavList/NavList.features.stories.tsx
+++ b/packages/react/src/NavList/NavList.features.stories.tsx
@@ -646,4 +646,48 @@ export const WithItemGap: StoryFn = () => (
WithItemGap.storyName = 'With gap between items (behind feature flag)'
+export const WithHeading: StoryFn = () => (
+
+
+
+ Settings
+
+
+ Profile
+
+ Appearance
+
+
+ Password and authentication
+ Sessions
+
+
+
+
+
+)
+
+export const WithVisuallyHiddenHeading: StoryFn = () => (
+
+
+
+ Settings
+
+
+ Profile
+
+ Appearance
+
+
+ Password and authentication
+ Sessions
+
+
+
+
+
+)
+
+WithVisuallyHiddenHeading.storyName = 'With Heading (hidden)'
+
export default meta
diff --git a/packages/react/src/NavList/NavList.module.css b/packages/react/src/NavList/NavList.module.css
index 9908361e112..93dbb6674eb 100644
--- a/packages/react/src/NavList/NavList.module.css
+++ b/packages/react/src/NavList/NavList.module.css
@@ -1,3 +1,9 @@
+.Heading {
+ margin-block-end: var(--base-size-8);
+ /* stylelint-disable-next-line primer/spacing */
+ margin-inline-start: calc(var(--control-medium-paddingInline-condensed) + var(--base-size-8));
+}
+
.GroupHeading > a {
color: var(--fgColor-default);
text-decoration: inherit;
diff --git a/packages/react/src/NavList/NavList.test.tsx b/packages/react/src/NavList/NavList.test.tsx
index b70f1314d4b..cedde128ac5 100644
--- a/packages/react/src/NavList/NavList.test.tsx
+++ b/packages/react/src/NavList/NavList.test.tsx
@@ -619,4 +619,137 @@ describe('NavList.ShowMoreItem with pages', () => {
expect(getComputedStyle(subGroup as HTMLElement).marginBlockStart).toBe('0px')
})
})
+
+ describe('NavList.Heading', () => {
+ it('renders an h2 heading by default', () => {
+ const {getByRole} = render(
+
+ Settings
+ Item 1
+ ,
+ )
+
+ expect(getByRole('heading', {level: 2, name: 'Settings'})).toBeInTheDocument()
+ })
+
+ it('respects an explicit heading level via the `as` prop', () => {
+ const {getByRole} = render(
+
+ Settings
+ Item 1
+ ,
+ )
+
+ expect(getByRole('heading', {level: 3, name: 'Settings'})).toBeInTheDocument()
+ })
+
+ it('labels the nav landmark with the heading', () => {
+ const {getByRole} = render(
+
+ Settings
+ Item 1
+ ,
+ )
+
+ const nav = getByRole('navigation', {name: 'Settings'})
+ const heading = getByRole('heading', {name: 'Settings'})
+ expect(nav).toHaveAttribute('aria-labelledby', heading.id)
+ })
+
+ it('does not override a consumer-supplied aria-label', () => {
+ const {getByRole} = render(
+
+ Settings
+ Item 1
+ ,
+ )
+
+ const nav = getByRole('navigation', {name: 'Custom label'})
+ expect(nav).not.toHaveAttribute('aria-labelledby')
+ })
+
+ it('defaults group headings to one level below the NavList.Heading', () => {
+ const {getByRole} = render(
+
+ Settings
+
+ Profile
+
+ ,
+ )
+
+ expect(getByRole('heading', {level: 2, name: 'Settings'})).toBeInTheDocument()
+ expect(getByRole('heading', {level: 3, name: 'Account'})).toBeInTheDocument()
+ })
+
+ it('derives group headings to h4 when the NavList.Heading is an h3', () => {
+ const {getByRole} = render(
+
+ Settings
+
+ Profile
+
+ ,
+ )
+
+ expect(getByRole('heading', {level: 3, name: 'Settings'})).toBeInTheDocument()
+ expect(getByRole('heading', {level: 4, name: 'Account'})).toBeInTheDocument()
+ })
+
+ it('keeps the h3 group heading default when there is no NavList.Heading', () => {
+ const {getByRole} = render(
+
+
+ Profile
+
+ ,
+ )
+
+ expect(getByRole('heading', {level: 3, name: 'Account'})).toBeInTheDocument()
+ })
+
+ it('lets group headings override their computed level with `as`', () => {
+ const {getByRole} = render(
+
+ Settings
+
+ Account
+ Profile
+
+ ,
+ )
+
+ expect(getByRole('heading', {level: 4, name: 'Account'})).toBeInTheDocument()
+ })
+
+ it('keeps a visually hidden heading in the accessibility tree', () => {
+ const {getByRole} = render(
+
+ Settings
+ Item 1
+ ,
+ )
+
+ const heading = getByRole('heading', {level: 2, name: 'Settings'})
+ expect(heading).toBeInTheDocument()
+ expect(getByRole('navigation', {name: 'Settings'})).toBeInTheDocument()
+ // The visually-hidden styles are applied to the heading itself, not a wrapping element.
+ expect(heading.parentElement?.tagName).not.toBe('SPAN')
+ })
+
+ it('forwards standard HTML attributes to the heading element', () => {
+ const {getByRole} = render(
+
+
+ Settings
+
+ Item 1
+ ,
+ )
+
+ const heading = getByRole('heading', {level: 2, name: 'Settings'})
+ expect(heading).toHaveAttribute('data-testid', 'nav-heading')
+ expect(heading).toHaveAttribute('title', 'Section navigation')
+ })
+ })
})
diff --git a/packages/react/src/NavList/NavList.tsx b/packages/react/src/NavList/NavList.tsx
index 6af6985976b..3deb4662531 100644
--- a/packages/react/src/NavList/NavList.tsx
+++ b/packages/react/src/NavList/NavList.tsx
@@ -19,6 +19,31 @@ import navListClasses from './NavList.module.css'
import {flushSync} from 'react-dom'
import {useSlots} from '../hooks/useSlots'
import {fixedForwardRef, type PolymorphicProps} from '../utils/modern-polymorphic'
+import HeadingComponent from '../Heading'
+import visuallyHiddenClasses from '../_VisuallyHidden.module.css'
+import type {FCWithSlotMarker} from '../utils/types/Slots'
+
+type HeadingLevels = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
+
+// NavList establishes a shallow hierarchy: the NavList itself is named by a
+// heading (h2 or h3) and its groups render one level deeper (h3 or h4). We don't
+// expose an h1 (that's the page title) and we don't go deeper than h4.
+type NavListHeadingLevels = 'h2' | 'h3'
+
+// Publishes the resolved numeric level of a NavList.Heading (e.g. 2 for an h2) so
+// NavList.Group/NavList.GroupHeading can default to one level deeper. `null` when
+// there is no NavList.Heading, in which case groups keep their historical h3 default.
+const NavListHeadingLevelContext = React.createContext(null)
+
+function headingTagToLevel(as: NavListHeadingLevels): number {
+ return Number.parseInt(as.slice(1), 10)
+}
+
+function levelToHeadingTag(level: number): HeadingLevels {
+ // Clamp to h4 so group headings never go deeper than the supported hierarchy.
+ const clamped = Math.min(Math.max(level, 1), 4)
+ return `h${clamped}` as HeadingLevels
+}
// ----------------------------------------------------------------------------
// NavList
@@ -27,21 +52,77 @@ export type NavListProps = {
children: React.ReactNode
} & React.ComponentProps<'nav'>
-const Root = React.forwardRef(({children, ...props}, ref) => {
+const Root = React.forwardRef(
+ ({children, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...props}, ref) => {
+ const [slots, childrenWithoutHeading] = useSlots(children, {
+ heading: Heading,
+ })
+
+ const fallbackHeadingId = useId()
+ const heading = slots.heading
+ const headingId = heading ? (heading.props.id ?? fallbackHeadingId) : undefined
+ const headingLevel = heading ? headingTagToLevel(heading.props.as ?? 'h2') : null
+
+ // Don't override a consumer-supplied accessible name; otherwise label the
+ //