Skip to content

Commit

Permalink
Refactor tailwind-lerp-colors as a function rather than a plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
brandonmcconnell committed Jul 7, 2022
1 parent 911800c commit d750270
Show file tree
Hide file tree
Showing 3 changed files with 156 additions and 131 deletions.
72 changes: 50 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,57 +27,85 @@
<hr />

## Installation
Install the plugin from npm:
Install the function from npm:
```bash
npm i -D tailwind-lerp-colors
```

Then add the plugin to your `tailwind.config.js` file. It's import to add `tailwind-lerp-colors` **first** within the Tailwind config plugins array to instantiate the new colors before other plugins or config options can reach them:
Then add the function to your `tailwind.config.js` file. It's recommended to use `tailwind-lerp-colors` in `theme.colors` and NOT `theme.extend.colors` as the function will only interpolate colors passed into its object, and using `theme.extend.colors` generally implies you're relying on leaving `theme.colors` as-is to keep the standard Tailwind colors. That route, the standard colors would not be interpolate dalong with your custom colors.

As a convenience, there is a `includeBase` property which can be passed into the `options` (though it's enabled by default) which automate importing Tailwind's base colors, so you do not need to pass them in manually.
As a convenience, there is a `includeBase` property which can be passed into the `options` (though it's enabled by default) which automatically imports Tailwind's base colors so you don't need to pass them in manually.

Also, if you rely on some of Tailwind's legacy color names (`lightBlue`, `warmGray`, `trueGray`, `coolGray`, `blueGray`), you can enable the `includeLegacy` property by setting it to `true` to include those legacy colors as well, though it's recommended by the Tailwind team to alias those legacy color names directly in your Tailwind config and not rely on Tailwind's `require('tailwindcss/colors')` to add those for you, as that will produce a warning. More info on that can be found on the offical Tailwind CSS website [here](https://tailwindcss.com/docs/upgrade-guide#renamed-gray-scales).
```js
// tailwind.config.js
const lerpColors = require('tailwind-lerp-colors');

module.exports = {
theme: {
// ...
colors: lerpColors({
// your colors
}, {
// function options (all optional)
includeBase: true,
includeLegacy: false,
lerpEnds: true,
interval: 25,
mode: 'rgb',
})
},
plugins: [
require('tailwind-lerp-colors'),
// ...other plugins
],
}
```

## Usage
`tailwind-lerp-colors` works out of the box without any required options, so if you're good with the default options (listed below in the "Advanced usage" example), this should work for you:
`tailwind-lerp-colors` works without using any options, so if you're good with the default options (listed below in the "Advanced usage" example), this should work for you:

### Simple usage:
```js
require('tailwind-lerp-colors')
// tailwind.config.js
theme: {
colors: lerpColors({
// your colors
})
}
```

Alternatively, if you want more flexibility, you can invoke the `tailwind-lerp-colors` plugin function with an options object.
Alternatively, if you want more flexibility, you can invoke the `tailwind-lerp-colors` function with an options object.

### Advanced usage:
```js
require('tailwind-lerp-colors')({
includeBaseColors: true,
includeEnds: true,
interval: 25,
mode: 'rgb',
})
// tailwind.config.js
theme: {
colors: lerpColors({
// your colors
}, {
// function options (all optional)
includeBase: true,
includeLegacy: false,
lerpEnds: true,
interval: 25,
mode: 'rgb',
})
}
```

The values listed above are all the options currently supported and their default values. Using the above would render the exact same result as the simple usage listed prior.

Every option in the options object is entirely optional and falls back to its respective default option when omitted.

#### Options explained:
* `includeBaseColors` (`boolean`) determines whether or not to include Tailwind's base colors in the interpolation and final colors. In Tailwind v3.x, all Tailwind base colors are enabled by default for use with the JIT compiler.
* `includeBase` (`boolean`) determines whether or not to include Tailwind's base colors in the interpolation and final colors. In Tailwind v3.x, all Tailwind base colors are enabled by default for use with the JIT compiler.

This setting follows the same methodology and includes all Tailwind base colors in the interpolation of your colors, even if they are not explicitly listed in your Tailwind config. When this option is enabled, the base colors are included at a lower priority, so any colors of the same name you list in your Tailwind config will still override the base colors as they normally would.

If this setting is disabled, the plugin will only interpolate colors explicitly listed in your Tailwind config.
If this setting is disabled, the function will only interpolate colors explicitly listed in your Tailwind config.

* `includeLegacy` (`boolean`) will include Tailwind's legacy color names (`lightBlue`, `warmGray`, `trueGray`, `coolGray`, `blueGray`) in the final colors output by `tailwind-lerp-colors`. As mentioned above, it's recommended by the Tailwind team to alias those legacy color names directly in your Tailwind config and not rely on Tailwind's `require('tailwindcss/colors')` to add those for you, as that will produce a warning. More info on that can be found on the offical Tailwind CSS website [here](https://tailwindcss.com/docs/upgrade-guide#renamed-gray-scales).

** *`includeBase` must be set to true in order for `includeLegacy` to work*

* `includeEnds` (`boolean`) will include interpolation past the bounds of the colors included in the provided palette. For example, assuming a color `brown` is included in Tailwind config colors, where `brown-50` is the lightest shade and `brown-900` is the darkest shade, the plugin—when enabled—would interpolate between white (`#fff`) and `brown-50` and between black (`#000`) and `brown-900` to expose lighter and darker shades of every color than those included in the palette.
* `includeEnds` (`boolean`) will include interpolation past the bounds of the colors included in the provided palette. For example, assuming a color `brown` is included in Tailwind config colors, where `brown-50` is the lightest shade and `brown-900` is the darkest shade, the function—when enabled—would interpolate between white (`#fff`) and `brown-50` and between black (`#000`) and `brown-900` to expose lighter and darker shades of every color than those included in the palette.

* `interval` (`number`, positive integer) sets the interval at which to interpolate colors. For example, with the default value of `25`, between `red-100` and `red-200`, it would interpolate the additional values `red-125`, `red-150`, and `red-175`. To include only the &#8220;halfway&#8221; values and not &#8220;quarter&#8221; values, you could pass an `interval` value of `50` which would only interpolate `red-150` between `red-100` and `red-200`. To interpolate every single value between each shade, you can pass a value of `1`, which would expose `red-101`, `red-102`, `red-103`, …, `red-899` per the default colors (including `red-0` and `red-1000` if `includeEnds` is enabled).

Expand All @@ -102,7 +130,7 @@ Every option in the options object is entirely optional and falls back to its re
}
```

While using an interval like `25` would not be compatible with a color like the one listed above, rest assured this conflict will neither break the plugin or your Tailwind config nor even exclude the color. Any color that is found to be incompatible with the `interval` value, whether because of a divisibility issue like in the aqua example above or because the color is a simple string (e.g. `brand-primary: '#c000ff'`), these colors will simply skip the interpolation step and be re-included into the new colors as-is.
While using an interval like `25` would not be compatible with a color like the one listed above, rest assured this conflict will neither break the function or your Tailwind config nor even exclude the color. Any color that is found to be incompatible with the `interval` value, whether because of a divisibility issue like in the aqua example above or because the color is a simple string (e.g. `brand-primary: '#c000ff'`), these colors will simply skip the interpolation step and be re-included into the new colors as-is.

* `mode` (`string`, must be value from list, see below) allows you to interpolate using the color mode of your choice for better color interpolation. This helps to avoid gray dead zones (more info on that [here](https://css-tricks.com/the-gray-dead-zone-of-gradients/)). This is especially useful when interpolating between colors of different hues.

Expand All @@ -127,7 +155,7 @@ I have a few improvements planned already, and I am always open to feature reque

Here are some of the features I have planned:

* a `function` option that allows users to more effectively control the rate at which the plugin interpolates between different colors, which would also allow for better luminosity matching with the base color palettes included in Tailwind
* a `function` option that allows users to more effectively control the rate at which the function interpolates between different colors, which would also allow for better luminosity matching with the base color palettes included in Tailwind

* filtering options, so users can define which colors they do or don't want to interpolate on

Expand All @@ -142,7 +170,7 @@ Please run tests where appropriate to help streamline the review and deployment

While you can always support me via [Buy Me a Coffee](https://buymeacoffee.com/brandonmcconnell), the best way to support me and this development is actually to contribute. Every bit of feedback helps me to develop tools the way you as users want them. Thanks!

Also, while I developed this plugin, much of the ✨magic✨ working behind the scenes runs on Chroma.js, built by [Gregor Aisch](https://github.com/gka) and contributed to by many! Chroma.js is an incredible tool that powers much of the crazy color interactions you see on the web, so definitely pay the [Chroma.js repo](https://github.com/gka/chroma.js) a visit.
Also, while I developed this function, much of the ✨magic✨ working behind the scenes runs on Chroma.js, built by [Gregor Aisch](https://github.com/gka) and contributed to by many! Chroma.js is an incredible tool that powers much of the crazy color interactions you see on the web, so definitely pay the [Chroma.js repo](https://github.com/gka/chroma.js) a visit.

## License
[MIT](https://choosealicense.com/licenses/mit/)
213 changes: 105 additions & 108 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,120 +1,117 @@
const plugin = require('tailwindcss/plugin');
const interpolateColors = (colorsObj, options = {}) => {
const chroma = require('chroma-js');
const builtInColors = require('tailwindcss/colors');
const legacyNames = ['lightBlue', 'warmGray', 'trueGray', 'coolGray', 'blueGray'];

const interpolateColors = plugin.withOptions(
() => {}, (options = {}) => {
function generateLerpedColors({ theme }) {
const chroma = require('chroma-js');
const baseColors = require('tailwindcss/colors');

const defaultOptions = {
includeBaseColors: true,
includeEnds: true,
interval: 25,
mode: 'rgb',
};

const validColorModes = [
'rgb', 'lab', 'lch', 'lrgb',
'hcl', 'num', 'hcg', 'oklch',
'hsi', 'hsl', 'hsv', 'oklab',
];

const sortByNumericFirstIndex = ([numericKeyA], [numericKeyB]) => {
return numericKeyA - numericKeyB;
};

const isOptionInvalid = (optionName, test) => {
return options.hasOwnProperty(optionName) && !test(options[optionName]);
}
const defaultOptions = {
includeBase: true,
includeLegacy: false,
lerpEnds: true,
interval: 25,
mode: 'rgb',
};

if (isOptionInvalid('includeBaseColors', v => typeof v === 'boolean'))
throw new Error('tailwind-lerp-colors option `includeBaseColors` must be a boolean.');
if (isOptionInvalid('includeEnds', v => typeof v === 'boolean'))
throw new Error('tailwind-lerp-colors option `includeEnds` must be a boolean.');
if (isOptionInvalid('interval', v => Number.isInteger(v) && v > 0))
throw new Error('tailwind-lerp-colors option `interval` must be a positive integer greater than 0.');
if (isOptionInvalid('mode', v => validColorModes.includes(v)))
throw new Error(`tailwind-lerp-colors option \`mode\` must be one of the following values: ${validColorModes.map(modeName => '`modeName`').join(', ')}.`);
const validColorModes = [
'rgb', 'lab', 'lch', 'lrgb',
'hcl', 'num', 'hcg', 'oklch',
'hsi', 'hsl', 'hsv', 'oklab',
];

const { includeBaseColors, includeEnds, interval, mode } = {
...defaultOptions,
...options,
};
const initialColors = Object.entries({
...(includeBaseColors ? baseColors : {}),
...theme.colors,
...theme.extend.colors,
});

const finalColors = {};
const sortByNumericFirstIndex = ([numericKeyA], [numericKeyB]) => {
return numericKeyA - numericKeyB;
};

for (const [name, shades] of initialColors) {
if (
['null', 'undefined'].includes(typeof shades) ||
!shades.toString
) {
continue;
}
finalColors[name] = shades;
if (
typeof shades === 'string' ||
Array.isArray(shades) ||
shades.toString() !== '[object Object]' ||
!Object.keys(shades).every(key => {
return !isNaN(key);
})
) {
continue;
}
const shadesArray = (
Object.entries(shades)
.map(([numericStringKey, color]) => {
return [Number(numericStringKey), color];
})
.sort(sortByNumericFirstIndex)
);
if (includeEnds) {
shadesArray.unshift([0, '#ffffff']);
shadesArray.push([1000, '#000000']);
}
const finalShades = [...shadesArray];
for (let i = 0; i < shadesArray.length - 1; i++) {
const [shade, color] = shadesArray[i];
const [nextShade, nextColor] = shadesArray[i + 1];
const isOptionInvalid = (optionName, test) => {
return options.hasOwnProperty(optionName) && !test(options[optionName]);
}

// check to make sure both shades being compared
// are evenly divisible by the set interval
let interpolations = (nextShade - shade) / interval - 1;
if (
interpolations <= 0 ||
!Number.isInteger(interpolations)
) continue;
if (isOptionInvalid('includeBase', v => typeof v === 'boolean'))
throw new Error('tailwind-lerp-colors option `includeBase` must be a boolean.');
if (isOptionInvalid('includeLegacy', v => typeof v === 'boolean'))
throw new Error('tailwind-lerp-colors option `includeLegacy` must be a boolean.');
if (isOptionInvalid('includeEnds', v => typeof v === 'boolean'))
throw new Error('tailwind-lerp-colors option `includeEnds` must be a boolean.');
if (isOptionInvalid('interval', v => Number.isInteger(v) && v > 0))
throw new Error('tailwind-lerp-colors option `interval` must be a positive integer greater than 0.');
if (isOptionInvalid('mode', v => validColorModes.includes(v)))
throw new Error(`tailwind-lerp-colors option \`mode\` must be one of the following values: ${validColorModes.map(modeName => '`modeName`').join(', ')}.`);

const scale = chroma.scale([color, nextColor]).mode(mode);
const getColorAt = percent => scale(percent).hex();
for (let run = 1; run <= interpolations; run++) {
const percent = run / (interpolations + 1);
finalShades.push([
shade + (interval * run),
getColorAt(percent)
]);
}
}
finalShades.sort(sortByNumericFirstIndex);
finalColors[name] = Object.fromEntries(finalShades)
}
const [baseColors, legacyColors] = Object.entries(builtInColors).reduce(
([base, legacy], [name, values]) => {
if (legacyNames.includes(name)) legacy[name] = values;
else base[name] = values;
return [base, legacy];
}, [{}, {}]
);
const { includeBase, includeLegacy, includeEnds, interval, mode } = {
...defaultOptions,
...options,
};
const initialColors = Object.entries({
...(includeBase ? baseColors : {}),
...(includeLegacy ? legacyColors : {}),
...colorsObj,
});

return finalColors;
const finalColors = {};

for (const [name, shades] of initialColors) {
if (
['null', 'undefined'].includes(typeof shades) ||
!shades.toString
) {
continue;
}
finalColors[name] = shades;
if (
typeof shades === 'string' ||
Array.isArray(shades) ||
shades.toString() !== '[object Object]' ||
!Object.keys(shades).every(key => {
return !isNaN(key);
})
) {
continue;
}

return {
theme: {
extend: {
colors: generateLerpedColors
}
const shadesArray = (
Object.entries(shades)
.map(([numericStringKey, color]) => {
return [Number(numericStringKey), color];
})
.sort(sortByNumericFirstIndex)
);
if (includeEnds) {
shadesArray.unshift([0, '#ffffff']);
shadesArray.push([1000, '#000000']);
}
const finalShades = [...shadesArray];
for (let i = 0; i < shadesArray.length - 1; i++) {
const [shade, color] = shadesArray[i];
const [nextShade, nextColor] = shadesArray[i + 1];

// check to make sure both shades being compared
// are evenly divisible by the set interval
let interpolations = (nextShade - shade) / interval - 1;
if (
interpolations <= 0 ||
!Number.isInteger(interpolations)
) continue;

const scale = chroma.scale([color, nextColor]).mode(mode);
const getColorAt = percent => scale(percent).hex();
for (let run = 1; run <= interpolations; run++) {
const percent = run / (interpolations + 1);
finalShades.push([
shade + (interval * run),
getColorAt(percent)
]);
}
};
}
finalShades.sort(sortByNumericFirstIndex);
finalColors[name] = Object.fromEntries(finalShades)
}
);

return finalColors;
};

module.exports = interpolateColors;
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tailwind-lerp-colors",
"version": "1.1.2",
"version": "1.1.3",
"description": "Interpolate between defined colors in Tailwind config for additional color stops",
"main": "index.js",
"publishConfig": {
Expand Down

0 comments on commit d750270

Please sign in to comment.