diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md
index 956cf975f4588d..22b569a0383cd7 100644
--- a/packages/ui/CHANGELOG.md
+++ b/packages/ui/CHANGELOG.md
@@ -12,6 +12,7 @@
### Enhancements
+- `CollapsibleCard`: Include the card title in the toggle button's accessible label so multiple collapsible cards on the same page are distinguishable by screen reader users ([#76329](https://github.com/WordPress/gutenberg/pull/76329)).
- `Notice`: Improve narrow layout by letting description and actions span the icon column when a title is present ([#76202](https://github.com/WordPress/gutenberg/pull/76202)).
## 0.8.0 (2026-03-04)
diff --git a/packages/ui/src/card/context.ts b/packages/ui/src/card/context.ts
new file mode 100644
index 00000000000000..bfd7248ed4134e
--- /dev/null
+++ b/packages/ui/src/card/context.ts
@@ -0,0 +1,8 @@
+import { createContext } from '@wordpress/element';
+
+export const TitleTextContext = createContext<
+ | {
+ setTitleText: ( text: string | undefined ) => void;
+ }
+ | undefined
+>( undefined );
diff --git a/packages/ui/src/card/title.tsx b/packages/ui/src/card/title.tsx
index fb6d14bd1642e7..ca4b5a384678d5 100644
--- a/packages/ui/src/card/title.tsx
+++ b/packages/ui/src/card/title.tsx
@@ -1,20 +1,68 @@
import { mergeProps, useRender } from '@base-ui/react';
-import { forwardRef } from '@wordpress/element';
+import { useMergeRefs } from '@wordpress/compose';
+import {
+ forwardRef,
+ useContext,
+ useLayoutEffect,
+ useRef,
+} from '@wordpress/element';
+import { TitleTextContext } from './context';
import styles from './style.module.css';
import type { TitleProps } from './types';
+/**
+ * Syncs the rendered text content of the given element to the nearest
+ * TitleTextContext (used by CollapsibleCard to build the trigger's
+ * accessible label). No-ops when rendered outside a CollapsibleCard.
+ *
+ * Runs on every render so the label stays current if children change
+ * dynamically. The cost is a single `textContent` read plus a bail-out
+ * `setState` when the value hasn't changed — skipped entirely when
+ * there is no context provider (i.e. the common non-collapsible case).
+ */
+function useSyncTitleText( ref: React.RefObject< HTMLElement | null > ) {
+ const titleTextContext = useContext( TitleTextContext );
+
+ useLayoutEffect( () => {
+ if ( ! titleTextContext ) {
+ return;
+ }
+
+ const text = ref.current?.textContent?.trim() || undefined;
+ titleTextContext.setTitleText( text );
+ } );
+
+ // Unmount-only cleanup — kept separate from the per-render sync
+ // above to avoid transiently clearing the value between runs.
+ useLayoutEffect( () => {
+ if ( ! titleTextContext ) {
+ return;
+ }
+
+ return () => titleTextContext.setTitleText( undefined );
+ }, [ titleTextContext ] );
+}
+
/**
* The title for a card. Renders as a `
` by default — use the `render`
* prop to swap in a semantic heading element when appropriate.
*/
export const Title = forwardRef< HTMLDivElement, TitleProps >(
- function CardTitle( { render, ...props }, ref ) {
+ function CardTitle( { render, ...restProps }, forwardedRef ) {
+ const internalRef = useRef< HTMLElement >( null );
+ const mergedRef = useMergeRefs( [ internalRef, forwardedRef ] );
+
+ useSyncTitleText( internalRef );
+
const element = useRender( {
defaultTagName: 'div',
render,
- ref,
+ ref: mergedRef,
// TODO: use `Text` component instead, when ready
- props: mergeProps< 'div' >( { className: styles.title }, props ),
+ props: mergeProps< 'div' >(
+ { className: styles.title },
+ restProps
+ ),
} );
return element;
diff --git a/packages/ui/src/collapsible-card/header.tsx b/packages/ui/src/collapsible-card/header.tsx
index a017bd29d8505f..1ea9bdd0af6f84 100644
--- a/packages/ui/src/collapsible-card/header.tsx
+++ b/packages/ui/src/collapsible-card/header.tsx
@@ -1,14 +1,56 @@
import { Collapsible } from '@base-ui/react/collapsible';
import clsx from 'clsx';
-import type { MouseEvent } from 'react';
-import { forwardRef, useCallback, useRef } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
+import type { MouseEvent, ReactNode } from 'react';
+import {
+ forwardRef,
+ useCallback,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from '@wordpress/element';
+import { __, sprintf } from '@wordpress/i18n';
import { chevronDown, chevronUp } from '@wordpress/icons';
import * as Card from '../card';
+import { TitleTextContext } from '../card/context';
import { IconButton } from '../icon-button';
import styles from './style.module.css';
import type { HeaderProps } from './types';
+/**
+ * Tracks the title text from a Card.Title child (via TitleTextContext) and
+ * falls back to the full header content text when no Card.Title is present.
+ * Returns the context provider value, a ref for the header content wrapper,
+ * and the composed trigger label.
+ */
+function useTriggerLabel( children: ReactNode ) {
+ const headerContentRef = useRef< HTMLDivElement >( null );
+ const [ titleText, setTitleText ] = useState< string >();
+ const [ headerText, setHeaderText ] = useState< string >();
+ const titleTextContextValue = useMemo( () => ( { setTitleText } ), [] );
+
+ // Fallback: read the header content's text when no Card.Title is
+ // present. `children` is listed as a dependency so the label
+ // re-syncs when the header content changes.
+ useLayoutEffect( () => {
+ if ( titleText === undefined ) {
+ const text = headerContentRef.current?.textContent?.trim();
+ setHeaderText( text || undefined );
+ }
+ }, [ titleText, children ] );
+
+ const identifierText = titleText ?? headerText;
+ const triggerLabel = identifierText
+ ? sprintf(
+ /* translators: %s: title of the card being expanded or collapsed */
+ __( 'Expand or collapse %s' ),
+ identifierText
+ )
+ : __( 'Expand or collapse' );
+
+ return { titleTextContextValue, headerContentRef, triggerLabel };
+}
+
/**
* The header of a collapsible card. Always visible, and acts as the
* toggle trigger — clicking anywhere on it expands or collapses the
@@ -24,6 +66,8 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >(
ref
) {
const triggerRef = useRef< HTMLButtonElement >( null );
+ const { titleTextContextValue, headerContentRef, triggerLabel } =
+ useTriggerLabel( children );
const handleHeaderClick = useCallback(
( event: MouseEvent< HTMLDivElement > ) => {
@@ -48,14 +92,21 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >(
onClick={ handleHeaderClick }
{ ...restProps }
>
-
{ children }
+
+
+ { children }
+
+
(
(
+
+
+
+ General settings
+
+
+ Configure your general preferences here.
+
+
+
+
+ Privacy
+
+
+ Manage your privacy options.
+
+
+
+
+ Notifications
+
+
+ Choose which notifications you receive.
+
+
+
+ ),
+};
+
/**
* Visual comparison: a `CollapsibleCard` (open) next to a regular `Card`
* to verify identical spacing and layout.
diff --git a/packages/ui/src/collapsible-card/test/index.test.tsx b/packages/ui/src/collapsible-card/test/index.test.tsx
index 6c2004807c4229..106991141128dc 100644
--- a/packages/ui/src/collapsible-card/test/index.test.tsx
+++ b/packages/ui/src/collapsible-card/test/index.test.tsx
@@ -87,23 +87,19 @@ describe( 'CollapsibleCard', () => {
);
+ const trigger = screen.getByRole( 'button', {
+ name: 'Expand or collapse Title',
+ } );
+
expect(
screen.queryByText( 'Toggle content' )
).not.toBeInTheDocument();
- await user.click(
- screen.getByRole( 'button', {
- name: 'Expand or collapse card',
- } )
- );
+ await user.click( trigger );
expect( screen.getByText( 'Toggle content' ) ).toBeVisible();
- await user.click(
- screen.getByRole( 'button', {
- name: 'Expand or collapse card',
- } )
- );
+ await user.click( trigger );
expect(
screen.queryByText( 'Toggle content' )
@@ -152,7 +148,7 @@ describe( 'CollapsibleCard', () => {
await user.click(
screen.getByRole( 'button', {
- name: 'Expand or collapse card',
+ name: 'Expand or collapse Title',
} )
);
@@ -179,7 +175,7 @@ describe( 'CollapsibleCard', () => {
await user.click(
screen.getByRole( 'button', {
- name: 'Expand or collapse card',
+ name: 'Expand or collapse Title',
} )
);
@@ -187,19 +183,90 @@ describe( 'CollapsibleCard', () => {
} );
} );
- describe( 'trigger', () => {
- it( 'renders a toggle button', () => {
+ describe( 'trigger accessible label', () => {
+ it( 'includes the Card.Title text in the trigger label', () => {
render(
- Title
+ Settings
);
expect(
screen.getByRole( 'button', {
- name: 'Expand or collapse card',
+ name: 'Expand or collapse Settings',
+ } )
+ ).toBeVisible();
+ } );
+
+ it( 'uses a static label that does not change when toggled', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ Settings
+
+
+ Content
+
+
+ );
+
+ const trigger = screen.getByRole( 'button', {
+ name: 'Expand or collapse Settings',
+ } );
+ expect( trigger ).toHaveAttribute( 'aria-expanded', 'false' );
+
+ await user.click( trigger );
+
+ expect( trigger ).toHaveAttribute( 'aria-expanded', 'true' );
+ expect( trigger ).toHaveAccessibleName(
+ 'Expand or collapse Settings'
+ );
+ } );
+
+ it( 'falls back to header content when no Card.Title is used', () => {
+ render(
+
+
+ Plain header text
+
+
+ );
+
+ expect(
+ screen.getByRole( 'button', {
+ name: 'Expand or collapse Plain header text',
+ } )
+ ).toBeVisible();
+ } );
+
+ it( 'produces unique labels for multiple cards', () => {
+ render(
+ <>
+
+
+ General
+
+
+
+
+ Privacy
+
+
+ >
+ );
+
+ expect(
+ screen.getByRole( 'button', {
+ name: 'Expand or collapse General',
+ } )
+ ).toBeVisible();
+ expect(
+ screen.getByRole( 'button', {
+ name: 'Expand or collapse Privacy',
} )
).toBeVisible();
} );