DEV Community

Zoe
Zoe

Posted on

3 1

Building WCAG-Compliant Flutter Components: Automating Contrast Calculations

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)),
)
Enter fullscreen mode Exit fullscreen mode

The WCAG Algorithm

WCAG defines contrast ratio as the luminance difference between two colors. The formula is:

contrast = (lighter + 0.05) / (darker + 0.05)
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  );
}
Enter fullscreen mode Exit fullscreen mode

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: () {},
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Have you implemented accessibility features in your component libraries? I'm curious about other approaches to this problem!


🔗 Links:

Warp.dev image

Warp is the #1 coding agent.

Warp outperforms every other coding agent on the market, and gives you full control over which model you use. Get started now for free, or upgrade and unlock 2.5x AI credits on Warp's paid plans.

Download Warp

Top comments (1)

Collapse
 
wesley_melo profile image
Wesley Melo

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 💡.

Feature flag article image

Create a feature flag in your IDE in 5 minutes with LaunchDarkly’s MCP server 🏁

How to create, evaluate, and modify flags from within your IDE or AI client using natural language with LaunchDarkly's new MCP server. Follow along with this tutorial for step by step instructions.

Read full post

👋 Kindness is contagious

Explore this insightful piece, celebrated by the caring DEV Community. Programmers from all walks of life are invited to contribute and expand our shared wisdom.

A simple "thank you" can make someone’s day—leave your kudos in the comments below!

On DEV, spreading knowledge paves the way and fortifies our camaraderie. Found this helpful? A brief note of appreciation to the author truly matters.

Let’s Go!