Jairus Joer

Senior Software Engineer & Designer

Demonstrating Software Personalisation

How colour, contrast, and palettes turn web theming into personal, accessible software.


In previous posts, I wrote about my philosophy of personal software and my recent work on software personalisation at HERO Software. In this post, I would like to continue this exploration in a more concrete way: by demonstrating what software personalisation can look like on the web through the lens of theming.

Theming, in essence, is the extension of an identity into software. Whether that identity belongs to a company or an individual is secondary to the goal of this extension: association.

In software, associations are formed through a variety of features. At the simple end, there might be an option to upload a custom logo. At the complex end, the product is designed from the outset to respect interconnected user preferences and the realities of multi-tenant brands.

Colour is a particularly effective tool for enabling association, from its application in user interfaces to its role in multi-brand design systems. To explore the growing complexity of colour, and the considerations that follow from it, choose a colour:

A single colour may not appear complex in isolation, but complexity emerges as soon as it interacts with an interface. The moment control of a colour is handed to the user, the number of considerations grows with the available palette.

The following sections explore this complexity in depth, breaking it down into its constituent parts: contrast, stability, palettes and beyond. This provides the knowledge necessary for implementation.

Contrast

Contrast is not just a matter of taste; it is a requirement defined by the Web Content Accessibility Guidelines (WCAG). In a fixed palette, it is often sufficient to provide hardcoded contrast colours that have been pre-validated against known backgrounds.

However, as soon as a colour space is exposed through a picker, this approach becomes impractical; there are too many possible inputs to validate them all in advance.

Previously, JavaScript libraries were required to reliably calculate the optimal contrast colour based on a theme colour. In April 2026, the CSS contrast-color() function became widely available and aims to address this problem head-on.

Aa
Aa

Changing the theme colour will automatically apply an appropriate contrast color

contrast-color() ensures minimum contrast in accordance with WCAG AA1 and returns either black or white based on the provided input. In this demonstration, the theme colour picker sets the CSS variable --color-accent-raw, which can then be used to derive a contrasting foreground colour:

:root {
  --color-accent-contrast: light-dark(white, black);

  @supports (color: contrast-color(red)) {
    --color-accent-contrast: contrast-color(var(--color-accent-raw));
  }
}

Given the relative recency of contrast-color() in the baseline, it should be treated as progressive enhancement. The default implementation should remain widely accessible, with the new function being opted into when the browser supports it correctly.

Framing it this way also sets the team’s expectations correctly: this is not a polyfill that will be replaced later, but the start of a layered approach, whereby each browser receives the version that it can support best.

Experimenting with the theme colour on this page, it becomes noticeable that links and interactive elements change, but not necessarily to the full intensity of the chosen colour. This behaviour is intentional, and it leads directly to the next layer of complexity: stability.

Stability

A theme colour should not only be legible; it should also be recognisable. If a colour shifts too much between light and dark mode, the sense of identity it carries begins to erode.

A vivid indigo that reads as bold and intentional in dark mode can appear muted and arbitrary in light mode; and the user, who chose it to mean something, notices the difference even when they cannot name it.

One approach to stabilising this is to derive a usable theme colour from the raw input while constraining its perceptual lightness across modes. Below is a minimal example of how the page theme colour is stabilised using oklch() and light-dark():

:root {
  --color-accent: light-dark(oklch(from var(--color-accent-raw) 0.5 c h), oklch(from var(--color-accent-raw) 0.75 c h));
}

In the OKLCH colour space, the values 0.5 and 0.75 are the perceptual lightness targets, where 0 is black and 1 is white. In light mode, setting lightness to 0.5 ensures the theme sits in the midrange; dark enough to be readable against white text, but light enough not to overpower the surrounding area.

In dark mode, setting lightness to 0.75 lifts the colour towards the lighter end of the spectrum, where it remains visible without washing out. The chroma c and hue h are passed through unchanged, preserving the original colour’s character across modes.

When implementing theming in software, design and product teams must consciously choose a position along the spectrum between freedom and predictability.

Too much freedom can result in user-chosen colours overwriting the intended identity of the software. Too much rigidity, however, and the sense of ownership disappears.

Neon yellow chosen by a user with high-contrast needs and muted sage chosen for calm focus should both remain functional, which requires the system to perform tasks that the designer cannot anticipate in advance.

There is no universal answer here. In my experience, the most satisfying outcomes emerge when design and product teams are given enough time to think through these trade-offs together, and when implementation respects both the constraints of accessibility and the aspirations of the brand.

Palettes

At some point in this process, the request for a full palette almost always appears: a range of shades derived from a single colour that can be applied to buttons, backgrounds, borders, and states.

Code
0
100
200
300
400
500
600
700
800
900
1000
--color-accent-0: oklch(from var(--color-accent-raw) 1.00 calc(c * 0.09) h);
--color-accent-100: oklch(from var(--color-accent-raw) 0.91 calc(c * 0.27) h);
--color-accent-200: oklch(from var(--color-accent-raw) 0.82 calc(c * 0.45) h);
--color-accent-300: oklch(from var(--color-accent-raw) 0.73 calc(c * 0.64) h);
--color-accent-400: oklch(from var(--color-accent-raw) 0.64 calc(c * 0.82) h);
--color-accent-500: oklch(from var(--color-accent-raw) 0.55 calc(c * 1.00) h);
--color-accent-600: oklch(from var(--color-accent-raw) 0.45 calc(c * 0.82) h);
--color-accent-700: oklch(from var(--color-accent-raw) 0.36 calc(c * 0.64) h);
--color-accent-800: oklch(from var(--color-accent-raw) 0.27 calc(c * 0.45) h);
--color-accent-900: oklch(from var(--color-accent-raw) 0.18 calc(c * 0.27) h);
--color-accent-1000: oklch(from var(--color-accent-raw) 0.09 calc(c * 0.09) h);

Constructing such a palette deterministically from a single input colour must account for all the previous considerations; and introduces several new ones in the realm of colour space.

Colours close to black or white cannot produce useful midtones using a simple lightness ramp. The available range collapses at the extremes and the generated shades either become indistinguishable from each other, or they lose their relationship to the original colour completely.

Yellows present a different problem: because yellow is an inherently high-lightness hue, its darker shades darken faster than those of other hues at the same lightness step. This means that a naive ramp will produce muddy shades of olive green where the user expects dark amber.

Any palette generator that avoids manual labour and creeping complexity will inevitably involve compromises. These compromises may include reduced freedom in favour of stability or ranges that are ‘good enough’ for most situations, but not perfectly calibrated for every possible hue. Consciously accepting a compromise is very different from discovering it during production.

One practical approach is to start from an existing, well-designed scale and adapt it. Libraries such as Radix2 and Tailwind CSS3 provide robust defaults that have been tested across a wide variety of inputs and use cases. Radix, in particular, exposes its generation logic and documents its hue-specific corrections, making it a useful reference even when the goal is to build something custom.

From there, a theme can refine the mapping between user-provided inputs and the chosen scale, replacing parts that do not fit rather than building everything from scratch. The key question is not which palette library to use, but what guarantees the system provides.

For some products, a small, opinionated palette is the right answer; fewer shades mean fewer failure modes. For others, a more expressive palette better supports the product’s identity and the diversity of its users. Either way, this decision should be made explicitly rather than by default.

Beyond

Although colour is only one axis of personalisation, it is closely linked to the others in ways that are easy to underestimate. The CSS light-dark() function, which is used throughout this post, handles more than just two colour values.

It links the theme colour directly to the user’s preference for light or dark mode, a form of personalisation that predates theming and has significant implications for legibility and comfort.

Alongside this, media features such as prefers-contrast express the user’s preference for higher contrast. While browsers provide sensible defaults, these can be adjusted to match a brand’s colour scheme without compromising the user’s preferences.

A user who has enabled high contrast in their operating system is making a clear statement about their needs. Software that ignores this preference, even in the name of brand consistency, prioritises itself over its users.

Taken together, these capabilities point towards a broader shift in how we can think about theming. Personalisation is not simply “letting users pick a colour”; it involves making decisions about which preferences the software should defer to and when.

A brand that provides a colour picker but disregards the user’s system contrast preference has created something that resembles personalisation without fully embracing it.

In contrast, a product that handles colour, contrast, motion and density preferences as a coherent system, with considered fallbacks at every level, demonstrates a different approach: the software belongs to the person using it in a real sense.

This shift, from theming as a surface feature to a product commitment, is what I find most interesting about this area. It shifts the focus from ‘What should the picker look like?’ to ‘What do we owe users who configure their tools?’ The answer to this question is not purely technical, yet it is rarely asked early enough.


While putting the finishing touches to this post, I came across a post by Rails Designer on building user-customisable themes with Tailwind CSS. They arrived at a similar conclusion: setting the hue while constraining lightness and chroma.


Until next time
Yours truly, Jairus Joer

Footnotes

  1. W3C, Web Content Accessibility Guidelines (WCAG) 2.2, 2026.

  2. Radix, Radix Colors.

  3. Tailwind CSS, Colors — Core concepts.