Created
March 22, 2024 11:54
-
-
Save domlen2003/4b1251de64387496284cc7e6802a602b to your computer and use it in GitHub Desktop.
A tailwind color system, with completely computational max continuous chroma oklch colors.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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