While building Hux UI, I kept running into the same accessibility problem: buttons with custom colors that had terrible text contrast. You'd pick a nice brand color, slap white text on it, and suddenly your button was unreadable.
Instead of manually checking contrast ratios every time, I decided to build a system that handles this automatically. Here's how I implemented WCAG-compliant contrast calculations in Flutter.
The Problem with Manual Contrast Checking
Most design systems punt on this problem. They give you preset color combinations that work, but the moment you use a custom brand color, you're on your own.
// This could be completely unreadable
Container(
color: myBrandColor, // Could be any color
child: Text('Button Text', style: TextStyle(color: Colors.white)),
)
The WCAG Algorithm
WCAG defines contrast ratio as the luminance difference between two colors. The formula is:
contrast = (lighter + 0.05) / (darker + 0.05)
Where luminance is calculated using the sRGB color space with gamma correction. For normal text, you need at least 4.5:1 contrast ratio for AA compliance.
Implementation in Flutter
Here's how I implemented the contrast calculation system:
/// Calculates the relative luminance of a color according to WCAG guidelines
double _getRelativeLuminance(Color color) {
// Convert RGB values to 0-1 range
final r = color.r / 255.0;
final g = color.g / 255.0;
final b = color.b / 255.0;
// Apply gamma correction
final rLinear = r <= 0.03928 ? r / 12.92 : pow((r + 0.055) / 1.055, 2.4);
final gLinear = g <= 0.03928 ? g / 12.92 : pow((g + 0.055) / 1.055, 2.4);
final bLinear = b <= 0.03928 ? b / 12.92 : pow((b + 0.055) / 1.055, 2.4);
// Calculate relative luminance using ITU-R BT.709 coefficients
return 0.2126 * rLinear + 0.7152 * gLinear + 0.0722 * bLinear;
}
/// Calculates contrast ratio between two colors
double _calculateContrastRatio(Color color1, Color color2) {
final luminance1 = _getRelativeLuminance(color1);
final luminance2 = _getRelativeLuminance(color2);
final lighter = luminance1 > luminance2 ? luminance1 : luminance2;
final darker = luminance1 > luminance2 ? luminance2 : luminance1;
return (lighter + 0.05) / (darker + 0.05);
}
The tricky part is the gamma correction. sRGB colors aren't linear - they use a gamma curve to match how displays work. You have to convert to linear RGB before calculating luminance.
Automatic Text Color Selection
Now I can automatically pick the best text color:
Color _getContrastingTextColor(Color backgroundColor, BuildContext context) {
final whiteContrast = _calculateContrastRatio(
backgroundColor,
HuxTokens.textInvert(context)
);
final blackContrast = _calculateContrastRatio(
backgroundColor,
HuxTokens.textPrimary(context)
);
// Choose the color with better contrast
return whiteContrast > blackContrast
? HuxTokens.textInvert(context)
: HuxTokens.textPrimary(context);
}
This runs every time a button renders, automatically ensuring WCAG AA compliance regardless of the background color.
Semantic Design Tokens
The other technical challenge was building a theme system that works in both light and dark mode. Instead of hardcoding colors, I built semantic tokens:
class HuxTokens {
/// Primary text color that adapts to light/dark theme
static Color textPrimary(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return isDark ? HuxColors.white : HuxColors.black;
}
/// Surface color for elevated components
static Color surfaceElevated(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return isDark ? HuxColors.black70 : HuxColors.white;
}
}
Every component uses these tokens instead of raw colors. When you switch themes, everything adapts automatically without any component-level changes.
Performance Considerations
You might worry about calculating contrast ratios on every render, but:
- It's fast: The math is just basic arithmetic, no expensive operations
- It's necessary: The alternative is manually checking every color combination
- It's cached: Flutter's widget rebuilding is smart about when this actually runs
Real-World Usage
In the actual component, it looks like this:
ButtonStyle _getButtonStyle(BuildContext context) {
final effectivePrimaryColor = primaryColor ?? HuxTokens.primary(context);
final foregroundColor = _getContrastingTextColor(effectivePrimaryColor, context);
return ButtonStyle(
backgroundColor: WidgetStateProperty.all(effectivePrimaryColor),
foregroundColor: WidgetStateProperty.all(foregroundColor),
// ... other styles
);
}
No matter what color you pass in, the text will be readable.
Testing the Algorithm
I tested this against the WebAIM contrast checker to make sure my implementation matches the WCAG spec. The results are identical.
You can test it yourself:
HuxButton(
primaryColor: Color(0xFF6366F1), // Any color
child: Text('Always Readable'),
onPressed: () {},
)
Why This Matters
Accessibility shouldn't be an afterthought. By building it into the component layer, developers get WCAG compliance without having to think about it.
The contrast calculation runs automatically, the semantic tokens handle theme switching, and your app stays accessible regardless of design changes.
The Trade-offs
Pros:
- Zero cognitive overhead for developers
- Guaranteed WCAG AA compliance
- Works with any color combination
- Adapts to theme changes automatically
Cons:
- Slight computation cost (negligible in practice)
- Less control over exact text colors (though you probably don't want that control)
Try It Out
The system is part of Hux UI, but the algorithms are standalone. You could implement this in any Flutter project:
flutter pub add hux
Have you implemented accessibility features in your component libraries? I'm curious about other approaches to this problem!
🔗 Links:
Top comments (1)
Olá 👋.
Obrigado por compartilhar algo tão valioso, atualmente estou a procura de uma parceria para construir um app que traz um conceito novo no mercado dos smartphones, gostaria de compartilhar minha ideia com vc, apresentar meu pitch deck e contratar seus serviços para me ajudar a concluir a fase inicial, pode parecer meio maluco mas talvez essa seja uma ótima oportunidade para vc e eu estaria trabalhando com a pessoa certa pro dar vida a essa ideia 💡.