Skip to content

[progress][meter] SSR hydration mismatch on aria-valuetext due to runtime-locale Intl.NumberFormat #4616

@xthezealot

Description

@xthezealot

Bug report

Current behavior

Progress.Root and Meter.Root generate aria-valuetext by calling Intl.NumberFormat(locale, { style: 'percent' }) in formatNumberValue. When the locale prop is not passed, the formatter defaults to the runtime locale — which differs between the SSR environment and the client browser. React then reports a hydration mismatch:

Warning: A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.

<div
  ...
  aria-valuenow={30}
+ aria-valuetext="30 %"
- aria-valuetext="30%"
  role="progressbar"
>

The server (Bun/Node) renders "30%" while a browser with a locale that uses NBSP before % (e.g. fr-FR, nl-NL) renders "30 %". The two inputs to Intl.NumberFormat are identical — only the default runtime locale differs.

Source: packages/react/src/utils/formatNumber.tsformatNumberValue(value, locale, format) falls through to Intl.NumberFormat(undefined, { style: 'percent' }) when no locale or format is provided.

The same formattedValue is also exposed via ProgressRootContext / MeterRootContext to Progress.Value / Meter.Value, so the visible text can mismatch too when those subcomponents are used.

Expected behavior

Progress.Root and Meter.Root should render the same aria-valuetext on the server and the client out of the box — either by producing a locale-independent default, or by deferring locale-sensitive formatting until after hydration.

Reproducible example

Minimal repro (any SSR framework — reproduced against TanStack Start on Bun, same outcome on Next.js App Router):

import { Progress } from '@base-ui/react/progress'

export default function Page() {
  return (
    <Progress.Root value={30}>
      <Progress.Track>
        <Progress.Indicator />
      </Progress.Track>
    </Progress.Root>
  )
}

Trigger by running the server with a locale that formats percents without NBSP (e.g. LANG=C or en-US) and opening the page in a browser with one that does (e.g. fr-FR, nl-NL, de-DE). Console shows the hydration mismatch on aria-valuetext.

Base UI version

v1.3.0

Which browser are you using?

Chrome (reproduces on any browser whose locale differs from the server's default locale)

Which OS are you using?

macOS (server); any OS on the client

Which assistive tech are you using (if applicable)?

N/A — bug surfaces as a React hydration warning; screen reader impact is a brief mismatch until React discards the server tree.

Additional context

Root causepackages/react/src/utils/formatNumber.ts:

export function formatNumberValue(value, locale, format) {
  if (value == null) return ''
  if (!format) {
    return formatNumber(value / 100, locale, { style: 'percent' })
  }
  return formatNumber(value, locale, format)
}

When neither locale nor format is passed by the consumer, Intl.NumberFormat uses the runtime default locale. This is non-deterministic across SSR boundaries.

Slider likely has the same problem via the same utility, though I haven't reproduced it — flagging for triage.

Possible fixes (happy to send a PR once the preferred direction is decided):

  1. Locale-independent default — when locale and format are both unset, skip Intl.NumberFormat and let the existing getDefaultAriaValueText fallback (${value}%) kick in. Breaking for consumers relying on localized Progress.Value output without passing locale.
  2. Mount-gated formatting — render a deterministic fallback on the first render, swap in the localized value after mount (useEffect). Non-breaking, one extra render.
  3. suppressHydrationWarning on the root — matches the approach in [switch][checkbox][radio][select][combobox] Add suppressHydrationWarning to hidden inputs #4482. Silences the warning but screen readers may still read a mismatched value before hydration completes.

Consumer workaround — pass an explicit locale to Progress.Root / Meter.Root:

<Progress.Root value={30} locale="en-US">

This works because both server and client now use the same formatter input.

Metadata

Metadata

Assignees

No one assigned

    Labels

    component: meterChanges related to the meter component.component: progressChanges related to the progress component.component: sliderChanges related to the slider component.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions