Task
Code
sector
HR/Talent
Year
2022
Tech Stack



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>


