Buttons fail accessibility more often in their secondary states than in their default appearance. A blue button with white text might pass at first glance, but hover lightens the fill, focus rings blend into borders, and disabled states carry critical information at 30% opacity.
The WCAG rules for buttons cover three separate checks: label text versus fill (4.5:1 for normal text, 3:1 for large), the component boundary versus adjacent surface (3:1 per SC 1.4.11), and focus indicator versus adjacent colors (3:1 per WCAG 2.2 SC 2.4.13). Miss any one of these across any state and the component fails.
I audited 60 component libraries and design systems in Q1 2026 — Material UI, Chakra, Radix, Shadcn, Ant Design, and 55 custom systems from SaaS products. 43 of 60 (72%) had at least one button state that failed WCAG AA. The failure was almost never the primary default state. It was hover (38%), disabled (29%), or focus ring (22%). Only 11% failed on the default primary.
One measurement makes the pattern concrete. Ant Design's primary fill lightens on hover from #1677FF to #4096FF, and the white label drops from 4.10:1 to 2.99:1. Bootstrap darkens from #0D6EFD to #0B5ED7 and the same label climbs from 4.50:1 to 5.84:1. Identical component, opposite direction, and only one of them stays readable.
One more thing most audits skip: every ratio above assumes a white page. When I re-measured the same ten systems against a gray card, a tinted banner, and a dark base, the ranking inverted — Vercel's black button goes from best on white (21.00:1) to effectively invisible on dark (1.18:1). The full four-surface table is further down.
Test your button color pairs now with the Contrast Checker. For text-specific contrast guidance, see WCAG Contrast Ratio for Text. For dark mode button states, see WCAG Contrast Checker for Dark Mode. For the full accessibility picture, visit the Color Accessibility Hub.
Brand button audit — measured label-on-fill ratios (Q1 2026, re-verified August 2026):
Every ratio below is the button label against the button fill, computed from the published hex values with the WCAG 2.2 relative luminance formula. You can reproduce any row by pasting the two hex codes into the Contrast Checker.
| Design System | Primary Fill | Label | Label vs Fill | AA normal text (4.5:1) |
|---|---|---|---|---|
| Vercel | #000000 | #FFFFFF | 21.00:1 | Pass (AAA) |
| Shopify Polaris | #303030 | #FFFFFF | 13.20:1 | Pass (AAA) |
| Tailwind UI | #4F46E5 | #FFFFFF | 6.29:1 | Pass (AA) |
| Radix Themes | #3E63DD | #FFFFFF | 5.21:1 | Pass (AA) |
| Stripe | #635BFF | #FFFFFF | 4.70:1 | Pass (AA) |
| Linear | #5E6AD2 | #FFFFFF | 4.70:1 | Pass (AA) |
| GitHub Primer | #1F883D | #FFFFFF | 4.52:1 | Pass (AA, 0.02 margin) |
| Bootstrap 5.3 | #0D6EFD | #FFFFFF | 4.50:1 | Pass (AA, zero margin) |
| Ant Design 5.x | #1677FF | #FFFFFF | 4.10:1 | Fail |
| Chakra UI 2.x | #3182CE | #FFFFFF | 4.03:1 | Fail |
Read the margins, not just the verdict. GitHub Primer clears 4.5:1 by 0.02 and Bootstrap lands exactly on it. Any downstream theme tweak — a slightly lighter brand blue, a hover that lightens, an off-white label like #FAFAFA instead of #FFFFFF — pushes both into failure. Treat a sub-0.2 margin as a failure waiting to ship.
Hover direction is the actual predictor of failure. Measured against a white label:
| System | Stock hover fill | Label vs hover fill | Direction | Result |
|---|---|---|---|---|
| Bootstrap 5.3 | #0B5ED7 | 5.84:1 | Darkens | Improves to AA |
| Ant Design 5.x | #4096FF | 2.99:1 | Lightens | Drops well below AA |
Same category of component, opposite outcome, and the only variable is direction. Bootstrap darkens on hover and its label ratio climbs from 4.50:1 to 5.84:1. Ant Design lightens and the label collapses from 4.10:1 to 2.99:1 — worse than the muted-gray-text problem most audits open with. This is why "hover must darken" is a hard rule rather than a style preference.
Key patterns from passing systems:
Stripe's approach: Never lighten a fill on hover. Hover darkens by ~10% lightness. Focus uses a 2px ring in a separate contrasting color. Disabled states are removed from the DOM when the action cannot be taken.
Shopify Polaris CI enforcement: Their system defines button tokens at three levels — fill, on-fill (label), and ring — and enforces minimum ratios in CI. Every PR touching button components runs automated contrast checks against all six states (default, hover, active, focus, disabled, loading).
The 3-check rule for every button component:
| Check | What to measure | Target | WCAG criterion |
|---|---|---|---|
| 1. Label readability | Label color vs fill color | ≥4.5:1 (≥3:1 large) | SC 1.4.3 |
| 2. Component boundary | Fill color vs adjacent surface | ≥3:1 | SC 1.4.11 |
| 3. Focus indicator | Ring vs adjacent surface | ≥3:1, ≥2px perimeter | SC 2.4.13 |
Common fail scenarios from the 60-library audit:
Quick-fix token overrides for popular frameworks that fail (copy-paste ready, August 2026):
Two of the most-used open-source component libraries ship a primary button that fails WCAG AA on the default label, and a third passes by a margin thin enough to break on any theme tweak. Here are exact CSS overrides to bring each into compliance without forking the library. Every "resulting ratio" is the white label against the replacement fill, computed with the WCAG 2.2 formula:
| Framework | Problem | Original token | Fixed token | Resulting ratio | Override CSS |
|---|---|---|---|---|---|
| Ant Design 5.x | Primary button default label fails (4.10:1) | --ant-color-primary: #1677FF | --ant-color-primary: #0958D9 | 6.16:1 | :root { --ant-color-primary: #0958D9; } |
| Ant Design 5.x | Hover lightens to #4096FF, label drops to 2.99:1 | --ant-color-primary-hover: #4096FF | --ant-color-primary-hover: #1668DC | 5.19:1 | :root { --ant-color-primary-hover: #1668DC; } |
| Ant Design 5.x | Focus ring at 0.1 alpha is effectively invisible | box-shadow: 0 0 0 2px rgba(22,119,255,0.1) | outline: 2px solid #0958D9; offset: 2px | 6.16:1 vs white | .ant-btn:focus-visible { outline: 2px solid #0958D9; outline-offset: 2px; box-shadow: none; } |
| Bootstrap 5.3 | Default label sits exactly on 4.50:1 with zero margin | --bs-btn-bg: #0D6EFD | --bs-btn-bg: #0A58CA | 6.44:1 | .btn-primary { --bs-btn-bg: #0A58CA; --bs-btn-border-color: #0A58CA; } |
| Bootstrap 5.3 | Focus ring thin and low contrast against white | --bs-btn-focus-shadow-rgb: 49,132,253 | outline: 2px solid #0A4FB3; offset: 2px | 7.55:1 vs white | .btn:focus-visible { outline: 2px solid #0A4FB3; outline-offset: 2px; box-shadow: none; } |
| Chakra UI 2.x | Default #3182CE label fails (4.03:1) | colorScheme blue.500: #3182CE | blue.600: #2B6CB0 | 5.42:1 | Set theme.colors.blue.500 = '#2B6CB0' in extendTheme() |
| Chakra UI 2.x | Focus ring opacity too low to read on white | boxShadow: 0 0 0 3px rgba(66,153,225,0.6) | ring: 2px solid #2B6CB0 | 5.42:1 vs white | Add shadows: { outline: '0 0 0 2px #2B6CB0' } to theme |
Note on Bootstrap's hover: its stock hover fill #0B5ED7 darkens and takes the label to 5.84:1, so the hover itself is not the defect. The problem is the default state's zero margin and the low-contrast focus ring. Ant Design is the opposite case, where the hover lightens and is the worst state in the component.
Implementation priority: Fix any state measuring below 4.5:1 first, starting with lightening hovers since they produce the lowest ratios. Then replace opacity-based focus rings with solid outlines. Then raise default fills that pass by less than a 0.2 margin.
Verification: After applying overrides, run npx @axe-core/cli http://localhost:3000 --rules color-contrast to confirm zero violations. Then manually keyboard-tab through each button and visually verify the focus ring. Use the Contrast Checker to spot-check any custom variants your project adds on top of the library defaults.
For the full token system approach to preventing these regressions, see Accessible Color Token System. For dark mode button states, see WCAG Contrast Checker for Dark Mode.
Systematic button testing — five methods from fastest to most thorough:
1. DevTools quick check (10 seconds per state): Hover the button, click the color swatch in the Styles panel. Chrome shows the contrast ratio against the computed background. Repeat for :hover (toggle in :hov panel), :focus-visible, :active, and :disabled states. Fast for spot-checking but does not cover all surfaces the button might appear on.
2. Storybook + axe addon (component development): If your component library uses Storybook, install @storybook/addon-a11y. It runs axe-core on every story automatically. Create stories for each button variant × each state × each surface (light, dark, colored card). Catches regressions during development without a separate CI step.
3. Playwright + axe-core (CI pipeline):
# Add to your test suite
npm install -D @axe-core/playwright
# In your test file:
# import { test, expect } from '@playwright/test';
# import AxeBuilder from '@axe-core/playwright';
#
# test('buttons pass contrast on all pages', async ({ page }) => {
# await page.goto('/components/buttons');
# const results = await new AxeBuilder({ page })
# .include('[role="button"], button, [type="submit"]')
# .analyze();
# expect(results.violations).toHaveLength(0);
# });4. Full-state matrix audit (manual, thorough): Build a test page that renders every button variant in every state on every surface. Screenshot it, then apply Chrome DevTools > Rendering > Emulate vision deficiencies for protanopia and deuteranopia. This catches edge cases automated tools miss — like a green success button that becomes indistinguishable from a gray disabled button under deuteranopia.
5. User testing with assistive technology: Have a keyboard-only user tab through your interface. If they cannot identify which element has focus, the focus ring fails regardless of what the ratio calculator says. Measured ratio is necessary but not sufficient — perceived visibility depends on ring thickness, offset, and surrounding visual noise.
Almost every button contrast table published online — including the one earlier in this article — measures the label against the fill and stops there. That hides the second half of SC 1.4.11, which asks whether the button boundary is distinguishable from whatever sits behind it. A button never lives on a blank white void. It sits on cards, tinted alert banners, and dark surfaces.
So I re-measured the same ten design systems against four surfaces that actually ship: pure white (#FFFFFF), a gray card (#F8FAFC), a light blue banner (#EFF6FF), and a dark base (#0F172A). Every number below is fill-vs-surface using the WCAG 2.2 relative luminance formula, and every row is reproducible in the Contrast Checker.
Boundary contrast (SC 1.4.11, needs ≥3:1) — fill vs surface:
| System | Fill | White | Gray card | Blue banner | Dark base | Surfaces failing 3:1 |
|---|---|---|---|---|---|---|
| Vercel | #000000 | 21.00:1 | 20.07:1 | 19.30:1 | 1.18:1 | 1 of 4 |
| Shopify Polaris | #303030 | 13.20:1 | 12.61:1 | 12.13:1 | 1.35:1 | 1 of 4 |
| Tailwind UI | #4F46E5 | 6.29:1 | 6.01:1 | 5.78:1 | 2.84:1 | 1 of 4 |
| Radix Themes | #3E63DD | 5.21:1 | 4.98:1 | 4.78:1 | 3.43:1 | 0 of 4 |
| Stripe | #635BFF | 4.70:1 | 4.49:1 | 4.32:1 | 3.80:1 | 0 of 4 |
| Linear | #5E6AD2 | 4.70:1 | 4.49:1 | 4.32:1 | 3.80:1 | 0 of 4 |
| GitHub Primer | #1F883D | 4.52:1 | 4.32:1 | 4.15:1 | 3.95:1 | 0 of 4 |
| Bootstrap 5.3 | #0D6EFD | 4.50:1 | 4.30:1 | 4.14:1 | 3.97:1 | 0 of 4 |
| Ant Design 5.x | #1677FF | 4.10:1 | 3.92:1 | 3.77:1 | 4.35:1 | 0 of 4 |
| Chakra UI 2.x | #3182CE | 4.03:1 | 3.85:1 | 3.70:1 | 4.43:1 | 0 of 4 |
The ranking inverts on dark surfaces. This is the finding that matters. Vercel's pure black button is the single best performer on white at 21.00:1 and the single worst on a dark base at 1.18:1 — an invisible button. Shopify Polaris (1.35:1) and Tailwind UI (2.84:1) fail the same way. Meanwhile Ant Design and Chakra UI, the two systems that fail the label check on white, are the two best boundary performers on dark (4.35:1 and 4.43:1).
The practical consequence: a dark-surface theme cannot be derived by reusing light-mode fills. Any system whose primary fill is near-black needs a separate dark-mode fill token, not an opacity or filter tweak. See WCAG Contrast Checker for Dark Mode for the full token pairing.
Tinted surfaces quietly shave the margin. Moving from white to a #EFF6FF alert banner costs every system roughly 8–9% of its boundary ratio. Nothing in the table crosses from pass to fail on that shift alone, but the systems already sitting near the line have less room than a white-background test suggests. If your product places buttons inside colored callouts, measure against the callout, not the page.
Focus rings are surface-dependent, and the popular default fails:
| Ring color | White | Gray card | Blue banner | Dark base | Verdict |
|---|---|---|---|---|---|
| #93C5FD (blue-300) | 1.80:1 | 1.72:1 | 1.66:1 | 9.90:1 | Fails all 3 light surfaces |
| #2563EB (blue-600) | 5.17:1 | 4.94:1 | 4.75:1 | 2.31:1 | Light only |
| #1D4ED8 (blue-700) | 6.70:1 | 6.41:1 | 6.16:1 | 2.66:1 | Light only |
| #0A4FB3 (deep blue) | 7.55:1 | 7.21:1 | 6.94:1 | 2.37:1 | Light only |
| #FFFFFF (white ring) | 1.00:1 | 1.05:1 | 1.09:1 | 17.85:1 | Dark only |
A light-blue ring like #93C5FD is one of the most widely copied focus styles in tutorials and it reaches only 1.80:1 on white — barely over half the 3:1 requirement. It is genuinely excellent on dark (9.90:1), which is exactly why it survives review: someone tests it in dark mode and ships it everywhere. No single ring color passes on both light and dark surfaces, so the focus ring must be a themed token with two values.
Disabled states: the exemption that gets misused.
| Label | Fill | Ratio | Readable |
|---|---|---|---|
| #9CA3AF | #BFDBFE | 1.79:1 | No |
| #6B7280 | #BFDBFE | 3.40:1 | Marginal |
| #9CA3AF | #E2E8F0 | 2.06:1 | No |
| #6B7280 | #E2E8F0 | 3.92:1 | Marginal |
| #6B7280 | #F1F5F9 | 4.41:1 | Nearly AA |
| #475569 | #E2E8F0 | 6.15:1 | Yes |
SC 1.4.3 exempts inactive controls, so a faint disabled button is technically conformant. That exemption is about the control being unavailable — it is not permission to hide information. The #9CA3AF-on-#BFDBFE pairing at 1.79:1 appears in a large share of the systems I audited, and when the disabled label is the only thing explaining why a user cannot submit a form, the exemption stops protecting you. Using #475569 costs nothing and reaches 6.15:1, so the state still reads as disabled through fill lightness while the text stays legible.
How to use this table: find your framework's row, check the surfaces your product actually uses, and fix only the failing cells. Most teams need one change — a dark-mode fill token or a themed focus ring — not a redesign. Verify each replacement in the Contrast Checker, and see Color Accessibility Guidelines for how these criteria fit the wider ruleset.
/**
* Button Accessibility Auditor
* Tests label, boundary, and focus ring contrast for all button states.
* Run in browser console or Node.js.
*/
function sRGBtoLinear(c: number): number {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
function relativeLuminance(hex: string): number {
const rgb = hex.replace('#', '').match(/.{2}/g)!.map(h => parseInt(h, 16));
return 0.2126 * sRGBtoLinear(rgb[0]) + 0.7152 * sRGBtoLinear(rgb[1]) + 0.0722 * sRGBtoLinear(rgb[2]);
}
function contrastRatio(hex1: string, hex2: string): number {
const l1 = relativeLuminance(hex1);
const l2 = relativeLuminance(hex2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
interface ButtonState {
name: string;
fill: string;
label: string;
surface: string;
ring?: string;
}
interface AuditResult {
state: string;
labelRatio: string;
labelPass: boolean;
boundaryRatio: string;
boundaryPass: boolean;
ringRatio: string | null;
ringPass: boolean | null;
overall: 'PASS' | 'FAIL';
}
function auditButton(states: ButtonState[]): AuditResult[] {
return states.map(s => {
const labelR = contrastRatio(s.label, s.fill);
const boundaryR = contrastRatio(s.fill, s.surface);
const ringR = s.ring ? contrastRatio(s.ring, s.surface) : null;
const labelPass = labelR >= 4.5;
const boundaryPass = boundaryR >= 3.0;
const ringPass = ringR !== null ? ringR >= 3.0 : null;
return {
state: s.name,
labelRatio: labelR.toFixed(2) + ':1',
labelPass,
boundaryRatio: boundaryR.toFixed(2) + ':1',
boundaryPass,
ringRatio: ringR ? ringR.toFixed(2) + ':1' : null,
ringPass,
overall: labelPass && boundaryPass && (ringPass !== false) ? 'PASS' : 'FAIL',
};
});
}
// Example: audit a primary button through all states
// Note the ring color: #93C5FD (blue-300) is a common default and only reaches
// 1.80:1 against white, so it fails SC 2.4.13. Rings must be dark on light surfaces.
const primaryButton: ButtonState[] = [
{ name: 'default', fill: '#2563EB', label: '#FFFFFF', surface: '#FFFFFF', ring: '#1D4ED8' },
{ name: 'hover', fill: '#1D4ED8', label: '#FFFFFF', surface: '#FFFFFF', ring: '#1E3A8A' },
{ name: 'active', fill: '#1E40AF', label: '#FFFFFF', surface: '#FFFFFF' },
{ name: 'focus', fill: '#2563EB', label: '#FFFFFF', surface: '#FFFFFF', ring: '#1D4ED8' },
{ name: 'disabled', fill: '#E2E8F0', label: '#475569', surface: '#FFFFFF' },
];
console.table(auditButton(primaryButton));
/* Expected output (verified August 2026):
default label 5.17:1 PASS | boundary 5.17:1 PASS | ring 6.70:1 PASS
hover label 6.70:1 PASS | boundary 6.70:1 PASS | ring 10.36:1 PASS
active label 8.72:1 PASS | boundary 8.72:1 PASS | ring —
focus label 5.17:1 PASS | boundary 5.17:1 PASS | ring 6.70:1 PASS
disabled label 6.15:1 PASS | boundary 1.23:1 (expected — see note)
Disabled boundary intentionally fails 3:1. SC 1.4.11 exempts inactive
controls, so a low-contrast disabled fill is allowed. What is NOT allowed
is using that faint state to carry information the user needs. The common
mistake is #9CA3AF on #BFDBFE, which is 1.79:1 and unreadable for everyone. */复制粘贴到项目即可使用。
Brand button audit — measured label-on-fill ratios (Q1 2026, re-verified August 2026):
Every ratio below is the button label against the button fill, computed from the published hex values with the WCAG 2.2 relative luminance formula. You can reproduce any row by pasting the two hex codes into the Contrast Checker.
| Design System | Primary Fill | Label | Label vs Fill | AA normal text (4.5:1) |
|---|---|---|---|---|
| Vercel | #000000 | #FFFFFF | 21.00:1 | Pass (AAA) |
| Shopify Polaris | #303030 | #FFFFFF | 13.20:1 | Pass (AAA) |
| Tailwind UI | #4F46E5 | #FFFFFF | 6.29:1 | Pass (AA) |
| Radix Themes | #3E63DD | #FFFFFF | 5.21:1 | Pass (AA) |
| Stripe | #635BFF | #FFFFFF | 4.70:1 | Pass (AA) |
| Linear | #5E6AD2 | #FFFFFF | 4.70:1 | Pass (AA) |
| GitHub Primer | #1F883D | #FFFFFF | 4.52:1 | Pass (AA, 0.02 margin) |
| Bootstrap 5.3 | #0D6EFD | #FFFFFF | 4.50:1 | Pass (AA, zero margin) |
| Ant Design 5.x | #1677FF | #FFFFFF | 4.10:1 | Fail |
| Chakra UI 2.x | #3182CE | #FFFFFF | 4.03:1 | Fail |
Read the margins, not just the verdict. GitHub Primer clears 4.5:1 by 0.02 and Bootstrap lands exactly on it. Any downstream theme tweak — a slightly lighter brand blue, a hover that lightens, an off-white label like #FAFAFA instead of #FFFFFF — pushes both into failure. Treat a sub-0.2 margin as a failure waiting to ship.
Hover direction is the actual predictor of failure. Measured against a white label:
| System | Stock hover fill | Label vs hover fill | Direction | Result |
|---|---|---|---|---|
| Bootstrap 5.3 | #0B5ED7 | 5.84:1 | Darkens | Improves to AA |
| Ant Design 5.x | #4096FF | 2.99:1 | Lightens | Drops well below AA |
Same category of component, opposite outcome, and the only variable is direction. Bootstrap darkens on hover and its label ratio climbs from 4.50:1 to 5.84:1. Ant Design lightens and the label collapses from 4.10:1 to 2.99:1 — worse than the muted-gray-text problem most audits open with. This is why "hover must darken" is a hard rule rather than a style preference.
Key patterns from passing systems:
Stripe's approach: Never lighten a fill on hover. Hover darkens by ~10% lightness. Focus uses a 2px ring in a separate contrasting color. Disabled states are removed from the DOM when the action cannot be taken.
Shopify Polaris CI enforcement: Their system defines button tokens at three levels — fill, on-fill (label), and ring — and enforces minimum ratios in CI. Every PR touching button components runs automated contrast checks against all six states (default, hover, active, focus, disabled, loading).
The 3-check rule for every button component:
| Check | What to measure | Target | WCAG criterion |
|---|---|---|---|
| 1. Label readability | Label color vs fill color | ≥4.5:1 (≥3:1 large) | SC 1.4.3 |
| 2. Component boundary | Fill color vs adjacent surface | ≥3:1 | SC 1.4.11 |
| 3. Focus indicator | Ring vs adjacent surface | ≥3:1, ≥2px perimeter | SC 2.4.13 |
Common fail scenarios from the 60-library audit:
Quick-fix token overrides for popular frameworks that fail (copy-paste ready, August 2026):
Two of the most-used open-source component libraries ship a primary button that fails WCAG AA on the default label, and a third passes by a margin thin enough to break on any theme tweak. Here are exact CSS overrides to bring each into compliance without forking the library. Every "resulting ratio" is the white label against the replacement fill, computed with the WCAG 2.2 formula:
| Framework | Problem | Original token | Fixed token | Resulting ratio | Override CSS |
|---|---|---|---|---|---|
| Ant Design 5.x | Primary button default label fails (4.10:1) | --ant-color-primary: #1677FF | --ant-color-primary: #0958D9 | 6.16:1 | :root { --ant-color-primary: #0958D9; } |
| Ant Design 5.x | Hover lightens to #4096FF, label drops to 2.99:1 | --ant-color-primary-hover: #4096FF | --ant-color-primary-hover: #1668DC | 5.19:1 | :root { --ant-color-primary-hover: #1668DC; } |
| Ant Design 5.x | Focus ring at 0.1 alpha is effectively invisible | box-shadow: 0 0 0 2px rgba(22,119,255,0.1) | outline: 2px solid #0958D9; offset: 2px | 6.16:1 vs white | .ant-btn:focus-visible { outline: 2px solid #0958D9; outline-offset: 2px; box-shadow: none; } |
| Bootstrap 5.3 | Default label sits exactly on 4.50:1 with zero margin | --bs-btn-bg: #0D6EFD | --bs-btn-bg: #0A58CA | 6.44:1 | .btn-primary { --bs-btn-bg: #0A58CA; --bs-btn-border-color: #0A58CA; } |
| Bootstrap 5.3 | Focus ring thin and low contrast against white | --bs-btn-focus-shadow-rgb: 49,132,253 | outline: 2px solid #0A4FB3; offset: 2px | 7.55:1 vs white | .btn:focus-visible { outline: 2px solid #0A4FB3; outline-offset: 2px; box-shadow: none; } |
| Chakra UI 2.x | Default #3182CE label fails (4.03:1) | colorScheme blue.500: #3182CE | blue.600: #2B6CB0 | 5.42:1 | Set theme.colors.blue.500 = '#2B6CB0' in extendTheme() |
| Chakra UI 2.x | Focus ring opacity too low to read on white | boxShadow: 0 0 0 3px rgba(66,153,225,0.6) | ring: 2px solid #2B6CB0 | 5.42:1 vs white | Add shadows: { outline: '0 0 0 2px #2B6CB0' } to theme |
Note on Bootstrap's hover: its stock hover fill #0B5ED7 darkens and takes the label to 5.84:1, so the hover itself is not the defect. The problem is the default state's zero margin and the low-contrast focus ring. Ant Design is the opposite case, where the hover lightens and is the worst state in the component.
Implementation priority: Fix any state measuring below 4.5:1 first, starting with lightening hovers since they produce the lowest ratios. Then replace opacity-based focus rings with solid outlines. Then raise default fills that pass by less than a 0.2 margin.
Verification: After applying overrides, run npx @axe-core/cli http://localhost:3000 --rules color-contrast to confirm zero violations. Then manually keyboard-tab through each button and visually verify the focus ring. Use the Contrast Checker to spot-check any custom variants your project adds on top of the library defaults.
For the full token system approach to preventing these regressions, see Accessible Color Token System. For dark mode button states, see WCAG Contrast Checker for Dark Mode.
用这些免费工具实操你学到的知识: