Building WCAG-Compliant Flutter Components: Automating Contrast Calculations

ZoeyZoey
4 min read

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:

  1. It's fast: The math is just basic arithmetic, no expensive operations

  2. It's necessary: The alternative is manually checking every color combination

  3. 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:

0
Subscribe to my newsletter

Read articles from Zoey directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Zoey
Zoey