Blog

2026-06-10

How the footer portrait became a random UI palette generator

A readable breakdown of the shared contrast-aware palette package behind the vesika.jpg click, with real code snippets and a live sandbox for accessible dark-site palettes.

live paletteBackground and text stay readable.

The same contrast rule can drive the whole site theme after the footer portrait is clicked.

BG 0, 0, 0TEXT 244, 244, 240CONTRAST 19.05:1

The original site had a small hidden interaction: click the portrait in the footer and the page repaints itself. That sounds like a throwaway visual trick, but the implementation is a compact color system. It generates a pair, proves the pair is readable, then exports it into the same CSS variables the rest of the site already uses.

The generator is a shared package

The palette code is not buried inside a click handler. It lives in @kerimov/palette, which lets the portfolio, the inline blog sandbox, and the standalone palette-generator app use the same rules. That matters because the generator is not just choosing nice-looking swatches. It is doing the accessibility work before any color reaches the UI.

RGB is normalized before it is trusted

Every input gets reduced to three clamped channels. From there, the code converts sRGB into linear light and applies the standard red, green, and blue luminance coefficients. This is the part that makes the generator judge perceived brightness instead of naive channel totals.

packages/palette/src/index.js
export const relativeLuminance = (rgb) => {
  const [r, g, b] = normalizeRgb(rgb).map((value) => {
    const channel = value / 255
    return channel <= 0.03928
      ? channel * LOW_GAMMA_COEFFICIENT
      : Math.pow((channel + 0.055) / 1.055, 2.4)
  })

  return r * RED_COEFFICIENT + g * GREEN_COEFFICIENT + b * BLUE_COEFFICIENT
}

export const contrastRatio = (a, b) => {
  const lighter = Math.max(relativeLuminance(a), relativeLuminance(b))
  const darker = Math.min(relativeLuminance(a), relativeLuminance(b))

  return (lighter + 0.05) / (darker + 0.05)
}

Random is allowed, unreadable is not

The generator starts with one color, then keeps rolling the second one until the contrast ratio clears the default 4.5 threshold. The same function also accepts a pinnedColor, which is why the package can support a fixed brand or page color later without changing the rejection loop.

packages/palette/src/index.js
export const createColorPair = ({
  pinnedColor,
  minContrast = 4.5,
  random = Math.random
} = {}) => {
  const colorA = pinnedColor ? normalizeRgb(pinnedColor) : randomColor(random)
  let colorB = randomColor(random)

  while (!isAccessible(colorA, colorB, minContrast)) {
    colorB = randomColor(random)
  }

  return [colorA, colorB]
}

The site stays dark-first

The returned pair is intentionally named bg and text, not colorA and colorB. After a readable pair exists, the palette function compares luminance again and swaps the values when the lighter color landed in the background slot. The result keeps the portfolio in its dark-site posture while still allowing the text color to jump somewhere unexpected.

packages/palette/src/index.js
export const createPalette = ({
  minContrast = 4.5,
  preferDarkBackground = true,
  pinnedColor,
  random = Math.random
} = {}) => {
  let [bg, text] = createColorPair({ pinnedColor, minContrast, random })

  if (preferDarkBackground && relativeLuminance(bg) > relativeLuminance(text)) {
    ;[bg, text] = [text, bg]
  }

  return {
    bg,
    text,
    contrast: contrastRatio(bg, text),
    bgLuminance: relativeLuminance(bg),
    textLuminance: relativeLuminance(text)
  }
}

The output is CSS, not a React theme

The site was already built around global variables: --pageBG, --pageBG-rgb, --pageText, and --pageText-rgb. The palette package formats exactly those tokens, including RGB parts for alpha overlays. That is why the generated colors can repaint borders, backgrounds, text, buttons, scrollbars, placeholders, and blend-mode effects without threading theme props through the component tree.

packages/palette/src/index.js
export const toCssVariables = (palette) => {
  const bg = toRgbParts(palette.bg)
  const text = toRgbParts(palette.text)

  return `:root {
  --pageBG: rgb(${bg});
  --pageBG-rgb: ${bg};
  --pageText: rgb(${text});
  --pageText-rgb: ${text};
}`
}

The footer click writes one style tag

The runtime piece is deliberately small. PaletteTheme listens for clicks on data-palette-trigger and data-palette-action. When one is clicked, it creates fresh palette rules and drops them into a style element in the document head.

components/palette-theme.js
const getPaletteRules = () => {
  const palette = createPalette()
  return `.carousel img, .footer--block img, [data-palette-trigger] img {mix-blend-mode: darken;}` + toCssVariables(palette)
}

The app is the same engine with more controls

The standalone palette generator in apps/palette-generator is a UI around the same package. It imports createPalette, toCssVariables, toHex, and the RGB formatters from @kerimov/palette. Its generate action updates the active palette and keeps a short recent-history strip, while the readout exposes the contrast score and copyable CSS variables.

apps/palette-generator/src/main.jsx
const generate = () => {
  const nextPalette = createPalette()
  setPalette(nextPalette)
  setHistory((items) => [nextPalette, ...items].slice(0, 6))
}

So the full loop is simple, but not shallow: normalize RGB, compute luminance, calculate contrast, reject weak pairs, optionally preserve a dark background, export CSS variables, and let the existing interface consume those variables. The sandbox above is not a mock of the idea. It is another consumer of the same generator.