(
hex: string,
{
darkMode = false,
background = darkMode ? DARK_BASE : LIGHT_BASE,
foreground = darkMode ? LIGHT_BASE : DARK_BASE,
mix,
}: ColorScaleOptions = {}
)
| 174 | * @param {object} options |
| 175 | */ |
| 176 | export function colorScale( |
| 177 | hex: string, |
| 178 | { |
| 179 | darkMode = false, |
| 180 | background = darkMode ? DARK_BASE : LIGHT_BASE, |
| 181 | foreground = darkMode ? LIGHT_BASE : DARK_BASE, |
| 182 | mix, |
| 183 | }: ColorScaleOptions = {} |
| 184 | ) { |
| 185 | const baseColor = rgbToOklch(hexToRgbArray(hex)); |
| 186 | const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null; |
| 187 | const foregroundColor = rgbToOklch(hexToRgbArray(foreground)); |
| 188 | const backgroundColor = rgbToOklch(hexToRgbArray(background)); |
| 189 | let mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light; |
| 190 | |
| 191 | if (mixColor && mix?.ratio && mix.ratio > 0) { |
| 192 | // If defined, we mix in a (tiny) bit of the mix color with the base color. |
| 193 | baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio); |
| 194 | baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio); |
| 195 | baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H; |
| 196 | } |
| 197 | |
| 198 | if ( |
| 199 | (darkMode && baseColor.L < backgroundColor.L) || |
| 200 | (!darkMode && baseColor.L > backgroundColor.L) |
| 201 | ) { |
| 202 | // If the supplied color is outside of our lightness bounds, use the supplied color's lightness. |
| 203 | // This is mostly used to allow darker-than-dark backgrounds for brands that specifically want that look. |
| 204 | const difference = (backgroundColor.L - baseColor.L) / backgroundColor.L; |
| 205 | backgroundColor.L = baseColor.L; |
| 206 | // At the edges of the scale, the subtle lightness changes stop being perceptible. We need to amp up our mapping to still stand out. |
| 207 | const amplifier = 1; |
| 208 | mapping = mapping.map((step, index) => |
| 209 | index < 9 ? step + step * amplifier * difference : step |
| 210 | ); |
| 211 | } |
| 212 | |
| 213 | const result = []; |
| 214 | |
| 215 | for (let index = 0; index < mapping.length; index++) { |
| 216 | const step = mapping[index]!; |
| 217 | const targetL = foregroundColor.L * step + backgroundColor.L * (1 - step); |
| 218 | |
| 219 | if ( |
| 220 | index === 8 && |
| 221 | !mix && |
| 222 | (darkMode ? targetL - baseColor.L < 0.2 : baseColor.L - targetL < 0.2) |
| 223 | ) { |
| 224 | // Original colour is close enough to target, so let's use the original colour as step 9. |
| 225 | result.push(hex); |
| 226 | continue; |
| 227 | } |
| 228 | |
| 229 | const chromaRatio = (() => { |
| 230 | switch (index) { |
| 231 | // Step 9 and 10 have max chroma, meaning they are fully saturated. |
| 232 | case 8: |
| 233 | case 9: |
no test coverage detected