Lora
Designing an organic color system
For a long time, the visual identity of this website was defined by a color system I built myself with a strong focus on accessibility.
My first attempt started with a simple goal: a scheme containing the main colors of the spectrum, where each hue had a well-specified contrast.
I used Leonardo to generate this initial palette, and for a while, it worked. It allowed me to build templates paying attention to accessibility standards.
But I ran into a problem.
As I deepened my understanding of color theory, I realized that my previous system —based on HSL and limited to only 7 swatches— was too rigid. It often forced me to choose between a color I liked and a color that was accessible. I lacked the nuance to fine-tune visual hierarchy, and the inconsistencies of the HSL color space meant that manipulating hues often broke the perceived lightness.
As noted in the W3C CSS Color Module Level 4 Editor’s Draft:
A disadvantage of HSL over OkLCh is that hue manipulation changes the visual lightness, and that hues are not evenly spaced apart.
I needed to move away from these inconsistencies. I didn’t just want an “accessible” palette; I wanted something organic, precise, and robust.
Lora
I found my aesthetic north star in Flexoki by Steph Ango. It’s an inky, analog-inspired theme that feels like writing on high-quality paper. It shifted my perspective on what a digital color scheme could feel like.
However, I didn’t just want to copy Flexoki’s values. I wanted to rebuild that organic feeling using a robust mathematical approach.
I wanted a name that reflected the system’s architecture, and I landed on Lora. It sounds organic and is pronounced /lo.ʁa/ —echoing the French pronunciation of “Laura”, my wife, to whom I dedicate this color palette.
It stands for:
- Lightness: the master curve of luminosity. I defined specific lightness steps first, ensuring that text contrast remains consistent regardless of the hue;
- OkLCh: the perceptual color space used by the system;
- Ratio: the mathematical relationship between colors. I avoided “magic numbers,” opting instead for specific scales;
- Adaptive: the system is designed to seamlessly switch between light and dark modes without breaking the balance between colors.
To quickly test the two themes —light and dark— just press ‘D’ on this page to switch the theme.
The implementation
To build a truly cohesive system, I turned to OkLCh. It separates Lightness (L), Chroma (C), and Hue (h) in a way that aligns with how our eyes actually perceive color.
Interactive resources such as Huetone, oklch.fyi and color x color have become my daily playground for understanding this new space.
1. The hues
Is color subjective or objective? From a purely physical point of view, color is electromagnetic radiation, existing independently of the observer. The shade I chose as RED , for example, is objectively defined by specific coordinates.
However, perception is subjective. Each of us —or rather, our brains— interprets color with different sensitivity, influenced by biological, cultural, and historical contexts.
This means the colors in my palette were chosen according to my own logic and personal taste —which others may or may not like. Yet, utilizing the OkLCh model allows me to translate these subjective choices into a perceptually uniform system, ensuring the palette works harmoniously.
These are the definitions I chose for the color wheel:
/* --- colors definitions --- */
--hue-magenta: 0;
--chroma-magenta: 0.2;
--hue-red: 30;
--chroma-red: 0.2;
--hue-orange: 45;
--chroma-orange: 0.2;
--hue-yellow: 95;
--chroma-yellow: 0.2;
--hue-green: 135;
--chroma-green: 0.2;
--hue-teal: 180;
--chroma-teal: 0.2;
--hue-cyan: 225;
--chroma-cyan: 0.2;
--hue-blue: 270;
--chroma-blue: 0.2;
--hue-purple: 315;
--chroma-purple: 0.2;
Hue
The gray, which I consider to be a neutral base, is obtained simply by setting chroma to 0:
--hue-base: 0;
--chroma-base: 0;
2. The lightness curve
My previous palette had only seven steps. This was a significant constraint: I often ran out of options when trying to find combinations that met contrast requirements while still looking cohesive.
With Lora, I expanded the scale to thirteen steps.
This larger canvas gives me the freedom to dial in the exact contrast needed for readability without losing the character of the color.
The philosophy here is one of capacity rather than obligation; just because the system offers thirteen steps does not mean they must all be used, but having them available ensures I always have the right tool to solve the accessibility puzzle.
I divided the range from 0.15 (a “less white” white) to 0.95 (a “less black” black) into these values:
/* --- lightness curve (l) --- */
--L-100: 0.95;
--L-200: 0.9;
--L-300: 0.85;
--L-400: 0.8;
--L-500: 0.7;
--L-600: 0.6;
--L-700: 0.55;
--L-800: 0.5;
--L-900: 0.45;
--L-1000: 0.4;
--L-1100: 0.3;
--L-1200: 0.2;
--L-1300: 0.15;
These steps create a very linear trend:
3. The chroma curve
After defining the lightness, I experimented with Chroma (saturation). Here is where the math gets interesting.
In the Lora system, I separate the curve shape from the color intensity:
- the scale (
--C-scale-): defines the global “shape” of saturation across lightness steps; it dictates that colors should be pastel at lightness 100, vibrant at 600, and muted again at 1300; these are multipliers (percentages); - the base chroma (
--chroma-hue): defines the maximum potential saturation for a specific hue (e.g., magenta is inherently more vivid than teal).
The final value is simply the math of this relationship:
This allows me to tweak the saturation curve for the entire design system in one place, without having to manually rewrite the chroma values for every single color swatch.
/* --- chroma curve (c - multiplier) --- */
--C-scale-100: 0.1;
--C-scale-200: 0.2;
--C-scale-300: 0.3;
--C-scale-400: 0.45;
--C-scale-500: 0.6;
--C-scale-600: 0.8;
--C-scale-700: 0.95;
--C-scale-800: 1;
--C-scale-900: 0.95;
--C-scale-1000: 0.8;
--C-scale-1100: 0.6;
--C-scale-1200: 0.45;
--C-scale-1300: 0.35;
With these values, the curve for magenta looks like this:
The result
The idea of how to implement this at the CSS level came from reading Easy theming with OKLCH colors by Manuel Strehl.
His work helped me understand how to make the system parametric, using CSS variables to generate the palette dynamically.
Here is the final result.
Lora color palette
Tokens for the current Hugo theme
| Color | Value (OKLCH) | Contrast | Light Token | Dark Token |
|---|---|---|---|---|
base-100 | calc... | Aa | ||
base-200 | calc... | Aa | ||
base-300 | calc... | Aa | ||
base-400 | calc... | Aa | ||
base-500 | calc... | Aa | ||
base-600 | calc... | Aa | ||
base-700 | calc... | Aa | ||
base-800 | calc... | Aa | ||
base-900 | calc... | Aa | ||
base-1000 | calc... | Aa | ||
base-1100 | calc... | Aa | ||
base-1200 | calc... | Aa | ||
base-1300 | calc... | Aa |
| Color | Value (OKLCH) | Contrast | Light Token | Dark Token |
|---|---|---|---|---|
cyan-100 | calc... | Aa | ||
cyan-200 | calc... | Aa | ||
cyan-300 | calc... | Aa | ||
cyan-400 | calc... | Aa | ||
cyan-500 | calc... | Aa | ||
cyan-600 | calc... | Aa | ||
cyan-700 | calc... | Aa | ||
cyan-800 | calc... | Aa | ||
cyan-900 | calc... | Aa | ||
cyan-1000 | calc... | Aa | ||
cyan-1100 | calc... | Aa | ||
cyan-1200 | calc... | Aa | ||
cyan-1300 | calc... | Aa |
| Token | Value (OKLCH) | Contrast | Light Map | Dark Map |
|---|---|---|---|---|
--tx-re
// Error | calc... | Aa | ... | ... |
--tx-or
// Warning | calc... | Aa | ... | ... |
--tx-ye
// Yellow | calc... | Aa | ... | ... |
--tx-gr
// Success | calc... | Aa | ... | ... |
--tx-cy
// Accent | calc... | Aa | ... | ... |
--tx-bl
// Blue | calc... | Aa | ... | ... |
--tx-pu
// Purple | calc... | Aa | ... | ... |
--tx-ma
// Magenta | calc... | Aa | ... | ... |
Color contrast matrix
Base
| TXT \ BG | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 | 1100 | 1200 | 1300 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 100 | |||||||||||||
| 200 | |||||||||||||
| 300 | |||||||||||||
| 400 | |||||||||||||
| 500 | |||||||||||||
| 600 | |||||||||||||
| 700 | |||||||||||||
| 800 | |||||||||||||
| 900 | |||||||||||||
| 1000 | |||||||||||||
| 1100 | |||||||||||||
| 1200 | |||||||||||||
| 1300 |
Accent
| TXT \ BG | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 1000 | 1100 | 1200 | 1300 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 100 | |||||||||||||
| 200 | |||||||||||||
| 300 | |||||||||||||
| 400 | |||||||||||||
| 500 | |||||||||||||
| 600 | |||||||||||||
| 700 | |||||||||||||
| 800 | |||||||||||||
| 900 | |||||||||||||
| 1000 | |||||||||||||
| 1100 | |||||||||||||
| 1200 | |||||||||||||
| 1300 |