05Contrast Badge That Crosses a Pass Line From a Hex Value
The luminance of a typed hex value is computed to get contrast ratios against white and black backgrounds, and badge color and text switch based on whether it clears the pass threshold.
Best forDesign-system docs and accessibility-check tools
hexToRgbrelativeLuminancecontrastRatio

<main class="stage">
<div class="cc is-demo" id="cc">
<label class="cc__field">
<span class="cc__cap">hex value</span>
<input class="cc__input" id="ccHex" type="text" maxlength="7" value="#ff5b3c" aria-describedby="ccReadout">
</label>
<div class="cc__badge" aria-hidden="true">
<span class="cc__swatch" id="ccSwatch"></span>
<div class="cc__lum" id="ccLum">
<div class="cc__lumMark" id="ccLumMark"></div>
</div>
<span class="cc__status" id="ccStatus">Pass</span>
</div>
<p class="cc__readout" id="ccReadout" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(상대휘도·대비 공식)은 index.html 의 relativeLuminance()/contrastRatio() 에 있다. 여기는 무대·레이아웃만.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.cc {
display: flex;
flex-direction: column;
align-items: stretch;
gap: $sp-2;
width: min(240px, 84vw);
background: $subject-blue-deep;
border-radius: $r-card;
padding: $sp-4;
box-shadow: $shadow-raised;
}
.cc__field {
display: flex;
flex-direction: column;
gap: $sp-1;
}
.cc__cap {
color: $color;
font-size: 12px;
font-weight: 500;
}
.cc__input {
border: 0;
border-radius: $r-control;
padding: $sp-1 $sp-2;
background: $subject-cream;
color: $subject-ink;
font-weight: 600;
font-size: 14px;
text-align: center;
}
.cc__input:focus-visible {
outline: 2px solid $color;
outline-offset: 2px;
}
.cc__badge {
display: flex;
align-items: center;
gap: $sp-2;
}
.cc__swatch {
flex: 0 0 auto;
width: 22px;
height: 22px;
border-radius: $r-sm;
box-shadow: $shadow-press;
}
.cc__lum {
position: relative;
flex: 1 1 auto;
height: 8px;
border-radius: $r-pill;
background: linear-gradient(to right, $stage-ink, $subject-cream);
}
.cc__lumMark {
position: absolute;
top: 50%;
left: 0;
width: 10px;
height: 10px;
margin-top: -5px;
border-radius: 50%;
background: $subject-blue;
box-shadow: $shadow-press;
will-change: transform;
}
.cc__status {
flex: 0 0 auto;
border-radius: $r-pill;
padding: $sp-1 $sp-2;
font-size: 11px;
font-weight: 700;
color: $subject-cream;
background: $subject-blue-deep;
transition: background-color $duration $easing;
}
.cc__status.is-pass {
background: $subject-blue-deep;
}
.cc__status.is-fail {
background: $error;
}
.cc__readout {
margin: 0;
color: $color;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.cc { padding: $sp-3; gap: $sp-1; width: min(220px, 84vw); }
.cc__cap { font-size: 10px; }
.cc__input { font-size: 12px; }
.cc__swatch { width: 18px; height: 18px; }
.cc__status { font-size: 10px; padding: $sp-1; }
.cc__readout { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.cc__status { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cc {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: min(240px, 84vw);
background: #0a4fe8;
border-radius: 16px;
padding: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cc__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.cc__cap {
color: #fff7e6;
font-size: 12px;
font-weight: 500;
}
.cc__input {
border: 0;
border-radius: 12px;
padding: 4px 8px;
background: #fff7e6;
color: #17141a;
font-weight: 600;
font-size: 14px;
text-align: center;
}
.cc__input:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.cc__badge {
display: flex;
align-items: center;
gap: 8px;
}
.cc__swatch {
flex: 0 0 auto;
width: 22px;
height: 22px;
border-radius: 8px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.cc__lum {
position: relative;
flex: 1 1 auto;
height: 8px;
border-radius: 999px;
background: linear-gradient(to right, #17141a, #fff7e6);
}
.cc__lumMark {
position: absolute;
top: 50%;
left: 0;
width: 10px;
height: 10px;
margin-top: -5px;
border-radius: 50%;
background: #2f6df6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
will-change: transform;
}
.cc__status {
flex: 0 0 auto;
border-radius: 999px;
padding: 4px 8px;
font-size: 11px;
font-weight: 700;
color: #fff7e6;
background: #0a4fe8;
transition: background-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cc__status.is-pass {
background: #0a4fe8;
}
.cc__status.is-fail {
background: #c2361a;
}
.cc__readout {
margin: 0;
color: #fff7e6;
font-size: 12px;
font-weight: 500;
line-height: 1.3;
text-align: center;
}
@media (max-width: 340px) {
.cc {
padding: 12px;
gap: 4px;
width: min(220px, 84vw);
}
.cc__cap {
font-size: 10px;
}
.cc__input {
font-size: 12px;
}
.cc__swatch {
width: 18px;
height: 18px;
}
.cc__status {
font-size: 10px;
padding: 4px;
}
.cc__readout {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.cc__status {
transition: none;
}
}(function () {
var root = document.getElementById('cc');
var input = document.getElementById('ccHex');
var swatch = document.getElementById('ccSwatch');
var lumBox = document.getElementById('ccLum');
var lumMark = document.getElementById('ccLumMark');
var statusEl = document.getElementById('ccStatus');
var readout = document.getElementById('ccReadout');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isDemo = true;
function human() { if (isDemo) { root.classList.remove('is-demo'); isDemo = false; } }
var PASS_THRESHOLD = 4.5; // WCAG AA 본문 글자 At reference
function hexToRgb(hex) {
var m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return { r: 255, g: 91, b: 60 };
var n = parseInt(m[1], 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
// 계산 한 겹 — sRGB 감마 보정 상대휘도와 WCAG 대비 공식
function relativeLuminance(rgb) {
function chan(c) {
var v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
return 0.2126 * chan(rgb.r) + 0.7152 * chan(rgb.g) + 0.0722 * chan(rgb.b);
}
function contrastRatio(l1, l2) {
var hi = Math.max(l1, l2), lo = Math.min(l1, l2);
return (hi + 0.05) / (lo + 0.05);
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
var SENT_PREFIX = 'White-background contrast ', SENT_MID1 = ':1 · black-background contrast ', SENT_MID2 = ':1 — ', SENT_SUFFIX = ' is better.';
var targetX = 0, renderedX = 0;
var LERP_RATE = 0.17; // 변형 05: 낮추면 표시가 값 변화를 더 천천히 뒤따라간다
function applyValue(hex) {
var rgb = hexToRgb(hex);
var L = relativeLuminance(rgb);
var whiteContrast = contrastRatio(L, 1);
var blackContrast = contrastRatio(L, 0);
var best = Math.max(whiteContrast, blackContrast);
var bestName = whiteContrast >= blackContrast ? 'white background' : 'black background';
var pass = best >= PASS_THRESHOLD;
swatch.style.background = '#' + ('000000' + ((rgb.r << 16) | (rgb.g << 8) | rgb.b).toString(16)).slice(-6);
statusEl.textContent = pass ? 'Pass' : 'Fail';
statusEl.classList.toggle('is-pass', pass);
statusEl.classList.toggle('is-fail', !pass);
var trackW = lumBox.clientWidth - lumMark.offsetWidth;
targetX = clamp01(L) * trackW;
readout.textContent = SENT_PREFIX + whiteContrast.toFixed(1) + SENT_MID1 + blackContrast.toFixed(1) + SENT_MID2 + bestName + SENT_SUFFIX;
if (reduce) { renderedX = targetX; paint(); }
}
function paint() {
lumMark.style.transform = 'translateX(' + renderedX.toFixed(1) + 'px)';
}
input.addEventListener('input', function () { human(); applyValue(input.value); });
input.addEventListener('change', function () { human(); applyValue(input.value); });
var DEMO_HEXES = ['#ff5b3c', '#2f6df6', '#ffd23f', '#17141a', '#fff7e6', '#8e2287'];
var DRIVE_PERIOD_MS = 2000;
var driveStart = 0, lastIdx = -1;
function driveTick(now) {
if (!driveStart) driveStart = now;
if (isDemo) {
var frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
var idx = Math.floor(frac * DEMO_HEXES.length);
if (idx !== lastIdx) { lastIdx = idx; input.value = DEMO_HEXES[idx]; applyValue(DEMO_HEXES[idx]); }
}
requestAnimationFrame(driveTick);
}
function frame() { renderedX += (targetX - renderedX) * LERP_RATE; paint(); requestAnimationFrame(frame); }
window.addEventListener('resize', function () { applyValue(input.value); });
applyValue(input.value);
if (!reduce) { requestAnimationFrame(driveTick); requestAnimationFrame(frame); }
})();import { useEffect, useRef } from "react";
import styles from "./ContrastBadge.module.scss";
type Rgb = { r: number; g: number; b: number };
export default function ContrastBadge() {
const rootRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const swatchRef = useRef<HTMLSpanElement | null>(null);
const lumBoxRef = useRef<HTMLDivElement | null>(null);
const lumMarkRef = useRef<HTMLDivElement | null>(null);
const statusRef = useRef<HTMLSpanElement | null>(null);
const readoutRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
const root = rootRef.current;
const input = inputRef.current;
const swatch = swatchRef.current;
const lumBox = lumBoxRef.current;
const lumMark = lumMarkRef.current;
const statusEl = statusRef.current;
const readout = readoutRef.current;
if (!root || !input || !swatch || !lumBox || !lumMark || !statusEl || !readout) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
function human() {
if (isDemo) {
root!.classList.remove("is-demo");
isDemo = false;
}
}
const PASS_THRESHOLD = 4.5;
function hexToRgb(hex: string): Rgb {
const m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
if (!m) return { r: 255, g: 91, b: 60 };
const n = parseInt(m[1], 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function relativeLuminance(rgb: Rgb) {
function chan(c: number) {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
}
return 0.2126 * chan(rgb.r) + 0.7152 * chan(rgb.g) + 0.0722 * chan(rgb.b);
}
function contrastRatio(l1: number, l2: number) {
const hi = Math.max(l1, l2);
const lo = Math.min(l1, l2);
return (hi + 0.05) / (lo + 0.05);
}
function clamp01(v: number) {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
const SENT_PREFIX = "흰 배경 대비 ";
const SENT_MID1 = ":1 · 검은 배경 대비 ";
const SENT_MID2 = ":1 중 ";
const SENT_SUFFIX = "이 더 낫습니다";
let targetX = 0;
let renderedX = 0;
const LERP_RATE = 0.17;
function applyValue(hex: string) {
const rgb = hexToRgb(hex);
const L = relativeLuminance(rgb);
const whiteContrast = contrastRatio(L, 1);
const blackContrast = contrastRatio(L, 0);
const best = Math.max(whiteContrast, blackContrast);
const bestName = whiteContrast >= blackContrast ? "흰 배경" : "검은 배경";
const pass = best >= PASS_THRESHOLD;
swatch!.style.background = "#" + ("000000" + (((rgb.r << 16) | (rgb.g << 8) | rgb.b) >>> 0).toString(16)).slice(-6);
statusEl!.textContent = pass ? "통과" : "미달";
statusEl!.classList.toggle("is-pass", pass);
statusEl!.classList.toggle("is-fail", !pass);
const trackW = lumBox!.clientWidth - lumMark!.offsetWidth;
targetX = clamp01(L) * trackW;
readout!.textContent =
SENT_PREFIX + whiteContrast.toFixed(1) + SENT_MID1 + blackContrast.toFixed(1) + SENT_MID2 + bestName + SENT_SUFFIX;
if (reduce) {
renderedX = targetX;
paint();
}
}
function paint() {
lumMark!.style.transform = "translateX(" + renderedX.toFixed(1) + "px)";
}
function onInput() {
human();
applyValue(input!.value);
}
input.addEventListener("input", onInput);
input.addEventListener("change", onInput);
const DEMO_HEXES = ["#ff5b3c", "#2f6df6", "#ffd23f", "#17141a", "#fff7e6", "#8e2287"];
const DRIVE_PERIOD_MS = 2000;
let driveStart = 0;
let lastIdx = -1;
let rafDrive = 0;
function driveTick(now: number) {
if (!driveStart) driveStart = now;
if (isDemo) {
const frac = ((now - driveStart) % DRIVE_PERIOD_MS) / DRIVE_PERIOD_MS;
const idx = Math.floor(frac * DEMO_HEXES.length);
if (idx !== lastIdx) {
lastIdx = idx;
input!.value = DEMO_HEXES[idx];
applyValue(DEMO_HEXES[idx]);
}
}
rafDrive = requestAnimationFrame(driveTick);
}
let rafFrame = 0;
function frame() {
renderedX += (targetX - renderedX) * LERP_RATE;
paint();
rafFrame = requestAnimationFrame(frame);
}
function onResize() {
applyValue(input!.value);
}
window.addEventListener("resize", onResize);
applyValue(input.value);
if (!reduce) {
rafDrive = requestAnimationFrame(driveTick);
rafFrame = requestAnimationFrame(frame);
}
return () => {
input.removeEventListener("input", onInput);
input.removeEventListener("change", onInput);
window.removeEventListener("resize", onResize);
if (rafDrive) cancelAnimationFrame(rafDrive);
if (rafFrame) cancelAnimationFrame(rafFrame);
};
}, []);
return (
<div ref={rootRef} className={`${styles.cc} is-demo`}>
<label className={styles.field}>
<span className={styles.cap}>hex 값</span>
<input ref={inputRef} className={styles.input} type="text" maxLength={7} defaultValue="#ff5b3c" aria-describedby="ccReadoutReact" />
</label>
<div className={styles.badge} aria-hidden="true">
<span ref={swatchRef} className={styles.swatch} />
<div ref={lumBoxRef} className={styles.lum}>
<div ref={lumMarkRef} className={styles.lumMark} />
</div>
<span ref={statusRef} className={styles.status}>통과</span>
</div>
<p ref={readoutRef} className={styles.readout} id="ccReadoutReact" role="status" aria-live="polite" />
</div>
);
}05 Contrast Badge That Crosses a Pass Line From a Hex Value — Measured area 4.078% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Calculator Design: 9 Inputs, Unit Converter UI
The post this demo lives in
CSS Calculator Design: 9 Inputs, Unit Converter UIAmong nine css calculator design inputs, one draws a QR grid from a text value, one fills a ring gauge from a birthdate See all demos in this post
If you like this effect - similar demos
Inputs04 macOS crayon — a grid color picker that sets its border color from a brightness value9 Color Picker Design Patterns (Copy-Paste)
Inputs05 Long-Press That Fans Out Variants Once a Held-Time Value Crosses a LineEmoji Picker: 9 Patterns by One Value
Inputs07 Live currency format — a stepper that recomputes digit-grouping positions every time its value changesNumber Stepper UI: 9 Interaction Patterns