mui / mui/base-ui

[radio] SSR markup has no aria-labelledby on the radiogroup and tabindex="-1" on every radio

Open
#5,597 0 comments 5 reactions 0 assignees View on GitHub
component: radio group type: bug
Dominant language
TypeScript
Stars
10.9k
Forks
543
Avg merge
1d 20h
Merged PRs (30d)
101

Description

# Bug report

Two related defects in the server-rendered markup of `Radio` / `RadioGroup` / `Fieldset`. Both self-correct on hydration, so they're invisible in a client-only app, but the SSR HTML ships an unlabelled radio group that is also outside the tab order.

They share a cause: both the label association and the roving tabindex are resolved in **layout effects**, which never run on the server.

## Current behavior

Server-rendering a `Fieldset.Root` + `RadioGroup` + `Radio.Root` tree produces:

```html


Is your pet a boy or a girl?

good boy

good girl


```

1. **The `radiogroup` has no `aria-labelledby`**, so it has no accessible name, even though `Fieldset.Legend` rendered and has an `id`.
2. **Every `role="radio"` has `tabindex="-1"`**, so the group is not reachable by Tab — there is no roving `tabindex="0"` entry point.

Both are corrected once hydration runs, so this only affects the pre-hydration window and anything consuming the static HTML.

## Expected behavior

The server markup should match what hydration produces:

1. `role="radiogroup"` carries `aria-labelledby` pointing at the legend.
2. The first non-disabled radio (or the checked one) carries `tabindex="0"`, the rest `-1`.

## Reproducible example

No bundler or framework needed — plain `renderToString`. Save as `repro.mjs` in a project with `react`, `react-dom` and `@base-ui/react` installed, then `node repro.mjs`:

```js
import { createElement as h } from 'react';
import { renderToString } from 'react-dom/server';
import { RadioGroup } from '@base-ui/react/radio-group';
import { Radio } from '@base-ui/react/radio';
import { Fieldset } from '@base-ui/react/fieldset';

const html = renderToString(
h(Fieldset.Root, { render: h(RadioGroup, null) },
h(Fieldset.Legend, null, 'Is your pet a boy or a girl?'),
h(Radio.Root, { value: 'male' }, 'good boy'),
h(Radio.Root, { value: 'female' }, 'good girl'),
),
);

console.log('radiogroup has aria-labelledby :', /role="radiogroup"[^>]*aria-labelledby/.test(html));
console.log('tabindex on role=radio :', html.match(/role="radio" tabindex="(-?\d)"/g));
```

Actual output:

```
radiogroup has aria-labelledby : false
tabindex on role=radio : [ 'role="radio" tabindex="-1"', 'role="radio" tabindex="-1"' ]
```

I originally hit this in a TanStack Start app, but as the repro shows it is not framework-specific.

## Base UI version

v1.7.0

## Which browser are you using?

Chrome 131 (the defect is in the server output, so it is browser-independent)

## Which OS are you using?

macOS 26

## Which assistive tech are you using (if applicable)?

None — found by inspecting SSR markup during an accessibility audit, then confirmed against the hydrated accessibility tree.

## Additional context

### Cause of (2), the `tabindex`

`useCompositeItem` derives the tabindex from the highlight:

```js
const isHighlighted = highlightedIndex === index;
const compositeProps = { tabIndex: isHighlighted ? 0 : -1, ... };
```

`useCompositeRoot` initialises `internalHighlightedIndex` to `0` during render, so on the server `highlightedIndex === 0`. But `useCompositeListItem` leaves `index` at `-1` until its `useIsoLayoutEffect` registration runs — so on the server no item matches and every item gets `-1`.

`useCompositeListItem` already has the remedy in the `guess` option, which computes the index from render order inside a `useState` initialiser (so it does run during SSR):

```js
// Guess the index from the render order. This avoids a re-render after mount for
// flat lists rendered in DOM order; ...
```

`MenuItem`, `MenuRadioItem`, `MenuCheckboxItem`, `MenuLinkItem`, `MenuSubmenuTrigger`, `SelectItem`, `ComboboxItem` and `OTPFieldInput` all pass `guess: true`. `RadioRoot` does not — and it goes through `CompositeItem`, which currently neither accepts nor forwards `guess`:

```js
// internals/composite/item/CompositeItem.js
const { render, className, style, state, props, refs, metadata, stateAttributesMapping, tag = 'div', ...elementProps } = componentProps;
const { compositeProps, compositeRef } = useCompositeItem({ metadata });
```

### Suggested fix for (2)

Accept and forward `guess` in `CompositeItem` (destructured so it doesn't leak onto the DOM), and pass `guess: true` from `RadioRoot`'s `CompositeItem`.

I patched exactly that locally against 1.7.0 and the repro then emits `tabindex="0"` / `tabindex="-1"`. In a browser I confirmed hydration produces no mismatch warning, arrow-key roving and selection still work, and tabbing back into the group still lands on the checked radio. Happy to open a PR if that approach looks right.

This is likely also the cause of #4174's observation that a `RadioGroup` "first mounts with `tabIndex=-1` ... then after a render they appear with `tabIndex=0`".

`Tabs`, `Toolbar` and the other composites that don't opt into `guess` are presumably affected the same way; I only verified `Radio`.

### Cause of (1), the `aria-labelledby`

`FieldsetRoot` holds the legend id in state and only learns it from the child:

```js
const [legendId, setLegendId] = React.useState(undefined);
// ...
props: [{ 'aria-labelledby': legendId, disabled }, elementProps]
```

`FieldsetLegend` pushes it up through `useRegisteredLabelId`, which registers in a layout effect. On the server that effect never runs, so `legendId` is `undefined` and the attribute is dropped. `RadioGroup`'s own `const ariaLabelledby = labelId ?? fieldsetContext?.legendId` inherits the same problem.

I don't have a clean fix to propose here, since it's a child-to-parent registration. The obvious direction would be for `FieldsetRoot` to generate the id during render and expose it via context for `FieldsetLegend` to consume, inverting the flow — but that presumably conflicts with supporting a caller-supplied `id` on the legend, so I'd rather leave the design call to you.

### Workaround for (1)

Because both `FieldsetRoot` and `CompositeRoot` merge caller props last, passing the id explicitly in both directions wins over the internal wiring and works on the server today:

```jsx
const legendId = useId();

}>
Is your pet a boy or a girl?
...

```

Contributor guide

Open the contributing guide

Research direction

Run the provided repro.mjs first to observe the server-rendered attributes. Then read internals/composite/item/CompositeItem.js, RadioRoot, FieldsetRoot, FieldsetLegend, and RadioGroup to trace the SSR paths described in the issue. Done means SSR includes the radiogroup label and correct roving tabindex, while hydration remains warning-free and interactive behavior still works.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
accessibility, frontend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
57/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.