
Index
Introduction
I tried creating the initial code for a colour ramp/scale, generator in Chat GPT. After a lot of iterations I came close but switched to Figma Make as I felt it would give me more control of the layout.
Problem statement
The initial prompt that I briefed ChatGPT with.
‘As a designer I have an existing brand colour palette. I want to create a design system from that palette. This will mean making a set of brand colours in my figma variables. I know the hex value of these brand colours. I want to turn each of these individual brand colours into an 11 step colour scale in the style of a tailwind colour palette.
The solution is it possible to use the DeltaE 2000 algorithm to calculate a range of colour values from a single hex value to create a colour scale in the style of the tailwind colour palette. Which has 11 colours represented on a scale of 50,100,200,300,400,500,600,700,800,900,950.’

Actual problem
Using online colour scale generators I found that if the colour was already ‘dark’ the values from 700 to 950 were all perceptually black and useless. So in a scale of 11 colours 3 or 4 would be useless.
Firstly I wanted the colour to be accurately placed in the scale , so if it was a light yellow it would place at 200 not 500. Secondly I wanted to be able to move the colour up and down the scale to be able to visually optimise the scale. The anchor hex never changes; but moving the anchor recomputes the surrounding swatches.
By about the fifteenth iteration we (Chat GPT and myself) were starting to understand what the app could do and act.
- Uses inline OKLab/OKLCH conversions
- Generates an 11-step OKLCH-based palette with a subtle chroma taper so the ends don’t collapse to white/black.
- Automatically places your input colour on the best anchor step.
- Preserves the exact anchor hex at the anchor position.
- Recomputes surrounding colours when you move the anchor (anchor hex stays the same).
- Highlights the anchor row and shows plain-text ▲ / ▼ arrow buttons (with tooltips) on that row to move the anchor up/down — the scale regenerates instantly.
- Supports saving multiple scales and exporting JSON.
Maths and OKLCH
By the time I had finished prototyping with Chat GPT, the scale was being calculated using OKLCH values, L (lightness), C (chroma), and H (hue). For some one who adores four colour separation and CMYK this was a steep learning curve.
The lightness is determined from the level of the ‘anchor’ colour. This colour remains the same and can be moved up and down the scale.
The chroma defines the vividness, intensity or “pureness” of the color similar to saturation. Increasing the chroma value makes a color more vivid and saturated, while decreasing it makes it duller. The ChatGPT model ‘tapers’ the chromo at each end of the scale. For some colours that may not work, if you want a pink to be vibrant all the way through?
The hue is just the colour of the rainbow on a colour wheel from 0 degrees to 360 degrees. Red is at 0 degree progressing through yellow, green, blue, and violet, with red appearing again at 360 degrees. So for example 120 degrees is green.
CSS-Tricks describes it this way which may help ‘Think of it as a 3D color cylinder: lightness moves up and down, chroma stretches outward from the center, and hue spins around the wheel.’
Keith J. Grant who is a principal software engineer at Red Hat has a great OKLCH slider on his page about OKLCH which does a much better job of illustrating OKLCH than words do. Go check it out.
If you want to deep dive into OKLCH smashing magazine have an ‘Interview With Björn Ottosson, Creator Of The Oklab Color Space‘. Who is very modest about his new colour space. He says, ‘I set out to create a simple color space that would be “okay”. I used an approach quite similar to IPT but combined it with the lightness and saturation estimates from CIECAM16. The resulting Oklab still has good hue uniformity but also handles lightness and saturation well.’
Also by the way Chat GPT is only using OKLCH for the maths behind the scenes to calculate the scales, the app converts it back to hex for the user anyway.
Iterations
I used Chat GPT to work out the best way of creating an app that could create colour scales from an initial set of existing brand colours. It was able to generate code examples which I could then preview in a browser.
After about twenty iterations I switched to FigmaMake. Asking chat GPT to create a final specification.
I did also hack some screenshots into a preferred layout in photoshop (old school) which I uploaded into Chat GPT but that only seemed to confuse things.
I suspect that FigmaMake will calculate the code differently to Chat GPT anyway.
Final Specification
I asked Chat GPT very politely. ‘Please can you write a prompt that describes all the features of the ‘Brand Colour Scale Builder Experiment’ for a prototyping tool like lovable or figma make. Focusing on the functionality and how the colour scales are calculated using an OKLCH-based generation with subtle chroma taper.’
Design and prototype an interactive web app called “Brand Colour Scale Builder Experiment.”
The goal is to help designers build balanced, perceptually uniform colour scales for brand palettes using OKLCH colour space with a subtle chroma taper.
1. Purpose and Core Functionality
The app generates colour scales from a single anchor colour (entered as a hex value).
It calculates a perceptually balanced 11-step scale (values 50–950) using OKLCH colour space, adjusting lightness (L*) evenly and tapering chroma (C) smoothly toward the lightest and darkest ends for natural tonal balance.
The anchor colour’s position in the scale determines where it sits (for example, at 500 or 700). Moving the anchor up or down adjusts the other tones accordingly, while the anchor itself remains unchanged.
This lets the user shift the tonal distribution depending on whether the anchor is midtone, dark, or light — while maintaining perceptual harmony.
2. Layout Overview
The interface has three main panels, with a fourth for export:
Panel 1: Input controls (top of screen, full width)
- Input field for hex value (e.g.
#004774) - Input field for colour name (e.g. “primary”)
- Buttons:
- “Generate” – builds the scale
- “Save palette” – saves current scale to the saved list
- “Reset” – resets to default
- “Export JSON” – opens the export panel
- “Clear Saved Palettes” – removes all saved scales
- When a valid colour is generated, display perceptual information:
- “Perceptual Lightness OK L*: 86.5 — placed at 300”
Panel 2: Generated Scale (left column)
- Displays 11 swatches arranged vertically, labelled 50–950.
- Each swatch shows:
- Colour block (64×64 px)
- Label (e.g. “300”)
- Hex value
- The anchor colour is outlined for clarity.
- Up/down arrow buttons move the anchor’s position in the scale, regenerating the surrounding tones using OKLCH-based interpolation.
Panel 3: Saved Palettes (right column)
- Each saved palette is a vertical column of 11 swatches, aligned horizontally next to each other.
- The palette name is shown below each column.
- Users can compare tonal values across multiple palettes easily — all 300s, 500s, etc. align horizontally for visual consistency.
Panel 4: Export JSON (below panels 2 and 3)
- Displays a formatted JSON text area showing all saved palettes, structured for direct import into Figma variable collections.
- Example output:
{ "french-lilac": { "50": "#fdf6fc", "100": "#fcebfc", "200": "#f6cff4", "300": "#f0b7ea", "400": "#e78bdc", "500": "#d75ec9", "600": "#ba3fa9", "700": "#9a3189", "800": "#7e2a6f", "900": "#68275c", "950": "#430f38" } } - Includes buttons:
- Copy (copies JSON to clipboard)
- Download (downloads
.jsonfile) - Close (hides export panel)
3. Visual Style
- Neutral, minimal design inspired by Massimo Vignelli’s systems thinking.
- Light grey background (
#f6f6f6) - White panels with rounded corners (
12px) - Soft shadows (
rgba(0,0,0,0.08)) - System UI font stack
- Left-aligned layout for clarity and readability
- Buttons: dark grey with white text, hover to near-black
4. Colour Generation Logic
Each scale is generated from an OKLCH colour model:
- The anchor colour defines base L (lightness), C (chroma), and h (hue).
- Eleven lightness steps (L*) are distributed between 0.95 (tints) and 0.05 (shades).
- The anchor’s position defines the offset and determines which step corresponds to the given base L*.
- Subtle chroma taper: chroma (C) gradually decreases toward the lightest and darkest ends using a cubic or exponential falloff, preventing oversaturation at highlights and shadows.
- Hue (h) remains constant for consistency.
This ensures each scale is perceptually balanced, uniform, and ready for UI use or brand variable systems.
5. Interactions and Behaviour
- The app updates dynamically as the anchor moves.
- Each interaction (generate, save, move anchor) updates both the visual and text data.
- Palettes persist in memory while the page is active.
- Export panel opens below the main content.
6. Goal
To create an intuitive, visually clear colour tool that allows designers to:
- Explore tonal systems based on OKLCH,
- Adjust colour perception interactively,
- Build consistent brand palettes,
- Export directly for implementation in design systems (e.g. Figma variables or CSS tokens).
7. Optional Enhancements (Future iterations)
- Tooltip on hover showing hex and OKLCH values
- Dark mode toggle
- Accessibility preview (contrast ratios)
- JSON schema validation for reimport
Final ChatGPT code
Here’s the fully merged, fully working, copy-ready HTML file — everything (HTML + CSS + JS) included and formatted cleanly so you can just paste it into a new file (e.g. brand-colour-scale.html), save, and open in your browser.
This version includes:
✅ OKLCH-based generation with subtle chroma taper
✅ Dark grey visible arrows
✅ Palette name + hex input
✅ Save, Clear, Export (copy + download)
✅ Panels aligned side-by-side (Generated + Saved)
✅ Export panel below both
✅ Consistent soft neutral styling
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Brand Colour Scale Builder Experiment</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #f6f6f6;
margin: 0;
padding: 20px;
color: #222;
}
h1 {
margin-top: 0;
font-size: 1.4em;
}
.panel {
background: white;
border-radius: 12px;
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
padding: 16px;
margin-bottom: 20px;
}
input, button, textarea {
font-size: 1rem;
}
input[type="text"] {
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 6px;
}
button {
background: #444;
color: white;
border: none;
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
}
button:hover {
background: #222;
}
.mainPanels {
display: flex;
gap: 20px;
align-items: flex-start;
flex-wrap: wrap;
}
.scaleList, .savedList {
display: flex;
align-items: flex-start;
}
.scaleList {
flex-direction: column;
}
.savedList {
flex-direction: row;
gap: 16px;
align-items: flex-start;
}
.scaleRow {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 6px;
padding: 4px 0;
}
.anchorHighlight {
outline: 2px solid #333;
border-radius: 4px;
}
.swatch {
width: 64px;
height: 64px;
border-radius: 4px;
margin-right: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.shadeLabel {
width: 40px;
font-size: 0.9em;
color: #555;
}
.hexVal {
font-family: monospace;
color: #333;
margin-right: 8px;
}
.arrowControls {
margin-left: auto;
display: flex;
flex-direction: column;
}
.arrowBtn {
font-size: 0.8em;
background: #666;
color: white;
border: none;
margin: 1px 0;
border-radius: 4px;
cursor: pointer;
width: 24px;
height: 20px;
}
.arrowBtn:hover {
background: #444;
}
.savedColumn {
display: flex;
flex-direction: column;
align-items: center;
}
.savedSwatch {
width: 64px;
height: 64px;
border-radius: 4px;
margin-bottom: 6px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.paletteName {
margin-top: 6px;
font-size: 0.9em;
color: #444;
text-align: center;
word-break: break-word;
max-width: 80px;
}
#exportArea {
display: none;
margin-top: 20px;
}
#exportBox {
width: 100%;
height: 300px;
font-family: monospace;
font-size: 0.9em;
background: #f0f0f0;
border-radius: 8px;
padding: 8px;
border: 1px solid #ccc;
}
.exportControls {
margin-top: 8px;
display: flex;
gap: 8px;
}
</style>
</head>
<body>
<h1>Brand Colour Scale Builder Experiment</h1>
<div class="panel">
<label>Base colour hex: <input id="hexInput" type="text" value="#004774"></label>
<label> Name: <input id="colourName" type="text" placeholder="e.g. primary" /></label>
<button id="generateBtn">Generate</button>
<button id="saveBtn">Save palette</button>
<button id="resetBtn">Reset</button>
<button id="exportBtn">Export JSON</button>
<button id="clearSaved">Clear Saved Palettes</button>
</div>
<div class="panel">
<div id="anchorInfo"></div>
</div>
<div class="mainPanels">
<div class="panel" style="flex:1;">
<h3>Generated Scale</h3>
<div id="scaleList" class="scaleList"></div>
</div>
<div class="panel" style="flex:1;">
<h3>Saved Palettes</h3>
<div id="savedList" class="savedList"></div>
</div>
</div>
<div id="exportArea" class="panel">
<h3>Exported JSON</h3>
<textarea id="exportBox" readonly></textarea>
<div class="exportControls">
<button id="copyBtn">Copy</button>
<button id="downloadBtn">Download</button>
<button id="closeExport">Close</button>
</div>
</div>
<script>
const labels = [50,100,200,300,400,500,600,700,800,900,950];
let currentAnchor = 5;
let currentHex = "#004774";
let savedPalettes = [];
function clamp(v, min=0, max=1){ return Math.min(max, Math.max(min,v)); }
function normalizeHex(raw){
if(!raw) return null;
let hex = raw.trim();
if(!hex.startsWith("#")) hex = "#" + hex;
if(/^#([0-9a-fA-F]{3})$/.test(hex)) hex = "#" + hex[1]+hex[1]+hex[2]+hex[2]+hex[3]+hex[3];
return /^#([0-9a-fA-F]{6})$/.test(hex) ? hex.toLowerCase() : null;
}
function hexToRgb(hex){ const h=hex.replace("#",""); return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)]; }
function rgbToHex([r,g,b]){ return "#" + [r,g,b].map(v=>v.toString(16).padStart(2,"0")).join(""); }
function srgbToLinear(v){v=v/255;return v<=0.04045?v/12.92:Math.pow((v+0.055)/1.055,2.4);}
function linearToSrgb(v){return v<=0.0031308?12.92*v:1.055*Math.pow(v,1/2.4)-0.055;}
function rgbToOklab(r8,g8,b8){
const r=srgbToLinear(r8),g=srgbToLinear(g8),b=srgbToLinear(b8);
const l=0.4122214708*r+0.5363325363*g+0.0514459929*b;
const m=0.2119034982*r+0.6806995451*g+0.1073969566*b;
const s=0.0883024619*r+0.2817188376*g+0.6299787005*b;
const l_=Math.cbrt(l),m_=Math.cbrt(m),s_=Math.cbrt(s);
return [0.2104542553*l_+0.7936177850*m_-0.0040720468*s_,1.9779984951*l_-2.4285922050*m_+0.4505937099*s_,0.0259040371*l_+0.7827717662*m_-0.8086757660*s_];
}
function oklabToRgb(L,a,b){
const l_=L+0.3963377774*a+0.2158037573*b,m_=L-0.1055613458*a-0.0638541728*b,s_=L-0.0894841775*a-1.2914855480*b;
const l=l_*l_*l_,m=m_*m_*m_,s=s_*s_*s_;
const R=+4.0767416621*l-3.3077115913*m+0.2309699292*s;
const G=-1.2684380046*l+2.6097574011*m-0.3413193965*s;
const B=-0.0041960863*l-0.7034186147*m+1.7076147010*s;
return [Math.round(clamp(linearToSrgb(R),0,1)*255),Math.round(clamp(linearToSrgb(G),0,1)*255),Math.round(clamp(linearToSrgb(B),0,1)*255)];
}
function oklabToOklch(L,a,b){const C=Math.sqrt(a*a+b*b);let h=Math.atan2(b,a)*180/Math.PI;if(h<0)h+=360;return [L,C,h];}
function oklchToOklab(L,C,h){const r=h*Math.PI/180;return [L,Math.cos(r)*C,Math.sin(r)*C];}
function hexToOklch(hex){const [r,g,b]=hexToRgb(hex);return oklabToOklch(...rgbToOklab(r,g,b));}
function oklchToHex(L,C,h){return rgbToHex(oklabToRgb(...oklchToOklab(L,C,h)));}
function chooseAnchorIndexFromL(L){const preliminary=Math.round((1-Math.pow(L,0.85))*10);return clamp(preliminary,0,10);}
function generateOKLCHScale(baseHex,anchorIdx){
const [baseL,baseC,baseH]=hexToOklch(baseHex);
const topL=0.95,bottomL=0.05;
const ramp=Array.from({length:11},(_,i)=>topL*(1-i/10)+bottomL*(i/10));
const offset=baseL-ramp[anchorIdx];
const adjustedL=ramp.map(L=>clamp(L+offset,0.02,0.98));
const maxDistance=5,taperStrength=0.6,taperExponent=1.05;
const hexes=adjustedL.map((L,i)=>{
const distance=Math.abs(i-anchorIdx);
const norm=Math.min(maxDistance,distance)/maxDistance;
const factor=1-taperStrength*Math.pow(norm,taperExponent);
const C=baseC*factor;
return oklchToHex(L,Math.max(0.0001,C),baseH);
});
hexes[anchorIdx]=baseHex;
return hexes;
}
const hexInput=document.getElementById("hexInput");
const colourName=document.getElementById("colourName");
const scaleList=document.getElementById("scaleList");
const anchorInfo=document.getElementById("anchorInfo");
const savedList=document.getElementById("savedList");
const exportArea=document.getElementById("exportArea");
const exportBox=document.getElementById("exportBox");
function renderScale(hex){
const norm=normalizeHex(hex);
if(!norm){alert("Enter valid hex");return;}
currentHex=norm;
const [L]=hexToOklch(norm);
currentAnchor=chooseAnchorIndexFromL(L);
const hexes=generateOKLCHScale(norm,currentAnchor);
anchorInfo.innerHTML=`The perceptual lightness of the anchor colour <strong>${norm}</strong> is (OK L*): <strong>${(L*100).toFixed(1)}</strong> — placed at <strong>${labels[currentAnchor]}</strong>.`;
drawRows(hexes,currentAnchor);
}
function drawRows(hexes,anchorIdx){
scaleList.innerHTML="";
hexes.forEach((hx,i)=>{
const row=document.createElement("div");
row.className="scaleRow"+(i===anchorIdx?" anchorHighlight":"");
const sw=document.createElement("div");
sw.className="swatch";
sw.style.background=hx;
const lbl=document.createElement("div");
lbl.className="shadeLabel";
lbl.textContent=labels[i];
const hexv=document.createElement("div");
hexv.className="hexVal";
hexv.textContent=hx;
row.append(sw,lbl,hexv);
if(i===anchorIdx){
const arrows=document.createElement("div");
arrows.className="arrowControls";
const up=document.createElement("button");
up.className="arrowBtn"; up.textContent="▲";
up.onclick=e=>{e.stopPropagation();moveAnchor(-1);};
const down=document.createElement("button");
down.className="arrowBtn"; down.textContent="▼";
down.onclick=e=>{e.stopPropagation();moveAnchor(1);};
arrows.append(up,down);
row.appendChild(arrows);
}
row.onclick=()=>{currentAnchor=i;drawRows(generateOKLCHScale(currentHex,currentAnchor),currentAnchor);};
scaleList.appendChild(row);
});
}
function moveAnchor(delta){
currentAnchor=clamp(currentAnchor+delta,0,10);
drawRows(generateOKLCHScale(currentHex,currentAnchor),currentAnchor);
}
document.getElementById("generateBtn").onclick=()=>renderScale(hexInput.value);
document.getElementById("resetBtn").onclick=()=>{hexInput.value="#004774";colourName.value="";renderScale("#004774");};
document.getElementById("saveBtn").onclick=()=>{
const name=colourName.value.trim()||"unnamed";
const hexes=generateOKLCHScale(currentHex,currentAnchor);
savedPalettes.push({name,hexes});
updateSaved();
};
document.getElementById("clearSaved").onclick=()=>{
if(confirm("Clear all saved palettes?")){savedPalettes=[];updateSaved();}
};
function updateSaved(){
savedList.innerHTML="";
savedPalettes.forEach(p=>{
const col=document.createElement("div");
col.className="savedColumn";
p.hexes.forEach(h=>{
const sw=document.createElement("div");
sw.className="savedSwatch";
sw.style.background=h;
col.appendChild(sw);
});
const lbl=document.createElement("div");
lbl.className="paletteName";
lbl.textContent=p.name;
col.appendChild(lbl);
savedList.appendChild(col);
});
}
document.getElementById("exportBtn").onclick=()=>{
if(!savedPalettes.length){alert("No palettes saved");return;}
const obj={};
savedPalettes.forEach(p=>{
obj[p.name]={};
p.hexes.forEach((h,i)=>{obj[p.name][labels[i]]=h;});
});
const json=JSON.stringify(obj,null,2);
exportBox.value=json;
exportArea.style.display="block";
};
document.getElementById("copyBtn").onclick=()=>{
exportBox.select();
document.execCommand("copy");
alert("Copied JSON to clipboard");
};
document.getElementById("downloadBtn").onclick=()=>{
const blob=new Blob([exportBox.value],{type:"application/json"});
const a=document.createElement("a");
a.href=URL.createObjectURL(blob);
a.download="brand-palettes.json";
a.click();
};
document.getElementById("closeExport").onclick=()=>{exportArea.style.display="none";};
renderScale("#004774");
</script>
</body>
</html>

Conclusion
Chat GPT was great for helping me develop an initial concept but I was unable to control the styling and layout as much as I wanted. After about 15 variations the styling seemed to begin unwinding with each iteration looking worse.

Creating a frankenstein monster. In the 1931 film Frankenstein, Henry Frankenstein shouts it after his creation stirs to life, “Look! It’s moving. It’s alive. It’s alive… It’s alive, it’s moving, it’s alive, it’s alive, it’s alive, it’s alive, IT’S ALIVE!”. He then follows this by saying, “Oh, in the name of God! Now I know what it feels like to be God!”. Or in my case a front end developer. The amazement at actually being able to have working code completely beyond my ability and understanding is remarkable. The downside is I don’t understand the code it could be monstrous. The further downside is am I depriving my dev colleagues of work?
There is also a red herring in the styling, Chat GPT pulled in ‘Neutral, minimal design inspired by Massimo Vignelli’s systems thinking’ from a previous product prompt. The only thing it taught me last time was that Chat GPT interprets Vignelli’s style very differently to me. It might almost be an anti-prompt.