Color helpers
Six helpers for the color arithmetic an admin screen does: parsing, contrast, and picking text that can actually be read.
<script setup lang="ts">
import { colorFromString, contrastColor, contrastRatio, hexToRgb, isLightColor, rgbToHex } from '@shared/utils/color'
import { initials } from '@shared/utils/string'
import { Badge } from '@shared/components/ui/badge'
import { Input } from '@shared/components/ui/input'
import { Label } from '@shared/components/ui/label'
import { computed, ref } from 'vue'
const NAMES = ['Ada Lovelace', 'Amina Haddad', 'Priya Nair', 'محمد المعراج', 'Sofia Rossi', 'Tomás Oliveira']
const background = ref('#fde047')
const rgb = computed(() => hexToRgb(background.value))
const foreground = computed(() => contrastColor(background.value))
const ratio = computed(() => contrastRatio(foreground.value, background.value))
const grade = computed(() => {
if (ratio.value >= 4.5)
return 'AA for body text'
if (ratio.value >= 3)
return 'AA for large text and UI only'
return 'below AA'
})
</script>
<template>
<div class="flex flex-col gap-5">
<section class="flex flex-col gap-3">
<div class="flex flex-wrap items-end gap-3">
<div class="grid gap-1">
<Label for="col-bg">Background</Label>
<Input id="col-bg" v-model="background" type="color" class="h-10 w-20 p-1" />
</div>
<div class="grid gap-1">
<Label for="col-hex">Hex</Label>
<Input id="col-hex" v-model="background" class="w-32" />
</div>
</div>
<div
class="flex items-center justify-between gap-4 rounded-xl border border-border p-4"
:style="{ background, color: foreground }"
>
<span class="font-semibold">Readable either way</span>
<span class="sa-text-sm">{{ foreground }}</span>
</div>
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
{{ rgb ? `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})` : 'unparseable' }}
</Badge>
<Badge variant="outline">
{{ rgb ? rgbToHex(rgb) : '—' }}
</Badge>
<Badge variant="outline">
isLightColor: {{ isLightColor(background) }}
</Badge>
<Badge variant="outline">
ratio {{ ratio.toFixed(2) }} — {{ grade }}
</Badge>
</div>
</section>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">colorFromString, with initials on top</span>
<div class="flex flex-wrap gap-3">
<div
v-for="name in NAMES"
:key="name"
class="flex size-11 items-center justify-center rounded-full font-semibold"
:style="{ background: colorFromString(name), color: contrastColor(colorFromString(name)) }"
:title="name"
>
{{ initials(name) }}
</div>
</div>
<p class="sa-text-xs text-muted-foreground">
The same name always gives the same color, on any script.
</p>
</section>
</div>
</template>Import
import { colorFromString, contrastColor, contrastRatio, hexToRgb, isLightColor, rgbToHex } from '@shared/utils/color'hexToRgb
function hexToRgb(hex: string): Rgb | null
interface Rgb {
r: number // 0–255
g: number // 0–255
b: number // 0–255
a: number // 0–1, defaults to 1
}hexToRgb('#3b82f6') // { r: 59, g: 130, b: 246, a: 1 }
hexToRgb('#f0a') // { r: 255, g: 0, b: 170, a: 1 }Returns null when it cannot parse. A bad value must fail visibly rather than resolve to black. Three-, six- and eight-digit hex all work.
rgbToHex
function rgbToHex(rgb: Rgb): string
function rgbToHex(r: number, g: number, b: number): stringrgbToHex(59, 130, 246) // '#3b82f6'
rgbToHex({ r: 59, g: 130, b: 246, a: 1 }) // '#3b82f6'Channels are rounded and clamped, so arithmetic that overshoots still yields a valid color. Alpha is dropped.
contrastRatio
function contrastRatio(foreground: string, background: string): numbercontrastRatio('#ffffff', '#3b82f6') // 3.68The WCAG ratio, 1 (identical) to 21 (black on white), and 0 if either color cannot be parsed.
AA is 4.5 for body text, 3 for large text and UI components. The example above passes for large text only.
isLightColor
function isLightColor(color: string): booleanisLightColor('#fde047') // true
isLightColor('#1e293b') // falseDefined as "black contrasts better than white here" — the same criterion contrastColor uses, not a perceptual lightness test. That is why a mid-dark blue can count as light: what matters is which text will be readable, not how bright the color looks.
contrastColor
function contrastColor(background: string, options?: ContrastColorOptions): string
interface ContrastColorOptions {
light?: string // returned on dark backgrounds, default '#ffffff'
dark?: string // returned on light backgrounds, default '#000000'
}contrastColor('#fde047') // '#000000'
contrastColor('#1e293b') // '#ffffff'Whichever candidate wins on WCAG contrast. An unparseable background falls back to dark, which stays readable where the reverse would not.
colorFromString
function colorFromString(value: string, options?: ColorFromStringOptions): string
interface ColorFromStringOptions {
saturation?: number // 0–100, default 65
lightness?: number // 0–100, default 55
}colorFromString('Ada Lovelace') // the same hex on every render
colorFromString('محمد المعراج') // works on any scriptFor avatar tiles and tag chips. Only the hue varies, which is what keeps a wall of avatars looking like one palette rather than a bag of sweets. The hash walks code points, so an astral character counts once.
Pair it with contrastColor for the initials on top, and initials for the letters themselves.
Related
- String helpers —
initials, the other half of an avatar. - Theming — the ramps the brand color generates.
useChartTheme— resolved colors for a chart.