Skip to content

Instantly share code, notes, and snippets.

@domlen2003
Created March 22, 2024 11:54
Show Gist options
  • Save domlen2003/4b1251de64387496284cc7e6802a602b to your computer and use it in GitHub Desktop.
Save domlen2003/4b1251de64387496284cc7e6802a602b to your computer and use it in GitHub Desktop.
A tailwind color system, with completely computational max continuous chroma oklch colors.
import colors from 'tailwindcss/colors';
import type { Coords } from 'colorjs.io';
import Color from 'colorjs.io';
/**
* Simple range function to generate an array of numbers
* @param lengthOrStart the length of the array or the start value if end is provided
* @param end the (optional) end value
* @returns an array of numbers in this range
*/
function range(lengthOrStart: number, end?: number) {
if (end === undefined) {
end = lengthOrStart;
lengthOrStart = 0;
}
return [...Array(end - lengthOrStart).keys()].map(i => i + lengthOrStart);
}
/**
* Check if a string is a valid hex color
* @param hex the string to check
* @returns true if the string is a valid hex color
*/
const isHex = (hex: any): hex is string => hex && typeof hex === 'string' && /^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/.test(hex);
/**
* Convert a hex color to oklch
* @param hex the hex color to convert
* @returns the oklch color coordinates
*/
const hexToOklch = (hex: any): Coords | null => {
if (!isHex(hex)) return null;
return new Color(Color.parse(hex)).to('oklch').coords;
};
/**
* Removes duplicates from an array with deep equality
* @param arr the array to remove duplicates from
* @returns the array without duplicates
*/
const uniq = <T>(arr: T[]): T[] => {
// Convert each array to a JSON string and use a Set to remove duplicates,
// then convert each string back to an array.
return [...new Set(arr.map(val => JSON.stringify(val)))].map(val => JSON.parse(val));
};
/**
* Compute average lightness of all tailwind colors for each shade
* @param color whether to get the average of colors or grays
* @returns the average lightness array
*/
const getTwShadesAverage = (color: boolean) => {
const shades = uniq(
Object.entries(colors).map(([_, shades]) => {
return Object.entries(shades)
.map(([_, color]) => hexToOklch(color as any))
.filter((color) => !!color);
})
).filter((shades) => shades && shades.length > 0)
.map(shades => shades as Coords[])
.filter((shades) => (color === shades[Math.floor(shades.length / 2)][1] * 100 > 5)) // filter out grays/colors depending on the color flag
.map((shades) => shades.map(([lightness, _chr, _hue]) => lightness))
.reduce((acc: { sum: number[], count: number }, cur: number[]) => {
return {
sum: cur.map((val, index) => (acc.sum[index] ?? 0) + val),
count: acc.count + (cur.length > 1 ? 1 : 0)
};
}, { sum: [], count: 0 });
return shades.sum.map((val) => val / shades.count);
};
/**
* Compute maximum chroma for a given lightness and hue in a given color space
* @param lightness the lightness value
* @param hue the hue value
* @param colorSpace the color space to check gamut in
* @returns the maximum chroma
*/
export const getMaxChroma = (lightness: number, hue: number, colorSpace = 'rec2020') => {
let maxChroma = 0;
let step = 0.1; // Initial step size for chroma adjustment
while (step >= 0.0001) {
// Increase chroma by step
maxChroma += step;
// Check if the color is in gamut for the given color space
const inGamut = new Color({ space: 'oklch', coords: [lightness, maxChroma, hue] })
.to(colorSpace)
.inGamut();
if (!inGamut) {
// If out of gamut, step back and reduce step size
maxChroma -= step;
step /= 10;
}
}
return maxChroma;
};
/**
* Compute maximum continuous chroma for a given lightness in a given color space
* @param lightness the lightness value
* @param colorSpace the color space to check gamut in
* @returns the maximum continuous chroma
*/
export const getMaxContinuousChroma = (lightness: number, colorSpace = 'rec2020') => {
let maxChroma = 1;
for (const hue of range(0, 360)) {
const inGamut = new Color({ space: 'oklch', coords: [lightness, maxChroma, hue] }).to(colorSpace).inGamut();
if (!inGamut) {
// If out of gamut, calculate max chroma for this hue
maxChroma = getMaxChroma(lightness, hue, colorSpace);
}
}
return maxChroma;
};
/**
* Tailwind lightness step names
*/
const tailwindSteps = Object.entries(colors.red).map(([name, _]) => parseInt(name));
/**
* Number of tailwind lightness steps
*/
const tailwindStepCount = tailwindSteps.length;
/**
* Lightness values for Tailwind colors, see: https://gist.github.com/domlen2003/3bedc3af057880c0162b3964f0c1f20e
*/
const lightnessLookup: number[] = getTwShadesAverage(true);
/**
* Lightness values for Tailwind grays
*/
const lightnessLookupGrays: number[] = getTwShadesAverage(false);
/**
* Chroma values for Tailwind colors based on max continuous chroma
*/
const chromaLookup: number[] = lightnessLookup.map((lightness) => getMaxContinuousChroma(lightness));
/**
* Generate a colors shades based on a custom color definition
* @param color the custom color definition
* @returns the generated color palette
*/
function generateColor(color: CustomColor) {
return range(tailwindStepCount)
.map(i => {
const adjustedLightness = color.lightnessAdjustment !== undefined ?
typeof color.lightnessAdjustment === 'function' ?
color.lightnessAdjustment(i, lightnessLookup[i]) :
color.lightnessAdjustment :
lightnessLookup[i];
const fixedLightness = typeof adjustedLightness === 'number' ? adjustedLightness.toFixed(2) : adjustedLightness;
const adjustedChroma = color.chromaAdjustment !== undefined ?
typeof color.chromaAdjustment === 'function' ?
color.chromaAdjustment(i, chromaLookup[i]) :
color.chromaAdjustment :
chromaLookup[i];
const fixedChroma = typeof adjustedChroma === 'number' ? adjustedChroma.toFixed(4) : adjustedChroma;
const fixedHue = typeof color.hue === 'number' ? color.hue.toFixed(2) : color.hue;
return `oklch(${fixedLightness} ${fixedChroma} ${fixedHue})`;
})
.reduce((acc: any, cur, index) => {
acc[tailwindSteps[index]] = cur;
return acc;
}, {});
}
/**
* Common adjustments for colors
*/
export const adj = {
/**
* Adjustment for Tailwind surface colors
*/
twSurfaceL: (index, _) => lightnessLookupGrays[index]
} satisfies Record<string, Adjustment>;
/**
* Generate a color palette based on a custom palette definition
* @param palette the custom colors with their name
* @returns the generated color palette
*/
export function generatePalette(palette: Palette) {
if (tailwindStepCount !== lightnessLookup.length || tailwindStepCount !== chromaLookup.length) {
throw new Error('The number of lightness/chroma steps must match the number of tailwind lightness steps');
}
return Object.entries(palette).reduce((acc, [name, color]) => ({ [name]: generateColor(color), ...acc }), {});
}
/**
* Calculate the chroma gain ration for a reference color and its corresponding lookupIndex
* i.e. how much more/less chroma the reference color has compared to the lookup table
* @param referenceChroma the chroma of the reference color
* @param lookupIndex the index of the reference color
* @returns the chroma gain ration
*/
function calcRefGain(referenceChroma: number, lookupIndex: number) {
return referenceChroma / chromaLookup[lookupIndex];
}
/**
* Generate a new color palette based on the Tailwind colors but with continuous chroma and minimal chromaGain
* @returns the generated color palette
*/
export function generateNewTwPalette() {
return Object.entries(colors).map(([name, shades]) => {
const referenceIndex = Math.floor(tailwindStepCount / 2) - 1;
const referenceOklch = hexToOklch(Object.entries(shades).map(([_, color]) => color).at(referenceIndex));
if (!referenceOklch) return null;
const [_, referenceChroma, referenceHue] = referenceOklch;
const customColor: CustomColor = referenceChroma * 100 < 5 ?//just a good guess -> no actual color would have this low of a chroma
// if surfaceColor we adjust the chroma to the reference chroma and apply the surface lightness adjustment
{
hue: isNaN(referenceHue) ? 0 : referenceHue,
chromaAdjustment: isNaN(referenceHue) ? 0 : (_, defaultValue) => defaultValue * calcRefGain(referenceChroma, referenceIndex),
lightnessAdjustment: adj.twSurfaceL
} :
{
hue: referenceHue,
// Capping chroma boost to 1.2 to avoid oversaturation
chromaAdjustment: (index, defaultValue) => defaultValue * Math.min(1.2, calcRefGain(referenceChroma, referenceIndex))
};
return { name, color: customColor };
}).reduce((acc, color) => {
if (!color) return acc;
return { ['new-' + color.name]: generateColor(color.color), ...acc };
}, {});
}
/**
* Generates a safelist for a given palette for bg colors, so the tests/colors site works correctly
*/
export function generateBgSafelist(palette: Palette) {
return [
...Object.entries(colors).flatMap(([name, _]) => tailwindSteps.map(i => `bg-${name}-${i}`)),
...Object.entries(colors).flatMap(([name, _]) => tailwindSteps.map(i => `bg-new-${name}-${i}`)),
...Object.entries(palette).flatMap(([name, _]) => tailwindSteps.map(i => `bg-${name}-${i}`))
];
}
//--- Api Types ---
type CustomColor = {
hue: number | string;
chromaAdjustment?: Adjustment;
lightnessAdjustment?: Adjustment;
};
type Adjustment = ((index: number, defaultValue: number) => number) | number | string | undefined
export type Palette = Record<string, CustomColor>;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment