Color Accessibility Input & Badge

Color Accessibility Input & Badge

Color Accessibility Input & Badge

Task

Code

sector

HR/Talent

Year

2022

Tech Stack

HTML logo
CSS logo
Sass Logo
Angular logo
Typescript logo

Project Overview

Why I Built It

I was given an open-ended assignment: create a tool that detects a hex color value and determines if it’s accessible against a white background.

What I Delivered

Instead of a simple checker, I built a reusable Angular component and directive that provides real-time, keystroke-based contrast validation using WCAG AA standards. The experience incorporates UX best practices such as:

  • Progressive disclosure — Key results are shown upfront, with additional details revealed on hover.

  • Intelligent behavior — Automatically detects 3-digit vs 6-digit hex values and recognizes color codes even when surrounded by other text.

  • Clear error handling — Immediate feedback helps users input valid values with confidence.

What This Demonstrates

I didn’t just complete a task—I turned it into a thoughtful, scalable tool that reflects my creativity, attention to user experience, and commitment to building meaningful UI solutions.

Solutions

Create Angular Directive

  • Computes relative luminance for two colors

  • Derives the contrast ratio

  • Evaluates the ratio against WCAG 2.2 Level AA (4.5:1 normal text, 3:1 large)

  • Shows instant, real-time pass/fail feedback to the user

import { Directive, ElementRef, OnInit, Input, Renderer2, Output, EventEmitter, OnChanges } from '@angular/core';
import { TransportInputValueService } from '../services/transport-input.data.service';
import { TransportObjectDataService } from '../services/transport-object.data.service';
 
@Directive({
  selector: '[colorContrast]'
})
 
 
export class ColorContrastDirective implements OnInit, OnChanges {
 
  @Input() userInputValue: string;
  @Input() extractedHexValue: string;
  @Input() basicColorContrast: boolean = false;
  anyHexPattern: any = /#([A-Fa-f0-9]{0,6})/;
  invalidHex: boolean;
  ratio: number;
  @Input() bgColorFromComponent: string;
  @Input() custom: boolean = false;
  formattedRatio: string;
  @Output() colorContrastDetector = new EventEmitter();
  @Output() colorSwitchDetector = new EventEmitter();
  @Input() blackWhiteSwitch: boolean = false;
  aaNormalText: string;
  aaLargeText: string;
  aaaNormalText: string;
  aaaLargeText: string;
  basicColorContrastObject: any = {};
  blackWhiteColorObject: any = {};
  blackRatio: number;
  whiteRatio: number;
  blackPassAANormal: string;
  blackPassAALarge: string;
  whitePassAANormal: string;
  whitePassAALarge: string;
  blackColor = '#000000';
  whiteColor = '#FFFFFF';
 
 
  constructor(private _el: ElementRef, private renderer: Renderer2, private transportInputData: TransportInputValueService, private transportObjectData: TransportObjectDataService) { 
 
  }
 
 
  ngOnInit() {
  }
 
 
 
  ngOnChanges() {
    if (this.basicColorContrast) {
      if (this.anyHexPattern.test(this.userInputValue)) {
        this.basicColorContrastRatioCalculation();
        this.getColorContrastInfo();
        this.setSharedObject(this.makeOpacityObject())
        console.table(this.getColorContrastInfo())
      }
    }
 
    if (this.blackWhiteSwitch) {
      this.ratioCalculationForColorSwitchTest(this.blackColor, this.whiteColor)
      this.getBlackWhiteSwitchInfo();
    }
 
  }
 
 
  /* Math for getting color contrast ratio*/
 
  //Get the luminance of each color hex value
  luminance(r, g, b) {
    let [lumR, lumG, lumB] = [r, g, b].map(component => {
      let proportion = component / 255;
 
      return proportion <= 0.03928
        ? proportion / 12.92
        : Math.pow((proportion + 0.055) / 1.055, 2.4);
    });
 
    return 0.2126 * lumR + 0.7152 * lumG + 0.0722 * lumB;
  }
 
 
 
  contrastRatio(luminance1, luminance2) {
    let lighterLum = Math.max(luminance1, luminance2);
    let darkerLum = Math.min(luminance1, luminance2);
    return (lighterLum + 0.05) / (darkerLum + 0.05);
  }
 
 
  //Returns the ratio of the two colors
  checkContrast(color1, color2) {
    let [luminance1, luminance2] = [color1, color2].map(color => {
      /* Remove the leading hash sign if it exists */
      color = color.startsWith("#") ? color.slice(1) : color;
 
 
      //If hex value is 3 digits, then the value is calculated by doubling each digit
      //I.E. #F6C equal to FF + 66 + CC 
      if (color.length === 3) {
        color = color.split('').map(function (hex) {
          return hex + hex;
        }).join('');
      }
      let r = parseInt(color.slice(0, 2), 16);
      let g = parseInt(color.slice(2, 4), 16);
      let b = parseInt(color.slice(4, 6), 16);
      return this.luminance(r, g, b);
    });
    return this.contrastRatio(luminance1, luminance2);
  }
 
  //Round and format ratio
  formatRatio(ratio) {
    let ratioAsFloat = ratio.toFixed(2);
    return `${ratioAsFloat}:1`
  }
 
 
  //Detects any 3 or 6 digit value beginning with a "#" and returns that value 
  detectHexValue(originalInputValue) {
    let trackHex = originalInputValue.match(this.anyHexPattern)[0];
    return trackHex
  }
 
  //Returns whether the there are 5 digits in a hex value 
  fiveDigitHexValue(originalInputValue) {
    let hexWithoutHash = originalInputValue.match(this.anyHexPattern)[1];
    return hexWithoutHash.length === 5 ? true : false;
  }
 
  //Automatically Adds a maxlength of 7 to an input if the first character of an input is a '#'.
  addMaxlength() {
    let hash = '#';
    if (this.userInputValue.indexOf(hash) === 0) {
      this._el.nativeElement.setAttribute('maxlength', '7');
    }
  }
 
  /*Basic Color Contrast Ratio Calculation
  Will run two colors through the color contrast calulation to produce a ratio.
  this function returns an object that includes the input value, extracted hex value, whether the input value is invalid, formatted ratio, background color,
  and whether the ratio passes AA Normal or Large. The object is what is emitted to your component*/
  basicColorContrastRatioCalculation() {
    this.addMaxlength();
    this.extractedHexValue = this.detectHexValue(this.userInputValue);
    this.ratio = this.checkContrast(this.extractedHexValue, this.bgColorFromComponent);
    this.formattedRatio = this.formatRatio(this.ratio);
 
 
    /*Uncomment this if you want to include WCAG Level AAA*/
    //this.aaaNormalText = ratio > 7 ? 'PASS': 'FAIL';
    //this.aaaLargeText = ratio > 4.5 ? 'PASS': 'FAIL';
 
    const object = {
      basicColorContrast: this.basicColorContrast,
      inputValue: this.userInputValue, //user entered value
      extractedHexValue: this.extractedHexValue, //takes the input value and only extract the hex value
      invalidHex: this.fiveDigitHexValue(this.userInputValue) || this.formattedRatio == 'NaN:1', //Detects if the input value is 5 digits OR not a number
      formattedRatio: this.formattedRatio, // Produces the ratio in a formatted fashion (i.e. "21.00:1")
      backgroundColor: this.bgColorFromComponent, //Background Color From the Component 
      aaNormalText: this.ratio > 4.5 ? 'PASS' : 'FAIL', //Computes the ratio of two colors against the Level AA normal text standard of 4.5
      aaLargeText: this.ratio > 3 ? 'PASS' : 'FAIL', //Computes the ratio of two colors against the Level AA large text standard of 4.5
 
    }
 
 
    return this.basicColorContrastObject = object;
  }
 
 
  /* Event Emitter that will take the object on line 135 and send it to the component where the directive is called*/
  getColorContrastInfo() {
    this.colorContrastDetector.emit(this.basicColorContrastObject)
  }
 
  /* This function runs both black and white against the hex value in the input field. It is designed
  to default to black. This means that black will be tested first for PASS or FAIL. If it passes, black will be the selected color regardless if
  it also passes for white. White will only be selected if black fails.
  This function returns an object that will be emitted via the Event Emitter*/
  ratioCalculationForColorSwitchTest(blackColor, whiteColor) {
    this.addMaxlength();
    this.blackRatio = this.checkContrast(this.userInputValue, blackColor);
    this.whiteRatio = this.checkContrast(this.userInputValue, whiteColor);
    this.blackPassAANormal = (this.blackRatio > 4.5) ? 'PASS' : 'FAIL';
    this.whitePassAANormal = (this.whiteRatio > 4.5) ? 'PASS' : 'FAIL';
    this.blackPassAALarge = (this.blackRatio > 3) ? 'PASS' : 'FAIL';
    this.whitePassAALarge = (this.whiteRatio > 3) ? 'PASS' : 'FAIL';
 
    const object = {
      blackWhiteSwitch: this.blackWhiteSwitch,
      inputValue: this.userInputValue, //user entered value
      extractedHexValue: this.detectHexValue(this.userInputValue), //extracted hex value from user entered text
      formattedRatio: (this.blackPassAANormal === 'PASS') ? this.formatRatio(this.blackRatio) : (this.whitePassAANormal === 'PASS') ? this.formatRatio(this.whiteRatio) : this.formatRatio(this.blackRatio), // Produces the ratio in a formatted fashion (i.e. "21.00:1")
      aaNormalText: (this.blackPassAANormal === 'PASS') ? this.blackPassAANormal : (this.whitePassAANormal === 'PASS') ? this.whitePassAANormal : this.blackPassAANormal,
      aaLargeText: (this.blackPassAALarge === 'PASS') ? this.blackPassAALarge : (this.whitePassAALarge === 'PASS') ? this.whitePassAALarge : this.blackPassAALarge,
      ratioAgainstBlack: this.formatRatio(this.blackRatio),  //runs the ratio between the user entered value and the color black
      ratioAgainstWhite: this.formatRatio(this.whiteRatio), //runs the ratio between the user entered value and the color white
      aaNormalTextForBlack: this.blackPassAANormal, //reports if the ratio against black has passed or not
      aaNormalTextForWhite: this.whitePassAANormal, //reports if the ratio against white has failed or not
      passForBlackAndWhite: (this.blackPassAANormal === 'PASS' && this.whitePassAANormal === 'PASS') ? true : false, //reports if the user entered value passes both black and white
      contrastingTextColor: (this.blackPassAANormal === 'PASS') ? this.blackColor : (this.whitePassAANormal === 'PASS') ? this.whiteColor : this.blackColor, //reports the contrasting text color for the user entered value, assumes that black is the default, so the only way white appears is if black fails
 
    }
    return this.blackWhiteColorObject = object
 
  }
 
 
  /* Event Emitter that will take the object on line 165 and send it to the component where the directive is called*/
  getBlackWhiteSwitchInfo() {
    this.colorSwitchDetector.emit(this.blackWhiteColorObject);
  }
 
  //Creates a smaller object for the <app-color-input> opacity inputs
  makeOpacityObject() {
    const object = {
      extractedHexValue: this.basicColorContrastRatioCalculation().extractedHexValue, //takes the input value and only extract the hex value
      invalidHex: this.basicColorContrastRatioCalculation().invalidHex, //Detects if the input value is 5 digits OR not a number
    }
 
    return object
  }
 
  //Sends object to transport object service
  setSharedObject(object) {
      this.transportObjectData.storePassedObject(object);
  }
 
 
 
 
}

Apply Directive to Input

Attach the directive to an input, then show the contrast ratio:

  • Apply the directive on the color input field

  • Add a small circular badge at the input’s top-right corner to display the live contrast ratio.

<!-- Input HTML -->
<mat-form-field appearance="outline">
  <mat-label>{{ inputTitle }}</mat-label>
  <input
    matInput id="ColorInput" type="text" formControlName="colorFormControl"
    colorContrast [pattern]="colorContrastPattern" [userInputValue]="colorFormControl.value"
    [basicColorContrast]="colorFormControl.value" [bgColorFromComponent]="backgroundColor"
    (colorContrastDetector)="colorInputObjectEE($event)" (focus)="addPrefix()" (ngModelChange)="colorChange()"
  />
  <mat-hint *ngIf="!colorFormControl.errors?.['pattern'] && !disabled">e.g: #6f389a</mat-hint>
  <mat-error *ngIf="colorFormControl.errors?.['pattern']">Hex values must begin with a # and have 3 or 6 digits.</mat-error>

  <!-- ACCESSIBILITY BADGE -->
  <div #label *ngIf="!colorInputObject.invalidHex && addBadge">
    <popup-button
      [popup]="colorContrastBandCContent" [canCopy]="false" [label]="label"
      [customPopupButtonType]="'inputBadgeRight'" [customOverlayClass]="'w-75'">
      <input-badge-button [colorContrastObject]="colorInputObject"></input-badge-button>
    </popup-button>
  </div>
</mat-form-field>

<!-- Badge HTML -->
<div class="input-badge-button">
  <span class="mx-2">{{ colorContrastObject.formattedRatio }}</span>
  <i class="fas fa-check-circle text-success" *ngIf="completePass() && !partialFailure()"></i>
  <i class="fas fa-exclamation-circle text-danger" *ngIf="partialFailure()"></i>
</div>

Adding More Detail on Hover

Sometimes users need more info. To help with that, I added a hover tooltip that shows:

  • Typed color

  • Background color

  • Contrast ratio

  • Overall pass/fail status

  • WCAG Level AA status for normal text

  • WCAG Level AA status for large text

  • A short explanation of what makes each pass or fail

This lets users see extra details only when they need them, without making the screen feel crowded.

<!-- Input HTML -->
<mat-form-field appearance="outline">
  <mat-label>{{ inputTitle }}</mat-label>
  <input
    matInput id="ColorInput" type="text" formControlName="colorFormControl"
    colorContrast [pattern]="colorContrastPattern" [userInputValue]="colorFormControl.value"
    [basicColorContrast]="colorFormControl.value" [bgColorFromComponent]="backgroundColor"
    (colorContrastDetector)="colorInputObjectEE($event)" (focus)="addPrefix()" (ngModelChange)="colorChange()"
  />
  <mat-hint *ngIf="!colorFormControl.errors?.['pattern'] && !disabled">e.g: #6f389a</mat-hint>
  <mat-error *ngIf="colorFormControl.errors?.['pattern']">Hex values must begin with a # and have 3 or 6 digits.</mat-error>

  <!-- ACCESSIBILITY BADGE -->
  <div #label *ngIf="!colorInputObject.invalidHex && addBadge">
    <popup-button
      [popup]="colorContrastBandCContent" [canCopy]="false" [label]="label"
      [customPopupButtonType]="'inputBadgeRight'" [customOverlayClass]="'w-75'"
    >
      <input-badge-button [colorContrastObject]="colorInputObject"></input-badge-button>
    </popup-button>
  </div>
</mat-form-field>

Next Step: Auto-Choosing Text Color

Next, I made the tool smarter by checking if black or white text is easier to read on the chosen color. The directive compares both and picks the one with better contrast.

Result: The component now helps users choose the best text color automatically.

/* This function runs both black and white against the hex value in the input field. 
It is designed to default to black. This means that black will be tested first for PASS or FAIL. 
If it passes, black will be the selected color regardless if it also passes for white. 
White will only be selected if black fails. This function returns an object that will be emitted via the Event Emitter*/
ratioCalculationForColorSwitchTest(blackColor, whiteColor) {
    this.addMaxlength();
    this.blackRatio = this.checkContrast(this.userInputValue, blackColor);
    this.whiteRatio = this.checkContrast(this.userInputValue, whiteColor);
    this.blackPassAANormal = (this.blackRatio > 4.5) ? 'PASS' : 'FAIL';
    this.whitePassAANormal = (this.whiteRatio > 4.5) ? 'PASS' : 'FAIL';
    this.blackPassAALarge = (this.blackRatio > 3) ? 'PASS' : 'FAIL';
    this.whitePassAALarge = (this.whiteRatio > 3) ? 'PASS' : 'FAIL';
 
    const object = {
      blackWhiteSwitch: this.blackWhiteSwitch,
      inputValue: this.userInputValue, //user entered value
      extractedHexValue: this.detectHexValue(this.userInputValue), //extracted hex value from user entered text
      formattedRatio: (this.blackPassAANormal === 'PASS') ? this.formatRatio(this.blackRatio) : (this.whitePassAANormal === 'PASS') ? this.formatRatio(this.whiteRatio) : this.formatRatio(this.blackRatio), // Produces the ratio in a formatted fashion (i.e. "21.00:1")
      aaNormalText: (this.blackPassAANormal === 'PASS') ? this.blackPassAANormal : (this.whitePassAANormal === 'PASS') ? this.whitePassAANormal : this.blackPassAANormal,
      aaLargeText: (this.blackPassAALarge === 'PASS') ? this.blackPassAALarge : (this.whitePassAALarge === 'PASS') ? this.whitePassAALarge : this.blackPassAALarge,
      ratioAgainstBlack: this.formatRatio(this.blackRatio),  //runs the ratio between the user entered value and the color black
      ratioAgainstWhite: this.formatRatio(this.whiteRatio), //runs the ratio between the user entered value and the color white
      aaNormalTextForBlack: this.blackPassAANormal, //reports if the ratio against black has passed or not
      aaNormalTextForWhite: this.whitePassAANormal, //reports if the ratio against white has failed or not
      passForBlackAndWhite: (this.blackPassAANormal === 'PASS' && this.whitePassAANormal === 'PASS') ? true : false, //reports if the user entered value passes both black and white
      contrastingTextColor: (this.blackPassAANormal === 'PASS') ? this.blackColor : (this.whitePassAANormal === 'PASS') ? this.whiteColor : this.blackColor, //reports the contrasting text color for the user entered value, assumes that black is the default, so the only way white appears is if black fails
 
    }
    return this.blackWhiteColorObject = object
 
  }

Last Upgrade: Opacity Support

For the next enhancement, I added support for opacity. I built a way for two color inputs to work together—one sets the base color, and the other adjusts its opacity.

Result: Users can now see how transparency affects contrast in real time.

<div class="row my-5">
    <div class="col-lg-6 mx-auto">
        <div class="mb-3">
            <!-- Base Color -->
            <app-color-input 
             [disabled]="false" 
             [slimColorInput]="true" 
             [smallThumbnail]="true" 
             [inputTitle]="'Base Color'" 
             [backgroundColor]="'#ffffff'" 
             [colorValue]="opacityBaseColor">
          </app-color-input>
        </div>
        <!-- Opacity Controller -->
        <app-color-input
            [receivedInputValueService]="true"
            [disabled]="false"
            [addPercent]="true"
            [opacityColorInput]="true"
            [smallThumbnail]="true"
            [inputTitle]="'Opacity'"
            [baseColor]="opacityBaseColor"
            [colorValue]="'63'"
        ></app-color-input>
    </div>
</div>

Built by Afftene.

Built by Afftene.

Built by Afftene.