diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md
index fcdad5093a7a26..4d0ec716f604ed 100644
--- a/packages/dataviews/CHANGELOG.md
+++ b/packages/dataviews/CHANGELOG.md
@@ -11,6 +11,7 @@
- DataForm: Add `compact` configuration option to the `datetime` control. [#76905](https://github.com/WordPress/gutenberg/pull/76905)
- DataViews: Field's description can accept ReactElements. [#76829](https://github.com/WordPress/gutenberg/pull/76829)
+- DataForm: Use `CollapsibleCard.HeaderDescription` for card layout header descriptions instead of manual `aria-describedby`. [#76867](https://github.com/WordPress/gutenberg/pull/76867)
## 13.1.0 (2026-03-18)
diff --git a/packages/dataviews/src/components/dataform-layouts/card/index.tsx b/packages/dataviews/src/components/dataform-layouts/card/index.tsx
index 29a153042023e6..bd18e5bab84637 100644
--- a/packages/dataviews/src/components/dataform-layouts/card/index.tsx
+++ b/packages/dataviews/src/components/dataform-layouts/card/index.tsx
@@ -5,7 +5,6 @@ import {
useCallback,
useContext,
useEffect,
- useId,
useMemo,
useRef,
useState,
@@ -87,7 +86,6 @@ function isSummaryFieldVisible< Item >(
function HeaderContent< Item >( {
data,
fields,
- descriptionId,
label,
layout,
isOpen,
@@ -96,7 +94,6 @@ function HeaderContent< Item >( {
}: {
data: Item;
fields: NormalizedField< Item >[];
- descriptionId: string;
label: string | undefined;
layout: NormalizedCardLayout;
isOpen: boolean;
@@ -120,11 +117,7 @@ function HeaderContent< Item >( {
>
{ label }
{ ( hasBadge || hasSummary ) && (
-
+
{ hasBadge && }
{ hasSummary && (
@@ -137,7 +130,7 @@ function HeaderContent< Item >( {
) ) }
) }
-
+
) }
);
@@ -208,7 +201,6 @@ export default function FormCardField< Item >( {
const { fields } = useContext( DataFormContext );
const layout = field.layout as NormalizedCardLayout;
const contentRef = useRef< HTMLDivElement >( null );
- const descriptionId = useId();
const form: NormalizedForm = useMemo(
() => ( {
@@ -284,7 +276,6 @@ export default function FormCardField< Item >( {
( {
open={ isOpen }
onOpenChange={ handleOpenChange }
>
-
+
{ headerContent }
void;
+} >( {
+ setDescriptionId: () => {},
+} );
diff --git a/packages/ui/src/collapsible-card/header-description.tsx b/packages/ui/src/collapsible-card/header-description.tsx
new file mode 100644
index 00000000000000..08c24560bee84b
--- /dev/null
+++ b/packages/ui/src/collapsible-card/header-description.tsx
@@ -0,0 +1,43 @@
+import { forwardRef, useContext, useEffect, useId } from '@wordpress/element';
+import { HeaderDescriptionIdContext } from './context';
+import type { HeaderDescriptionProps } from './types';
+
+/**
+ * Secondary content placed in the collapsible card header that describes
+ * the trigger button via `aria-describedby`. Use it for supplementary
+ * information such as status badges or summary values.
+ *
+ * The content is visually rendered but marked `aria-hidden` so that
+ * assistive technologies consume it only through the `aria-describedby`
+ * relationship on the trigger, avoiding double announcements.
+ *
+ * Avoid interactive elements (buttons, links, inputs) inside this
+ * component — the entire header is the toggle trigger.
+ */
+export const HeaderDescription = forwardRef<
+ HTMLDivElement,
+ HeaderDescriptionProps
+>( function CollapsibleCardHeaderDescription(
+ { children, className, ...restProps },
+ ref
+) {
+ const descriptionId = useId();
+ const { setDescriptionId } = useContext( HeaderDescriptionIdContext );
+
+ useEffect( () => {
+ setDescriptionId( descriptionId );
+ return () => setDescriptionId( undefined );
+ }, [ descriptionId, setDescriptionId ] );
+
+ return (
+
+ { children }
+
+ );
+} );
diff --git a/packages/ui/src/collapsible-card/header.tsx b/packages/ui/src/collapsible-card/header.tsx
index fdb7d88a8705e1..230f05b2d9f654 100644
--- a/packages/ui/src/collapsible-card/header.tsx
+++ b/packages/ui/src/collapsible-card/header.tsx
@@ -1,11 +1,12 @@
import clsx from 'clsx';
-import { forwardRef } from '@wordpress/element';
+import { forwardRef, useMemo, useState } from '@wordpress/element';
import { chevronDown } from '@wordpress/icons';
import * as Card from '../card';
import * as Collapsible from '../collapsible';
import { Icon } from '../icon';
import styles from './style.module.css';
import focusStyles from '../utils/css/focus.module.css';
+import { HeaderDescriptionIdContext } from './context';
import type { HeaderProps } from './types';
/**
@@ -22,38 +23,54 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >(
{ children, className, render, ...restProps },
ref
) {
+ const [ descriptionId, setDescriptionId ] = useState< string >();
+
+ const contextValue = useMemo(
+ () => ( { setDescriptionId } ),
+ [ setDescriptionId ]
+ );
+
return (
-
- }
- nativeButton={ false }
- >
- { children }
-
+
+ }
+ nativeButton={ false }
+ aria-describedby={ descriptionId }
>
+
+ { children }
+
-
-
+
+
);
}
);
diff --git a/packages/ui/src/collapsible-card/index.ts b/packages/ui/src/collapsible-card/index.ts
index 3387b70e220616..d8d00f0202a394 100644
--- a/packages/ui/src/collapsible-card/index.ts
+++ b/packages/ui/src/collapsible-card/index.ts
@@ -1,5 +1,6 @@
import { Root } from './root';
import { Header } from './header';
+import { HeaderDescription } from './header-description';
import { Content } from './content';
-export { Root, Header, Content };
+export { Root, Header, HeaderDescription, Content };
diff --git a/packages/ui/src/collapsible-card/stories/index.story.tsx b/packages/ui/src/collapsible-card/stories/index.story.tsx
index d7148a5105d1f0..b587604c8e63a8 100644
--- a/packages/ui/src/collapsible-card/stories/index.story.tsx
+++ b/packages/ui/src/collapsible-card/stories/index.story.tsx
@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import * as Card from '../../card';
import * as CollapsibleCard from '../index';
+import { Stack } from '../../stack';
/**
* Temporary text component for story examples. This will be replaced by an
@@ -29,6 +30,7 @@ const meta: Meta< typeof CollapsibleCard.Root > = {
component: CollapsibleCard.Root,
subcomponents: {
'CollapsibleCard.Header': CollapsibleCard.Header,
+ 'CollapsibleCard.HeaderDescription': CollapsibleCard.HeaderDescription,
'CollapsibleCard.Content': CollapsibleCard.Content,
},
};
@@ -156,6 +158,51 @@ export const Stacked: Story = {
),
};
+/**
+ * A collapsible card with a `HeaderDescription` that provides supplementary
+ * information (e.g. status, summary) as an `aria-describedby` relationship.
+ */
+export const WithHeaderDescription: Story = {
+ // `defaultOpen` (uncontrolled) and `open` (controlled) should not be
+ // used together — disable the `open` control to avoid confusion.
+ argTypes: { open: { control: false } },
+ args: {
+ defaultOpen: true,
+ },
+ render: ( { open, defaultOpen, onOpenChange, disabled, ...restArgs } ) => (
+
+
+
+ Settings
+
+
+ 3 items configured
+
+
+
+
+
+
+ The header description provides supplementary context to the
+ trigger button. Assistive technologies will announce the
+ description alongside the button label.
+
+
+
+ ),
+};
+
/**
* 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 1d23797d399303..5ce044713dd492 100644
--- a/packages/ui/src/collapsible-card/test/index.test.tsx
+++ b/packages/ui/src/collapsible-card/test/index.test.tsx
@@ -178,4 +178,97 @@ describe( 'CollapsibleCard', () => {
).toBeVisible();
} );
} );
+
+ describe( 'HeaderDescription', () => {
+ it( 'sets aria-describedby on the trigger pointing to the description', () => {
+ render(
+
+
+ Settings
+
+ 3 errors
+
+
+
+ Content
+
+
+ );
+
+ const trigger = screen.getByRole( 'button', {
+ name: 'Settings',
+ } );
+ const descriptionElement = screen.getByTestId( 'desc' );
+
+ expect( descriptionElement ).toHaveAttribute( 'id' );
+ expect( trigger ).toHaveAttribute(
+ 'aria-describedby',
+ descriptionElement.id
+ );
+ } );
+
+ it( 'marks the description content as aria-hidden', () => {
+ render(
+
+
+ Settings
+
+ Status: OK
+
+
+
+ );
+
+ const descriptionWrapper = screen.getByTestId( 'desc' );
+ expect( descriptionWrapper ).toHaveAttribute(
+ 'aria-hidden',
+ 'true'
+ );
+ } );
+
+ it( 'does not set aria-describedby when HeaderDescription is absent', () => {
+ render(
+
+
+ Title
+
+
+ );
+
+ const trigger = screen.getByRole( 'button', { name: 'Title' } );
+ expect( trigger ).not.toHaveAttribute( 'aria-describedby' );
+ } );
+
+ it( 'forwards ref on HeaderDescription', () => {
+ const descRef = createRef< HTMLDivElement >();
+
+ render(
+
+
+ Title
+
+ Description
+
+
+
+ );
+
+ expect( descRef.current ).toBeInstanceOf( HTMLDivElement );
+ } );
+
+ it( 'renders description content visually', () => {
+ render(
+
+
+ Title
+
+ Badge content
+
+
+
+ );
+
+ expect( screen.getByText( 'Badge content' ) ).toBeVisible();
+ } );
+ } );
} );
diff --git a/packages/ui/src/collapsible-card/types.ts b/packages/ui/src/collapsible-card/types.ts
index f76986ef1a49b5..35763ccc7910dd 100644
--- a/packages/ui/src/collapsible-card/types.ts
+++ b/packages/ui/src/collapsible-card/types.ts
@@ -37,6 +37,18 @@ export interface HeaderProps extends ComponentProps< 'div' > {
children?: ReactNode;
}
+export interface HeaderDescriptionProps extends ComponentProps< 'div' > {
+ /**
+ * Secondary content that describes the header trigger via
+ * `aria-describedby`. Rendered visually but marked `aria-hidden`
+ * so assistive technologies consume it only through the description
+ * relationship, avoiding double announcements.
+ *
+ * Avoid interactive elements — the entire header is the toggle trigger.
+ */
+ children?: ReactNode;
+}
+
export interface ContentProps extends ComponentProps< 'div' > {
/**
* The content to be rendered inside the collapsible content area.