diff --git a/.changeset/codeblock-header-focus-controls.md b/.changeset/codeblock-header-focus-controls.md
new file mode 100644
index 000000000000..74d7528b44ce
--- /dev/null
+++ b/.changeset/codeblock-header-focus-controls.md
@@ -0,0 +1,6 @@
+---
+'@astryxdesign/core': patch
+---
+
+[fix] CodeBlock: complete the collapsible header's disclosure pattern. It now shows the standard accent focus ring on `:focus-visible` — previously it defined no focus style and fell back to the browser's default outline, unlike the system's other disclosure controls (Collapsible, TabMenu) — and links to the code region it shows/hides via `aria-controls` (the header already exposed `aria-expanded`). The region stays mounted when collapsed (CSS grid animation), so `aria-controls` is an always-resolvable reference. (#3723)
+@bhamodi
diff --git a/packages/core/src/CodeBlock/CodeBlock.test.tsx b/packages/core/src/CodeBlock/CodeBlock.test.tsx
index 6a0db662a7b3..6e1dada5ab58 100644
--- a/packages/core/src/CodeBlock/CodeBlock.test.tsx
+++ b/packages/core/src/CodeBlock/CodeBlock.test.tsx
@@ -114,6 +114,49 @@ describe('CodeBlock', () => {
expect(header).toHaveAttribute('aria-expanded', 'false');
});
+ it('links the collapsible header to its code region via aria-controls', () => {
+ render(
+ ,
+ );
+ const header = screen
+ .getAllByRole('button')
+ .find(el => el.hasAttribute('aria-expanded'))!;
+ const controlsId = header.getAttribute('aria-controls');
+ // aria-controls must be present and point at the real code region.
+ expect(controlsId).toBeTruthy();
+ const region = document.getElementById(controlsId as string);
+ expect(region).not.toBeNull();
+ // The region contains the scrollable code body (role="group").
+ expect(region).toContainElement(screen.getByRole('group'));
+ });
+
+ it('keeps aria-controls resolvable when collapsed (region stays mounted)', () => {
+ render(
+ ,
+ );
+ const header = screen
+ .getAllByRole('button')
+ .find(el => el.hasAttribute('aria-expanded'))!;
+ fireEvent.click(header);
+ expect(header).toHaveAttribute('aria-expanded', 'false');
+ // The code region uses a CSS grid animation to collapse, so it stays in
+ // the DOM — aria-controls stays a valid, resolvable reference (unlike a
+ // conditionally-mounted region, which would need a conditional attribute).
+ const controlsId = header.getAttribute('aria-controls');
+ expect(controlsId).toBeTruthy();
+ expect(document.getElementById(controlsId as string)).not.toBeNull();
+ });
+
it('applies a per-instance syntax theme via the syntaxTheme prop', () => {
const {container} = render(
= collapsibleThreshold;
const [isCollapsed, setIsCollapsed] = useState(false);
+ // Links the collapsible header to the code region it shows/hides so assistive
+ // tech can move from the button to its controlled content (disclosure
+ // pattern). The region stays mounted when collapsed (CSS grid animation), so
+ // this is always a resolvable reference — aria-controls can be unconditional.
+ const regionId = useId();
const scrollContainerRef = useRef(null);
const scrollStyle: CSSProperties | undefined = maxHeight
@@ -727,6 +744,7 @@ export function CodeBlock({
role={canCollapse ? 'button' : undefined}
tabIndex={canCollapse ? 0 : undefined}
aria-expanded={canCollapse ? !isCollapsed : undefined}
+ aria-controls={canCollapse ? regionId : undefined}
onClick={canCollapse ? () => setIsCollapsed(prev => !prev) : undefined}
onKeyDown={
canCollapse
@@ -830,6 +848,7 @@ export function CodeBlock({
{headerEl}
{canCollapse ? (