Skip to content

[BUG] Accessible names in src/components/ui/ are hard-coded in English (dialog, sheet, carousel, pagination, breadcrumb) #23

Description

@luciaon4u

What you see

Eleven accessible names in src/components/ui/ are hard-coded English string literals. On a localized storefront a screen-reader user hears them in English regardless of the active language, while a sighted user sees nothing wrong — every one of them belongs to an icon-only control.

Verified against v2026.9.0:

File Line String
dialog.tsx 72 aria-label="Close"
dialog.tsx 76 <span className="sr-only">Close</span>
sheet.tsx 77 <span className="sr-only">Close</span>
carousel.tsx 200 <span className="sr-only">Previous slide</span>
carousel.tsx 230 <span className="sr-only">Next slide</span>
pagination.tsx 75 aria-label="Go to previous page"
pagination.tsx 92 aria-label="Go to next page"
pagination.tsx 115 <span className="sr-only">More pages</span>
pagination.tsx 15 aria-label="pagination"
breadcrumb.tsx 96 <span className="sr-only">More</span>
breadcrumb.tsx 8 aria-label="breadcrumb"

No primitive in src/components/ui/ uses useTranslation at all — we checked every .tsx in the folder.

ELI5

The X that closes a dialog has no text in it, just an icon. So the only thing that tells a blind user what that button does is a hidden label. That label is written in English inside the component. A Spanish storefront translates its own copy fine, but the dialog still announces "Close" — and nobody testing visually will ever notice, because there is nothing to see.

Why a consumer cannot fix it

These elements are rendered inside the primitives and are not exposed:

  • DialogContent renders its own close button whenever showCloseButton is true (the default).
  • SheetContent always renders one.
  • CarouselPrevious / CarouselNext build the sr-only span themselves.
  • PaginationPrevious / PaginationNext set aria-label on the anchor they render.

There is no prop and no slot to override. A consumer's only options are to fork the file or to reach into the DOM after render. In our storefront that is 25 call sites for the dialog and sheet alone (20 DialogContent, 5 SheetContent), so patching call sites was never on the table either.

Impact

WCAG 2.2 SC 4.1.2 (Name, Role, Value) is satisfied — the name exists — but the name is in the wrong language for every non-English locale, which is 3.1.2 (Language of Parts) territory and, more practically, a control the user cannot identify. It affects every dialog, sheet, carousel, pagination and breadcrumb in a storefront, which in ours means cart, PDP, account, checkout and all Page Designer overlays.

Suggested fix

The cleanest is for the primitives to resolve their own labels, since they are the only ones that can:

const { t } = useTranslation("common")
// …
<span className="sr-only">{t("close")}</span>

with the keys added to src/locales/*/translations.json under common. That fixes every consumer at once and needs no call-site change — it is what we did in our own fork of dialog.tsx and sheet.tsx, and nothing else had to change. It does give src/components/ui/ its first dependency on the i18n provider, which may be a deliberate line the template does not want to cross.

If it is, the alternative that still fixes the default is an optional label prop whose fallback is the translation, not the literal:

function DialogContent({ closeLabel, ... }) {
  const { t } = useTranslation("common")
  // …
  <span className="sr-only">{closeLabel ?? t("close")}</span>

What does not work is a prop with an English default: the bug then survives everywhere nobody remembers to pass it, which is the current situation with extra steps.

A small nit in the same place

dialog.tsx carries both aria-label="Close" (line 72) and <span className="sr-only">Close</span> (line 76) on the same button. aria-label wins the accessible name computation, so the span is dead text — it can never be announced. One of the two is redundant. (sheet.tsx has only the span, which is the shape upstream shadcn ships.)

Testing note, in case it is useful

Asserting the translated value is harder than it looks: t('common:close') resolves to "Close" in English, so such a test passes against a hard-coded literal, and i18next's fallback to the bare key means even a missing key matches a /close/i query. What worked for us was registering a sentinel bundle on a throwaway language and switching to it for the test — then putting the literal back and confirming the test actually fails.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions