Skip to content

Focus ring fails WCAG 1.4.11: --ring is a light gray applied at 50% (outline-ring/50, ring-ring/50), 1.52:1 in light mode and 1.45:1 in dark #1238

Description

@rawdaymx

Summary

Every focusable element in the design-system layer gets its visible focus indicator from
--ring (and, on the sidebar, --sidebar-ring) composited at 50% opacity —
focus-visible:ring-ring/50 on Button and Input, and the global
@apply border-border outline-ring/50; rule applied to every element. --ring is a light,
near-achromatic gray (oklch(0.708 0 0) in light mode, oklch(0.556 0 0) in dark), and at
50% opacity over the surfaces it actually sits on, the resulting focus ring falls far short of
the 3:1 minimum WCAG 1.4.11 requires for a UI component's visual boundary.

The same @layer base rule that fails also contains a token that passes: border-border
(--border: oklch(0.92 0.004 286.32) in light mode) is a low-contrast, decorative divider
color by design and is not subject to 1.4.11 — it isn't the accessible-focus-indicator token.
--ring is: it is the value every focus-visible:ring-* utility in the design system reaches
for. So this isn't "every color in default.css fails" — the border token is fine for what it
is; the ring token specifically fails the contrast floor for what it is used for.

Two of the four numbers below come from a criterion the theme doesn't obviously advertise:
--sidebar-ring (packages/ui/src/components/ui/sidebar.tsx uses it 5 times) is a distinct
token from --ring, and it is worse than --ring in dark mode (oklch(0.439 0 0) vs.
oklch(0.556 0 0)).

Environment

Defect type Static (code)
Commit 975c7a02a963d80bf140bb269d773b4912b8e7b6 (upstream/main)
How measured git fetch upstream && git rev-parse upstream/main, then reading and running a small TypeScript program against packages/ui/src/styles/default.css checked out at that commit (WCAG 2.x relative-luminance contrast math, sRGB alpha composition — see Evidence)
Supplementary observation The circular composited color was also confirmed to actually render this way in a browser against a self-hosted deployment of the ghcr.io/chatbotxio/*:latest images (not the commit above; deployed image, dated separately — see "What we did not verify")

How to see it

  1. Read packages/ui/src/styles/default.css:
    • :root (light): --ring: oklch(0.708 0 0); (line 99), --sidebar-ring: oklch(0.708 0 0); (line 112)
    • .dark: --ring: oklch(0.556 0 0); (line 133), --sidebar-ring: oklch(0.439 0 0); (line 146)
    • @layer base { * { @apply border-border outline-ring/50; } } (line 151) — applies outline-ring/50 to every element in the app
    • packages/ui/src/components/ui/button.tsx:7 and packages/ui/src/components/ui/input.tsx:12: focus-visible:ring-ring/50 ring-[3px]
  2. Compute the WCAG 2.x contrast ratio of --ring (and --sidebar-ring) composited at 50%
    alpha over the background it actually sits on (white/--background, --card, --sidebar),
    in both modes, using standard sRGB alpha compositing (color-mix//50 is exactly this: the
    browser paints the ring color at 50% opacity over whatever is underneath).

Evidence

Script (comprobar/leerTokensCss, portable WCAG 2.x math, no dependencies) run against
default.css at the commit above:

$ pnpm exec tsx medir.ts default.css
claro [
  'claro/texto-secundario 4.39',
  'claro/anillo 1.52',
  ... (unrelated rows omitted, see draft 03/05 for those)
]
oscuro [
  'oscuro/enlace 2.60',
  'oscuro/boton-contra-fondo 2.60',
  'oscuro/anillo 1.45',
  'oscuro/lateral 3.39',
  ...
]

Isolated ring/50-over-white composition (the simplest case, no sidebar token involved):

$ pnpm exec tsx medir-anillo-blanco.ts
ring/50 sobre blanco: 1.545

So: 1.545:1 over plain white, 1.52:1 over --sidebar (oklch(0.985 0 0), light), and 1.45:1 in
dark mode once --sidebar-ring: oklch(0.439 0 0) (worse than plain --ring there) is the
value actually composited. All three are well under the 3:1 floor for a UI-component boundary.

Usage counts confirming this isn't a corner case (measured against upstream/main at the
commit above, restricted to apps/**/*.tsx and packages/**/*.tsx):

$ git grep -n -E "(ring|outline)-ring/50" upstream/main -- 'apps/*.tsx' 'packages/*.tsx' | wc -l
25
$ git grep -n -E "(ring|outline|border)-ring([^/-]|$)" upstream/main -- 'apps/*.tsx' 'packages/*.tsx' | wc -l
40

(Witness that the query itself returns results: git grep -c "ring-ring" upstream/main -- packages/ui/src/components/ui/button.tsx1.)

Where it comes from

packages/ui/src/styles/default.css (commit 975c7a02a963d80bf140bb269d773b4912b8e7b6):

  • :root line 99 (--ring), line 112 (--sidebar-ring)
  • .dark line 133 (--ring), line 146 (--sidebar-ring)
  • line 151, the global outline-ring/50 rule
  • packages/ui/src/components/ui/button.tsx:7, packages/ui/src/components/ui/input.tsx:12 (the two most-used components that also apply ring-ring/50 directly)

Why we think this is a defect rather than the intended design

A visible focus indicator is a WCAG 1.4.11 (Non-text Contrast) requirement, not a stylistic
choice — component libraries built on shadcn (which this design system extends) generally treat
the ring color as the thing that must clear the non-text contrast floor, precisely because it
is the only signal a keyboard user gets that focus moved. The 50% opacity itself isn't the
problem in principle (a sufficiently dark/saturated ring can still clear 3:1 at 50%); the
problem is that --ring/--sidebar-ring are light, low-chroma grays that were seemingly picked
to look "neutral" rather than to survive being halved. We found no test, comment, or PR in the
history of default.css asserting this ratio is intentional.

Suggested fix

Values from a1-anillo.css (verified against the criteria above, using the same measurement
script — see Evidence):

:root {
  --ring: oklch(0.278 0 0);
  --sidebar-ring: oklch(0.278 0 0);
}
.dark {
  --ring: oklch(0.76 0 0);
  --sidebar-ring: oklch(0.76 0 0);
}

These are, respectively, the lightest gray in light mode and the darkest gray in dark mode that
still clear 3:1 both at 100% and at the 50% opacity the components actually use, against
--background, --card, and --sidebar. Re-running the same measurement script against this
patched file removes exactly the anillo rows in both modes and adds none:

$ pnpm exec tsx medir.ts a1-anillo.css
claro [ ...(same rows as before, minus 'claro/anillo 1.52')... ]
oscuro [ ...(same rows as before, minus 'oscuro/anillo 1.45')... ]

Alternative: drop the /50 and use the ring color at full opacity. This is strictly safer
contrast-wise but touches every component that references ring-ring/50/outline-ring/50
(dozens of call sites per the counts above), a much larger surface than a two-variable fix.

What we did not verify

  • Screen-reader behavior around focus — this is a visual-contrast finding only (WCAG 1.4.11),
    not a screen-reader or keyboard-navigation-order finding.
  • APCA / WCAG 3 contrast (the numbers above are WCAG 2.x, the ratio the spec's own tests use).
  • The exact deployed image digest for the "in a browser" observation of the computed
    outline-color — it was checked against a self-hosted deployment of the
    ghcr.io/chatbotxio/*:latest images on an earlier date than the commit cited in Environment,
    without recording its sha256: digest at the time; the code-level measurement above is the
    one that is pinned to a specific commit and is what this issue relies on.
  • Whether any consumer intentionally relies on the current low-contrast ring (e.g. as a subtle
    "focus without visual noise" affordance) — we found no such comment or test.

Related issues — none of these describes it

Searched with gh search issues --repo ChatbotXIO/ChatbotX "<term>" for focus ring,
outline-ring, and ring contrast (no --state filter, so both open and closed are included);
all three returned zero results. Witness that the search mechanism itself works: the same
command with "bug" and "error" returns multiple real issues (e.g. #1158, #1159, #1170).

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