Index

Alpha Colors

A solid colour is a result. #e3e3e3 is a fact about a rectangle: whatever sits behind it, that is what you get.

An alpha colour is not a colour. It is a colour and a claim about the background. rgb(0 0 0 / 10%) says nothing on its own — composite it over white and you get #e6e6e6, over this page’s background #e3e3e3, over black you get black. The value in your token file is only half of the value on the screen.

That reads as a weakness. It is the entire feature, and there is a number that says how much of it you are buying. Compositing is result = α·fg + (1 − α)·bg. Differentiate with respect to the background and the foreground term falls away: the background’s share of the outcome is exactly 1 − α. A divider at 6% alpha is 94% background. Body text at 81% barely knows what is behind it. Which means the tokens a design system calls “subtle” — dividers, hover fills, scrims, the quiet ones — are precisely the ones that hand almost the whole decision to whatever they land on.

Below is a single rectangle. Its left half is a solid #dedede; its right half is the alpha colour that reproduces #dedede on this site’s --bg. At that background they are the same rectangle and the seam does not exist. Drag the background darker.

solid #dedede
alpha #dedede
ΔL* 0.00
--bg --surface dark --bg

Matched — the pair is solved for this background.

Left half solid, right half alpha, no gutter between them. They agree at #fcfcfc and stay within a lightness step of each other across every light surface this site has — then the seam opens. ΔL* is the difference in perceptual lightness; below about 1 the eye reads one flat field.

Note where it separates. Across --bg, --surface and --code-bg — the three light surfaces this site actually uses — the two halves stay under ΔL* 1.7, which is to say invisible. The alpha half is not merely surviving there; it is doing the more correct thing, holding a constant relationship to its surface while the solid holds a constant value. The contract only breaks when you leave the family it was written for.

This started as a tool I wanted for client work and then sat in a notes file for a year. Alphredo got there first and is good — a web app and a Figma plugin, and it already does the thing that matters most, which is to paint the results on a surface you can change and let you watch the palette move.

What follows is the same equation, built into this page, with the comparison made harder to look away from: the solid and its alpha equivalent flush against each other in every row, so a mismatch arrives as a seam rather than as two swatches with a gap between them, and a number for how far apart they have got. It also moves alpha where alphredo moves saturation — which, as the next section gets to, is the difference between the solver’s own slack and a colour adjustment standing where the slack should be.


The colour you meant

One equation per channel, two unknowns:

target = α·fg + (1 − α)·bg

The system is underdetermined. Every alpha from some minimum up to 1 has a colour that solves it, and they all composite to the same target — #5384E6 on white is #0049da at 67.45%, and #1a5bde at 75%, and #356ee2 at 85%, and itself at 100%. Rearranged for fg and held inside the gamut, each channel sets a floor:

fg ≥ 0    ⟹  α ≥ (bg − target) / bg
fg ≤ 255  ⟹  α ≥ (target − bg) / (255 − bg)

Only one of the two can bind on a given channel. Take the largest floor across all three and you have the most translucent version of the colour — the one that survives the most different backgrounds, which is why it is the sensible default.

Walking alpha up from that floor pulls the foreground back toward the target, and at 100% it is the target. At the floor the ink is as saturated as the gamut allows; every step up desaturates it. Crucially, every one of those pairs still composites to the same colour. The slack is free.

That matters because it is not the dial these tools usually give you. Alphredo has a saturation control, and it multiplies the saturation of the already solved colour — after the equation, not inside it. The alpha does not move to compensate, so the match simply stops being a match. Solve #5384E6 on white and pull its saturation to a quarter and the pair now paints #8a96af, which is ΔL* 5.7 from where you started and a different colour by eye. Nothing says so.

The two knobs look identical in a UI and are opposites: one is the equation’s own slack and costs nothing, the other is a colour adjustment wearing the solver’s clothes. The generator below moves alpha.


What it buys you

Fewer tokens. This site defines --surface as #f5f5f5, --code-bg as #f2f2f2, and --astro-code-background as rgba(0, 0, 0, 0.03). Solved against --bg, the first two are black at 2.78% and 3.97%. They are three spellings of one idea — slightly recessed — and one of them already knew it. A scale of tints per surface collapses into a scale of alphas that works on all of them.

State layers are alpha whether you write them that way or not. Hover, pressed, selected and focus are physically a scrim of ink over whatever is underneath. Write them solid and the button is correct on the default surface and wrong on a card, a modal, a coloured banner. Write them alpha and there is one set of numbers.

Alpha compounds; solid overwrites. Two 10% layers are not 20%, they are 1 − 0.9² = 19%; three are 27.1%. That is the behaviour you want from nested surfaces and sticky headers, and solid colours cannot express it at all — the second layer simply repaints the first.

alpha — 10% each
solid — the same one value
1 layer #e3e3e3 10%
2 layers #cccccc 19%
3 layers #b8b8b8 27.1%
Same geometry both sides: three bars, fanned, each overlapping its neighbour. On the left every overlap is a new value. On the right each bar is the opaque equivalent of one layer, so the overlaps have nothing to say and the fan reads as a single block.

Dark mode gets cheaper, and this is where I got it wrong. The promise is that black ink on light flips to white ink on dark and the numbers carry over. They do not, and I assumed the asymmetry ran the other way round.

This site ships --border at black 10% in light and white 15% in dark. I raised the dark one by eye, on the theory that a light border on a dark field needs more help. Measured, black 10% on #fcfcfc moves the surface by ΔL* 8.80; white 10% on #1c1c1c moves it by 10.84 — the dark side was already ahead at the same number. To match the light border’s weight, dark wants 8.1%, not 15%. What shipped is nearly twice the perceptual contrast of its counterpart.

That is not automatically a bug — deliberately firmer separation in dark mode is a defensible choice, and I may keep it. It was not a choice when I made it, though. It was a guess, and a generator that reports nothing but the solved alpha would have let me keep making it.


Where solid wins

The honest column. None of these are edge cases.

SolidAlpha
Value on screenThe one in the token fileOnly known with the background
Contrast ratioComputable onceOne per background it can land on
LayeringOverwritesCompounds
BackgroundsOne correct surfaceA family of surfaces
Over a photoPredictableNothing in the bright areas, mud in the dark
Eyedropper saysA value you can grep forA value that is in no file anywhere

Text is the one to be careful with. An alpha colour has no contrast ratio, it has a contrast ratio per background, so WCAG cannot be checked once and trusted. This site’s --text-secondary is a solid #808080 — as alpha it would be black at 49.21% — and leaving it solid is very likely the right call. Alpha is for the surfaces text sits on, not usually for the text.

Over an image the model has nothing to stand on. Every number here presumes a flat, known background. Black at 6% over a photograph is invisible in the highlights and a smear in the shadows.

Watch the degenerate end. --text-primary, #303030, solves to black at 80.95% — it “works”, round-trips exactly, and buys nothing. Past about 90% you have written a solid colour with extra steps, and the tool below says so rather than quietly handing it back.

And you cannot eyedrop it. QA reports a divider as #e3e3e3 and that string appears nowhere in the codebase. Design handoff pays this too.


The generator

Every row is the figure at the top of this page. Type a solid colour on the left; the bar beside it is that colour on one half and its alpha equivalent on the other, flush, no gutter. It opens on this site’s light-theme values, solved against --bg, so every bar starts unbroken.

Then drag the strip under the list. That is not the background the colours are solved for — it is the surface they are being carried to, and the whole palette repaints on it at once. The seams open in order, lowest alpha first, which is 1 − α playing out down a column instead of sitting in one.

0.0
0.0
0.0
0.0
0.0
0.0

Drag the preview to move the whole palette onto another surface. The left half of each bar is the solid; the right half is the alpha colour. They part in order — lowest alpha first.

Alpha above the floor 0% The solver's slack. Every value here reproduces the same colour — it walks the ink back toward the target rather than restating it.
/* solved against #fcfcfc */
--alpha-1: rgb(0 0 0 / 2.78%);
--alpha-2: rgb(0 0 0 / 3.97%);
--alpha-3: rgb(0 0 0 / 11.9%);
--alpha-4: rgb(0 0 0 / 39.29%);
--alpha-5: rgb(0 0 0 / 49.21%);
--alpha-6: rgb(0 0 0 / 80.95%);

Set the preview to near-black and read the ΔL* column: 86.8, 85.7, 79.7, 58.2, 49.6, 18.5, against 1 − α values of 97.2%, 96.0%, 88.1%, 60.7%, 50.8%, 19.0%. Same shape, because it is the same fact. The lightest, quietest token in the set travels the furthest, and #303030 — the one that is 81% opaque and barely a translucency at all — hardly moves.

Convert is that list; edit a row to change it, clear one to delete it, fill the empty row at the bottom to add one. Generate runs the equation the other way: an ink, a number of steps, and a curve build the ladder, and the rows show what each rung composites to. Alpha above the floor is the slack from the section above — drag it and the ink desaturates while every bar stays whole, which is the difference between the free parameter and a colour adjustment.

Click any α to copy its notation. Export is CSS custom properties, eight-digit hex, or JSON; the CSS uses rgb(r g b / a%) rather than #rrggbbaa because a percentage in a token file is a number you can reason about and 1e is not.


Props

AlphaColors

targetsstringthis site's light scale

Whitespace- or comma-separated solid colours.

backgroundstring#fcfcfc

The background each pair is solved against.

inkstring#000000

Starting ink for Generate mode.

AlphaProof

targetstring#dedede

The solid colour to reproduce.

solveOnstring#fcfcfc

Background the pair is solved for, and the slider’s start.

stopsStop[]site surfaces

Labelled ticks under the slider.

AlphaStack

alphanumber0.1

Alpha of each layer.

layersnumber3

How many bars are fanned out.

backgroundstring#fcfcfc

The stage’s own background.

inkstring#000000

Layer colour.


Notes

It solves in sRGB, deliberately. That is where browsers composite by default, so it is where the answer has to be exact. Solving in OKLab would give a prettier ramp — the intermediate steps of a generated scale are better spaced perceptually — but the composite would then be computed in one space and painted in another, and the match would drift. Perceptual space is the right tool for choosing the targets and the wrong one for reproducing them.

Rounding is measured rather than assumed. Eight-bit colour and a quantised alpha both round the answer, and solve.ts carries a residual() that composites the pair back and reports the worst channel miss out of 255. Every value on this page returns zero, for a reason worth knowing: the composite’s sensitivity to the foreground is α, so a low-alpha token — the one whose ink lands on the most extreme, least forgiving numbers — is also the one whose rounding reaches the screen least. Exporting alpha as a percentage with two decimals leaves four decimal places on the alpha itself, finer than the 1/255 the paint is quantised to anyway.

Notation. #rrggbbaa, rgb(r g b / a%) and color-mix(in srgb, black 6%, transparent) all describe the same paint. The middle one exports by default because the alpha stays legible as a percentage.

One file, no dependencies. Everything above runs on this:

export interface RGB {
  r: number
  g: number
  b: number
}

export interface AlphaColor {
  color: RGB
  alpha: number
}

export type Curve = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'

/** Above this, the solved colour is so close to opaque that it buys nothing. */
export const DEGENERATE_ALPHA = 0.9

const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
const clamp255 = (n: number) => clamp(n, 0, 255)

export function parseColor(input: string): RGB | null {
  const value = input.trim().toLowerCase()

  const hex = value.match(/^#?([0-9a-f]{3,8})$/)
  if (hex) {
    const digits = hex[1]
    if (digits.length === 3 || digits.length === 4) {
      const [r, g, b] = Array.from(digits.slice(0, 3), (d) => parseInt(d + d, 16))
      return { r: r!, g: g!, b: b! }
    }
    if (digits.length === 6 || digits.length === 8) {
      return {
        r: parseInt(digits.slice(0, 2), 16),
        g: parseInt(digits.slice(2, 4), 16),
        b: parseInt(digits.slice(4, 6), 16)
      }
    }
    return null
  }

  const fn = value.match(/^rgba?\(([^)]+)\)$/)
  if (fn) {
    const parts = fn[1]!.split(/[\s,/]+/).filter(Boolean)
    if (parts.length < 3) return null
    const channels = parts
      .slice(0, 3)
      .map((part) => (part.endsWith('%') ? (parseFloat(part) / 100) * 255 : parseFloat(part)))
    if (channels.some((c) => !Number.isFinite(c))) return null
    return {
      r: clamp255(Math.round(channels[0]!)),
      g: clamp255(Math.round(channels[1]!)),
      b: clamp255(Math.round(channels[2]!))
    }
  }

  return null
}

export function toHex({ r, g, b }: RGB): string {
  const pair = (n: number) => clamp255(Math.round(n)).toString(16).padStart(2, '0')
  return `#${pair(r)}${pair(g)}${pair(b)}`
}

export function toHex8(color: RGB, alpha: number): string {
  const a = clamp(Math.round(alpha * 255), 0, 255)
    .toString(16)
    .padStart(2, '0')
  return `${toHex(color)}${a}`
}

/** Space-separated syntax, the one that reads as a percentage in a token file. */
export function toRgbSlash(color: RGB, alpha: number): string {
  const { r, g, b } = roundColor(color)
  return `rgb(${r} ${g} ${b} / ${formatAlpha(alpha)}%)`
}

export function formatAlpha(alpha: number): string {
  return parseFloat((alpha * 100).toFixed(2)).toString()
}

export function roundColor({ r, g, b }: RGB): RGB {
  return { r: clamp255(Math.round(r)), g: clamp255(Math.round(g)), b: clamp255(Math.round(b)) }
}

export function composite(fg: RGB, alpha: number, bg: RGB): RGB {
  return {
    r: alpha * fg.r + (1 - alpha) * bg.r,
    g: alpha * fg.g + (1 - alpha) * bg.g,
    b: alpha * fg.b + (1 - alpha) * bg.b
  }
}

/**
 * The lowest alpha that still reproduces `target` on `bg` with an in-gamut
 * colour.
 *
 * Compositing gives `target = a * fg + (1 - a) * bg`, one equation per channel
 * and two unknowns, so the system is underdetermined: every alpha from this
 * minimum up to 1 has a colour that solves it. Rearranged for fg and held to
 * `0 <= fg <= 255`, each channel sets a floor on alpha, and the largest floor
 * across the three is the answer.
 */
export function solveAlpha(target: RGB, bg: RGB): AlphaColor {
  const channels: [number, number][] = [
    [target.r, bg.r],
    [target.g, bg.g],
    [target.b, bg.b]
  ]

  let alpha = 0
  for (const [t, b] of channels) {
    // fg >= 0 when the target is darker than the background, fg <= 255 when it
    // is lighter. Only one of the two can bind on a given channel.
    const floor = t < b ? (b - t) / b : t > b ? (t - b) / (255 - b) : 0
    if (Number.isFinite(floor)) alpha = Math.max(alpha, floor)
  }

  return { color: colorAt(target, bg, alpha), alpha: clamp(alpha, 0, 1) }
}

/**
 * The colour that pairs with a given alpha. Raising alpha past the minimum
 * walks the colour back toward the target — which is the same dial a generator
 * labels "saturation", seen from the other side.
 */
export function colorAt(target: RGB, bg: RGB, alpha: number): RGB {
  if (alpha <= 0) return { r: 0, g: 0, b: 0 }
  const solve = (t: number, b: number) => clamp255((t - (1 - alpha) * b) / alpha)
  return {
    r: solve(target.r, bg.r),
    g: solve(target.g, bg.g),
    b: solve(target.b, bg.b)
  }
}

/**
 * Largest per-channel miss once the colour is rounded to 8-bit and the alpha to
 * the precision that gets exported, in 0-255 units. Reported rather than hidden:
 * the pair is only ever as exact as the notation it is written in.
 */
export function residual(target: RGB, solved: AlphaColor, bg: RGB): number {
  const alpha = parseFloat(formatAlpha(solved.alpha)) / 100
  const result = composite(roundColor(solved.color), alpha, bg)
  return Math.max(
    Math.abs(Math.round(result.r) - Math.round(target.r)),
    Math.abs(Math.round(result.g) - Math.round(target.g)),
    Math.abs(Math.round(result.b) - Math.round(target.b))
  )
}

export function isDegenerate(alpha: number): boolean {
  return alpha > DEGENERATE_ALPHA
}

/** Relative luminance, WCAG 2.x. */
export function luminance({ r, g, b }: RGB): number {
  const channel = (v: number) => {
    const s = clamp255(v) / 255
    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
  }
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
}

export function contrast(a: RGB, b: RGB): number {
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x)
  return (hi! + 0.05) / (lo! + 0.05)
}

/**
 * CIE L*, the perceptual lightness of a colour on a 0-100 scale.
 *
 * Contrast ratio is the wrong instrument for the values this tool deals in: it
 * is a ratio, so near the top of the scale a large step in lightness barely
 * moves it. L* is a difference, which is what you want when the question is
 * whether a border on white and the same border on near-black carry the same
 * visual weight.
 */
export function lightness(color: RGB): number {
  const y = luminance(color)
  return y <= 216 / 24389 ? (24389 / 27) * y : 116 * Math.cbrt(y) - 16
}

export function deltaLightness(a: RGB, b: RGB): number {
  return Math.abs(lightness(a) - lightness(b))
}

/** Readable ink for a surface, for UI painted on a colour the theme did not pick. */
export function inkFor(background: RGB): string {
  return lightness(background) > 55 ? '#141414' : '#f5f5f5'
}

/** A hairline that reads on the same surface without competing with the ink. */
export function edgeFor(background: RGB): string {
  return lightness(background) > 55 ? 'rgb(0 0 0 / 0.18)' : 'rgb(255 255 255 / 0.22)'
}

const curves: Record<Curve, (t: number) => number> = {
  linear: (t) => t,
  'ease-in': (t) => t * t,
  'ease-out': (t) => 1 - (1 - t) * (1 - t),
  'ease-in-out': (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2)
}

export interface RampOptions {
  steps: number
  intensity: number
  offset: number
  curve: Curve
}

/** The alpha ladder itself: `offset` at the first step, `intensity` at the last. */
export function rampAlphas({ steps, intensity, offset, curve }: RampOptions): number[] {
  const shape = curves[curve] ?? curves.linear
  if (steps <= 1) return [clamp(intensity, 0, 1)]
  return Array.from({ length: steps }, (_, i) => {
    const t = shape(i / (steps - 1))
    return clamp(offset + (intensity - offset) * t, 0, 1)
  })
}

export interface Step {
  target: RGB
  solved: AlphaColor
  residual: number
  degenerate: boolean
}

export function solveScale(targets: RGB[], bg: RGB): Step[] {
  return targets.map((target) => {
    const solved = solveAlpha(target, bg)
    return {
      target,
      solved,
      residual: residual(target, solved, bg),
      degenerate: isDegenerate(solved.alpha)
    }
  })
}

/** Targets produced by laying `ink` over `bg` at each rung of the ramp. */
export function rampTargets(ink: RGB, bg: RGB, options: RampOptions): RGB[] {
  return rampAlphas(options).map((alpha) => roundColor(composite(ink, alpha, bg)))
}

export function parseList(input: string): RGB[] {
  return input
    .split(/[\s,;]+|["'[\]{}]|:\s*/)
    .map((token) => token.trim())
    .filter(Boolean)
    .map(parseColor)
    .filter((color): color is RGB => color !== null)
}

Nothing here reads a colour out of the page. Every demo paints its own surface and carries its own ink — the generator picks black or near-white text from the lightness of whatever you have dragged the preview to — because the numbers printed beside a swatch are computed against one specific background and stop being true the moment it moves. The theme controls the chrome around them and nothing inside. That is deliberate: resolving a token to a value once at startup is the bug that eats theme toggles, and the way not to have it is to never hold a resolved colour at all.

And the reason any of this got written. While measuring this site to find examples, --border turned out to have two definitions — a solid #dedede in :root and rgba(0, 0, 0, 0.1) in an html.light block — along with two different --selection values and two different --mark values. The theme script always adds the class, so the alpha copies won and the :root block only ever rendered with JavaScript off. The bug survived a year of looking straight at it because on --surface the two definitions differ by ΔL* 0.53, which is nothing. There is one block now.