Skip to content

Commit 8a2a855

Browse files
committed
feat: 🎨 Support HSL, LCH, LAB color format and a lot of code refactor
1 parent d2e2bbe commit 8a2a855

File tree

4 files changed

+107
-143
lines changed

4 files changed

+107
-143
lines changed

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@
1919
"import": "./dist/plugin.js",
2020
"require": "./dist/plugin.cjs"
2121
},
22+
"dependencies": {
23+
"color-convert": "^2.0.1"
24+
},
2225
"devDependencies": {
2326
"@biomejs/biome": "1.4.1",
27+
"@types/color-convert": "^2.0.3",
2428
"@types/node": "^20.10.2",
2529
"tsup": "^8.0.1",
2630
"typescript": "^5.3.2"

pnpm-lock.yaml

Lines changed: 27 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/plugin.ts

Lines changed: 64 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,9 @@
1+
import { hex, hsl, rgb } from "color-convert";
12
import plugin from "tailwindcss/plugin";
2-
import { ThemeConfig } from "tailwindcss/types/config";
3-
import {
4-
type ColorTuple,
5-
type HexColor,
6-
type Modifier,
7-
type Variants,
8-
hexToRgb,
9-
hslToRgb,
10-
invertColor,
11-
rgbToHsl,
12-
shadeModifier,
13-
tintModifier,
14-
variants,
15-
} from "./utils";
3+
import { type ThemeConfig } from "tailwindcss/types/config";
4+
import { ColorTuple, invertColor, isColorDark, shadeModifier, tintModifier } from "./utils";
165

6+
type HexColor = `#${string}`;
177
type Plugin = ReturnType<typeof plugin>;
188
export type RealtimeColorOptions = {
199
colors: {
@@ -27,84 +17,118 @@ export type RealtimeColorOptions = {
2717
shades: (keyof RealtimeColorOptions["colors"])[];
2818
prefix: string;
2919
shadeAlgorithm: keyof typeof availableModifiers;
20+
colorFormat: "rgb" | "hsl" | "lch" | "lab";
3021
};
3122
type RealtimeColorOptionsWithoutColor = Omit<RealtimeColorOptions, "colors">;
3223

33-
const isDarkMode = (color: HexColor) => {
34-
const l = rgbToHsl(hexToRgb(color))[2];
35-
return l > 50;
36-
};
37-
24+
const variants = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
3825
const availableModifiers = {
3926
tailwind: {
4027
50: tintModifier(0.95),
4128
100: tintModifier(0.9),
4229
200: tintModifier(0.75),
4330
300: tintModifier(0.6),
4431
400: tintModifier(0.3),
45-
500: (c: ColorTuple<"RGB">) => c,
32+
500: (c: ColorTuple) => c,
4633
600: shadeModifier(0.9),
4734
700: shadeModifier(0.6),
4835
800: shadeModifier(0.45),
4936
900: shadeModifier(0.3),
5037
950: shadeModifier(0.2),
51-
} as Record<Variants, Modifier<"RGB">>,
38+
},
5239

5340
realtimeColors: Object.fromEntries(
5441
variants.map((variant) => [
5542
variant,
56-
(rgb) => {
57-
const [h, s, _l] = rgbToHsl(rgb);
58-
return hslToRgb([h, s, 100 - variant / 10]);
43+
(color: ColorTuple) => {
44+
const [h, s] = rgb.hsl(color);
45+
return hsl.rgb([h, s, 100 - variant / 10]);
5946
},
6047
]),
61-
) as Record<Variants, Modifier<"RGB">>,
48+
),
6249
};
6350

51+
const formatRGBColor = (color: ColorTuple, to: RealtimeColorOptions["colorFormat"]) => {
52+
switch (to) {
53+
case "rgb": {
54+
const [r, g, b] = color;
55+
return `${r}, ${g}, ${b}`;
56+
}
57+
case "hsl": {
58+
const [h, s, l] = rgb.hsl(color);
59+
return `${h} ${s}% ${l}%`;
60+
}
61+
case "lab": {
62+
const [l, a, b] = rgb.lab.raw(color).map((c) => c.toFixed(2));
63+
return `${l}% ${a} ${b}`;
64+
}
65+
case "lch": {
66+
const [l, c, h] = rgb.lch.raw(color).map((c) => c.toFixed(2));
67+
return `${l}% ${c} ${h}`;
68+
}
69+
}
70+
};
71+
72+
const wrapInFunction = (color: string, type: RealtimeColorOptions["colorFormat"]) =>
73+
// For some reason rgb() doesn't work with `/ <alpha-value>`
74+
type === "rgb" ? `rgba(${color})` : `${type}(${color} / <alpha-value>)`;
75+
6476
const getCSS = (config: RealtimeColorOptions) => {
6577
const { theme, shades, prefix, colors } = config;
6678
if (!theme) return [];
6779
const modifiers = availableModifiers[config.shadeAlgorithm];
6880
const variables: Record<string, string> = {};
6981
const altVariables: Record<string, string> = {};
7082
for (const [colorName, color] of Object.entries(colors)) {
83+
const rgbColor = hex.rgb(color);
7184
if (shades.includes(colorName as keyof typeof colors)) {
72-
const rgb = hexToRgb(color);
7385
for (const [variant, modifier] of Object.entries(modifiers)) {
74-
variables[`--${prefix}${colorName}-${variant}`] = modifier(rgb).join(",");
75-
altVariables[`--${prefix}${colorName}-${variant}`] = invertColor(modifier(rgb)).join(",");
86+
variables[`--${prefix}${colorName}-${variant}`] = formatRGBColor(
87+
modifier(rgbColor),
88+
config.colorFormat,
89+
);
90+
altVariables[`--${prefix}${colorName}-${variant}`] = formatRGBColor(
91+
invertColor(modifier(rgbColor)),
92+
config.colorFormat,
93+
);
7694
}
7795
} else {
78-
variables[`--${prefix}${colorName}`] = hexToRgb(color).join(", ");
79-
altVariables[`--${prefix}${colorName}`] = invertColor(hexToRgb(color)).join(", ");
96+
variables[`--${prefix}${colorName}`] = formatRGBColor(rgbColor, config.colorFormat);
97+
altVariables[`--${prefix}${colorName}`] = formatRGBColor(
98+
invertColor(rgbColor),
99+
config.colorFormat,
100+
);
80101
}
81102
}
82-
const isDark = isDarkMode(colors.background);
103+
const isDark = isColorDark(colors.background);
83104
return [
84105
{
85106
":root": isDark ? variables : altVariables,
86107
":is(.dark):root": isDark ? altVariables : variables,
87108
},
88109
];
89110
};
90-
91111
const getTheme = (config: RealtimeColorOptions) => {
92112
const { theme, shades, prefix, colors } = config;
93113
const colorsTheme: ThemeConfig["colors"] = {};
94114
const modifiers = availableModifiers[config.shadeAlgorithm];
95115
for (const [colorName, color] of Object.entries(colors)) {
116+
const rgbColor = hex.rgb(color);
96117
if (shades.includes(colorName as keyof typeof colors)) {
97-
const rgb = hexToRgb(color);
98118
colorsTheme[`${prefix}${colorName}`] = {};
99119
for (const [variant, modifier] of Object.entries(modifiers)) {
100-
(colorsTheme[`${prefix}${colorName}`] as Record<string, string>)[variant] = `rgba(${
101-
theme ? `var(--${prefix}${colorName}-${variant})` : modifier(rgb).join(", ")
102-
})`;
120+
(colorsTheme[`${prefix}${colorName}`] as Record<string, string>)[variant] = wrapInFunction(
121+
theme
122+
? `var(--${prefix}${colorName}-${variant})`
123+
: formatRGBColor(modifier(rgbColor), config.colorFormat),
124+
config.colorFormat,
125+
);
103126
}
104127
} else {
105-
colorsTheme[`${prefix}${colorName}`] = `rgba(${
106-
theme ? `var(--${prefix}${colorName})` : hexToRgb(color).join(", ")
107-
})`;
128+
colorsTheme[`${prefix}${colorName}`] = wrapInFunction(
129+
theme ? `var(--${prefix}${colorName})` : formatRGBColor(rgbColor, config.colorFormat),
130+
config.colorFormat,
131+
);
108132
}
109133
}
110134
return colorsTheme;
@@ -139,6 +163,7 @@ function realtimeColors(
139163
shades: ["primary", "secondary", "accent"],
140164
prefix: "",
141165
shadeAlgorithm: "tailwind",
166+
colorFormat: "rgb",
142167
};
143168

144169
if (typeof configOrUrl === "string") {

src/utils.ts

Lines changed: 12 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,9 @@
1-
export type ColorTuple<_ extends "RGB" | "HSL" | ""> = [number, number, number];
2-
export type HexColor = `#${string}`;
3-
export type Modifier<T extends "RGB" | "HSL" | ""> = (rgb: ColorTuple<T>) => ColorTuple<T>;
4-
export type Variants = (typeof variants)[number];
1+
import { hex, rgb } from "color-convert";
52

6-
export const variants = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
3+
export type ColorTuple = [number, number, number];
74

8-
export const hexToRgb = (color: HexColor) => {
9-
if (typeof color !== "string" || !color.startsWith("#")) {
10-
throw new TypeError("Color should be a hex string.");
11-
}
12-
13-
const hexMatch = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color);
14-
if (hexMatch) {
15-
return hexMatch.splice(1).map((c) => parseInt(c, 16)) as ColorTuple<"RGB">;
16-
}
17-
18-
const hexMatchShort = /^#?([\da-f])([\da-f])([\da-f])$/i.exec(color);
19-
if (hexMatchShort) {
20-
return hexMatchShort.splice(1).map((c) => parseInt(c + c, 16)) as ColorTuple<"RGB">;
21-
}
22-
23-
throw new Error("Invalid color format, Use hex color.");
24-
};
25-
26-
export const rgbToHex = (color: ColorTuple<"RGB">) => {
27-
return `#${color.map((c) => c.toString(16).padStart(2, "0")).join("")}` as HexColor;
28-
};
29-
30-
export const rgbToHsl = (color: ColorTuple<"RGB">) => {
31-
const [r, g, b] = color.map((c) => c / 255);
32-
33-
const max = Math.max(r, g, b);
34-
const min = Math.min(r, g, b);
35-
36-
let h = 0;
37-
let s = 0;
38-
const l = (max + min) / 2;
39-
40-
if (max === min) {
41-
h = s = 0;
42-
} else {
43-
const d = max - min;
44-
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
45-
switch (max) {
46-
case r:
47-
h = (g - b) / d + (g < b ? 6 : 0);
48-
break;
49-
case g:
50-
h = (b - r) / d + 2;
51-
break;
52-
case b:
53-
h = (r - g) / d + 4;
54-
break;
55-
}
56-
h /= 6;
57-
}
58-
59-
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)] as ColorTuple<"HSL">;
60-
};
61-
62-
export const hslToRgb = (color: ColorTuple<"HSL">) => {
63-
let [h, s, l] = color;
64-
65-
h = h / 360;
66-
s = s / 100;
67-
l = l / 100;
68-
69-
const hueToRgb = ([p, q, t]: ColorTuple<"">) => {
70-
if (t < 0) t += 1;
71-
if (t > 1) t += 1;
72-
if (t < 1 / 6) return p + (q - p) * 6 * t;
73-
if (t < 1 / 2) return q;
74-
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
75-
return p;
76-
};
77-
78-
let r;
79-
let g;
80-
let b;
81-
82-
if (s === 0) {
83-
r = g = b = l;
84-
} else {
85-
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
86-
const p = 2 * l - q;
87-
r = hueToRgb([p, q, h + 1 / 3]);
88-
g = hueToRgb([p, q, h]);
89-
b = hueToRgb([p, q, h - 1 / 3]);
90-
}
91-
92-
return [r, g, b].map((c) => Math.round(c * 255)) as ColorTuple<"RGB">;
93-
};
94-
95-
export const invertColor: Modifier<"RGB"> = (color) => {
96-
let [h, s, l] = rgbToHsl(color);
5+
export const invertColor = (color: ColorTuple) => {
6+
let [h, s, l] = rgb.hsl(color);
977
l = 100 - l;
988
h /= 360;
999
s /= 100;
@@ -105,7 +15,7 @@ export const invertColor: Modifier<"RGB"> = (color) => {
10515

10616
if (s === 0) r = g = b = Math.round(l * 255);
10717
else {
108-
const modifier = ([a, b, c]: ColorTuple<"">) => {
18+
const modifier = ([a, b, c]: ColorTuple) => {
10919
if (c < 0) c += 1;
11020
if (c > 1) c -= 1;
11121
return c < 1 / 6
@@ -124,15 +34,13 @@ export const invertColor: Modifier<"RGB"> = (color) => {
12434
b = Math.round(modifier([m, n, h - 1 / 3]) * 255);
12535
}
12636

127-
return [r, g, b];
37+
return [r, g, b] as ColorTuple;
12838
};
12939

130-
export const tintModifier =
131-
(intensity: number): Modifier<"RGB"> =>
132-
(rgb) =>
133-
rgb.map((c) => Math.round(c + (255 - c) * intensity)) as ColorTuple<"RGB">;
40+
export const tintModifier = (intensity: number) => (rgb: ColorTuple) =>
41+
rgb.map((c) => Math.round(c + (255 - c) * intensity)) as ColorTuple;
42+
43+
export const shadeModifier = (intensity: number) => (rgb: ColorTuple) =>
44+
rgb.map((c) => Math.round(c * intensity)) as ColorTuple;
13445

135-
export const shadeModifier =
136-
(intensity: number): Modifier<"RGB"> =>
137-
(rgb) =>
138-
rgb.map((c) => Math.round(c * intensity)) as ColorTuple<"RGB">;
46+
export const isColorDark = (color: string) => hex.hsl(color)[2] > 50;

0 commit comments

Comments
 (0)