Keep the pair readable
A color proof recomputes its paired ink as the surface changes, preserving an unrounded contrast target within a bounded palette.

Choose a surface swatch or open the color well. Lock contrast to recompute ink; unlock it to keep the ink fixed as the surface changes.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import * as React from "react"
export const DEFAULT_CONFIG = { targetRatio: 4.5, foregroundPolarity: "dark", lightnessFloor: 0, lightnessCeiling: 100 }
type Config = typeof DEFAULT_CONFIG
const clamp = (v: number, min: number, max: number) => Math.max(min, Math.min(max, Number.isFinite(v) ? v : min))
export function luminance(hex: string) {
const rgb = [1, 3, 5]
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
}
export function contrast(a: string, b: string) {
const x = luminance(a),
y = luminance(b)
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)
}
function ink(lightness: number) {
const l = lightness / 100,
saturation = 0.18,
hue = 225,
chroma = (1 - Math.abs(2 * l - 1)) * saturation,
secondary = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)),
m = l - chroma / 2
return `#${[0, secondary, chroma]
.map((v) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, "0"),
)
.join("")}`
}
export function solveInk(background: string, target: number, polarity: string, floor: number, ceiling: number) {
let best = ink(polarity === "light" ? ceiling : floor)
let solution: string | null = null
const back = luminance(background)
for (let i = 0; i <= 1000; i++) {
const color = ink(floor + ((ceiling - floor) * i) / 1000),
light = luminance(color),
ratio = contrast(color, background)
const properSide = polarity === "light" ? light > back : light < back
if (properSide && ratio > contrast(best, background)) {
best = color
}
if (properSide && ratio >= target && (solution === null || ratio < contrast(solution, background))) {
solution = color
}
}
return { color: solution ?? best, available: solution !== null }
}
const SWATCHES = [
{ name: "Persimmon", hex: "#f26832" },
{ name: "Citron", hex: "#dce55b" },
{ name: "Porcelain", hex: "#e9e6df" },
{ name: "Sky", hex: "#81b9de" },
{ name: "Petal", hex: "#e9a7c1" },
]
function useFittedStage() {
const viewport = React.useRef<HTMLDivElement>(null)
const [scale, setScale] = React.useState(1)
React.useLayoutEffect(() => {
const element = viewport.current!
const fit = () => setScale(Math.min(element.clientWidth / 600, element.clientHeight / 375))
fit()
const observer = new ResizeObserver(fit)
observer.observe(element)
return () => observer.disconnect()
}, [])
return { viewport, scale }
}
export default function Example({ active = true, config }: { active?: boolean; config?: Partial<Config> }) {
const fitted = useFittedStage()
const settings = { ...DEFAULT_CONFIG, ...config },
target = clamp(settings.targetRatio, 3, 7),
floor = clamp(settings.lightnessFloor, 0, 40),
ceiling = clamp(settings.lightnessCeiling, 60, 100)
const [background, setBackground] = React.useState(SWATCHES[0].hex),
[locked, setLocked] = React.useState(true),
[frozen, setFrozen] = React.useState("#342a29")
const solved = React.useMemo(
() => solveInk(background, target, settings.foregroundPolarity, floor, ceiling),
[background, target, settings.foregroundPolarity, floor, ceiling],
)
const foreground = locked ? solved.color : frozen,
ratio = contrast(background, foreground),
passes = ratio >= target
const group = React.useId()
return (
<div
ref={fitted.viewport}
style={{
width: "100%",
height: "100%",
aspectRatio: "16 / 10",
position: "relative",
overflow: "hidden",
background: "#f26832",
}}
>
<div
style={{
width: 600,
height: 375,
position: "absolute",
top: "50%",
left: "50%",
transform: `translate(-50%, -50%) scale(${fitted.scale})`,
transformOrigin: "center",
}}
>
<div
className="contrast-lock"
data-background={background}
data-foreground={foreground}
data-ratio={ratio}
data-available={solved.available}
style={{ "--paper": background, "--ink": foreground } as React.CSSProperties}
>
<style>{`
.contrast-lock{height:100%;width:100%;display:flex;flex-direction:column;font-family:Arial,sans-serif;box-sizing:border-box;background:#f3f2ed}.contrast-lock *{box-sizing:border-box}.cl-proof{background:var(--paper);color:var(--ink);flex:1;min-height:0;display:flex;flex-direction:column;padding:25px 33px 24px;position:relative}.cl-top{display:flex;align-items:center;justify-content:space-between;font-size:9px;letter-spacing:1.2px;font-weight:600}.cl-ratio{font-variant-numeric:tabular-nums;letter-spacing:.4px;font-size:10px;border:1px solid currentColor;border-radius:20px;padding:6px 9px}.cl-type{display:flex;flex:1;align-items:center;justify-content:space-between;gap:22px;min-height:0}.cl-aa{font-family:Arial,sans-serif;font-size:192px;font-weight:700;letter-spacing:-18px;line-height:1;padding-right:15px;margin-left:-10px}.cl-read{width:173px;padding-top:8px;font-size:16px;line-height:1.35;letter-spacing:-.3px;font-weight:400}.cl-read small{display:block;font-size:10px;font-weight:400;letter-spacing:0;line-height:1.5;margin-top:20px;max-width:135px}.cl-bottom{display:flex;justify-content:space-between;font:9px monospace;letter-spacing:.6px}.cl-controls{height:88px;flex-shrink:0;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;gap:18px;border-top:1px solid #fff8}.cl-color{display:flex;align-items:center;gap:8px}.cl-native{width:29px;height:35px;opacity:0;position:absolute;inset:0;cursor:pointer}.cl-custom{position:relative;display:block;width:29px;height:29px;border:1px solid #0002;background:var(--paper);border-radius:50%;box-shadow:inset 0 0 0 3px #f3f2ed}.cl-custom:focus-within{outline:2px solid #303933;outline-offset:3px}.cl-color-label{font-size:9px;color:#797e75;line-height:1.8}.cl-color-label strong{font:10px monospace;color:#31382f;text-transform:uppercase;display:block}.cl-swatches{display:flex;gap:5px;border-left:1px solid #dbddd4;padding-left:17px}.cl-swatch{position:relative;width:24px;height:32px;display:grid;place-items:center;cursor:pointer}.cl-swatch input{position:absolute;opacity:0;width:100%;height:100%;inset:0;margin:0;cursor:pointer}.cl-swatch i{width:21px;height:21px;background:var(--swatch);border:1px solid #0001;border-radius:50%}.cl-swatch input:checked+i{box-shadow:0 0 0 3px #f3f2ed,0 0 0 4px #3b4437}.cl-swatch input:focus-visible+i{outline:2px solid #303933;outline-offset:5px}.cl-lock{border:1px solid #d5d9cf;background:#fafaf7;border-radius:7px;min-width:115px;height:38px;padding:0 12px;display:flex;align-items:center;justify-content:center;gap:7px;font-size:10px;color:#354031;cursor:pointer;box-shadow:0 1px 1px #00000005}.cl-lock[aria-pressed=true]{background:#354031;border-color:#354031;color:#f4f5ec}.cl-lock:focus-visible{outline:2px solid #526b43;outline-offset:3px}.cl-lock:active{transform:translateY(1px)}.cl-lock:disabled,.cl-native:disabled,.cl-swatch input:disabled{cursor:default;opacity:.5}.cl-unavailable{position:absolute;bottom:23px;right:30px;background:#f8f5e9;color:#633928;padding:4px 7px;border-radius:3px;font-size:9px;letter-spacing:0;box-shadow:0 1px 3px #0001}.cl-swatch input:disabled{opacity:0}
`}</style>
<section className="cl-proof" aria-label="Color pair reading specimen">
<div className="cl-top">
<span>Arial</span>
<span className="cl-ratio" role="status">
{(Math.floor(ratio * 100) / 100).toFixed(2)} : 1
</span>
</div>
<div className="cl-type">
<span className="cl-aa" aria-hidden="true">
Aa
</span>
<div className="cl-read">The quick brown fox jumps over the lazy dog.</div>
</div>
<div className="cl-bottom">
<span>INK {foreground.toUpperCase()}</span>
<span>{passes ? `≥ ${target.toFixed(1)}:1` : "Below target"}</span>
</div>
{locked && !solved.available && <span className="cl-unavailable">Target unavailable in this range</span>}
</section>
<div className="cl-controls">
<div className="cl-color">
<label className="cl-custom">
<input
className="cl-native"
type="color"
aria-label="Surface color"
value={background}
disabled={!active}
onChange={(e) => setBackground(e.target.value)}
/>
</label>
<span className="cl-color-label">
Surface<strong>{background}</strong>
</span>
</div>
<div className="cl-swatches" role="radiogroup" aria-label="Surface palette">
{SWATCHES.map((swatch) => (
<label className="cl-swatch" key={swatch.hex} style={{ "--swatch": swatch.hex } as React.CSSProperties}>
<input
type="radio"
name={group}
aria-label={swatch.name}
checked={background === swatch.hex}
disabled={!active}
onChange={() => setBackground(swatch.hex)}
/>
<i />
</label>
))}
</div>
<button
className="cl-lock"
aria-pressed={locked}
disabled={!active}
onClick={() => {
if (locked) {
setFrozen(foreground)
}
setLocked(!locked)
}}
>
<svg width="12" height="14" viewBox="0 0 16 18" fill="none" aria-hidden="true">
<rect x="3" y="7.5" width="10" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.4" />
<path
d={locked ? "M5 7.5V5a3 3 0 0 1 6 0v2.5" : "M5 7.5V5a3 3 0 0 1 6 0"}
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
{locked ? "Contrast locked" : "Ink stays fixed"}
</button>
</div>
</div>
</div>
</div>
)
}
Where it works well
Explore theme colors while preserving a text contrast relationship, or show why a restricted palette cannot meet it.
Reduced motion
Every color edit applies immediately; no hue tween passes through unverified intermediate contrasts. Inactive hosts disable all inputs.
Things to know
sRGB WCAG contrast calculation, not APCA or a full accessibility audit. Ink is searched on a fixed 225-degree, 18%-saturation HSL axis in 1,001 samples. Impossible polarity or lightness bounds show an explicit unavailable state.