Repository: tailwindlabs/tailwindcss
Pull request #4348 (2021-05-13 19:03:33)

Support opacity modifiers for colors in JIT
This PR adds a new syntax for specifying color opacity when using the JIT engine.

Instead of needing to use utilities like `bg-opacity-50`, `text-opacity-25`, or `placeholder-opacity-40`, this PR makes it possible to just tack the opacity right on to the end of the color:

```diff
- <div class="bg-red-500 bg-opacity-25">
+ <div class="bg-red-500/25">
```

The motivation for this is to remove the need to add new color opacity utilities any time we add other new utilities that are color based. For example, currently the gradient utilities do not support opacity at all:

```html
<!-- This doesn't work -->
<div class="bg-gradient-to-r from-red-500 bg-opacity-50">
```

People expect this to work (https://github.com/tailwindlabs/tailwindcss/issues/3875) but in reality the better solution would be something like this:

```html
<div class="bg-gradient-to-r from-red-500 from-opacity-50">
```

The problem with this solution is it scales really poorly. Any time we add a new color utility, we need a new corresponding opacity utility, which means coming up with a new core plugin name, documenting it, etc.

```html
<!-- This is the new solution -->
<div class="bg-gradient-to-r from-red-500/50">
```

This PR introduces an approach that allows you to apply opacity to any color by just adding `/{opacity}` to the end of the color, and it is handled at an earlier phase in the compiler so that no color related plugins even have to know that this syntax exists.

The opacity values you add at the end of the color name are taken from your `opacity` scale in your theme, and the utility-specific opacity scales are ignored, the idea being that this feature effectively deprecates that stuff.

This feature also supports arbitrary opacity values, in case you want to pull the opacity from a CSS variable or use some other one-off value, using this syntax:

```html
<div class="bg-red-500/[var(--opacity)]">
```

This feature is only available in JIT mode, as making it work in the classic engine would introduce a tremendous amount of bloat due to the combinatoric explosion of classes.

codecov-commenter: # [Codecov](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#4348](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (b321536) into [master](https://codecov.io/gh/tailwindlabs/tailwindcss/commit/6be797633f95a87600b414eb077cafc6cbd46ec4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (6be7976) will **decrease** coverage by `0.01%`.
> The diff coverage is `75.00%`.

[![Impacted file tree graph](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348/graphs/tree.svg?width=650&height=150&src=pr&token=bY4d7WZ54r&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)

```diff
@@            Coverage Diff             @@
##           master    #4348      +/-   ##
==========================================
- Coverage   71.85%   71.84%   -0.02%     
==========================================
  Files         220      220              
  Lines        4769     4795      +26     
  Branches      790      799       +9     
==========================================
+ Hits         3427     3445      +18     
- Misses       1262     1266       +4     
- Partials       80       84       +4     
```


| [Impacted Files](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [src/util/pluginUtils.js](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL3V0aWwvcGx1Z2luVXRpbHMuanM=) | `61.78% <73.52%> (+1.17%)` | :arrow_up: |
| [src/jit/lib/generateRules.js](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2ppdC9saWIvZ2VuZXJhdGVSdWxlcy5qcw==) | `92.05% <100.00%> (ø)` | |
| [src/jit/lib/setupContext.js](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3JjL2ppdC9saWIvc2V0dXBDb250ZXh0Lmpz) | `77.83% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Last update [6be7976...b321536](https://codecov.io/gh/tailwindlabs/tailwindcss/pull/4348?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None).

MichaelDeBoey: @adamwathan Will things like `bg-gradient-to-r from-purple-400/50 via-pink-500/20 to-red-500/70` also work?
adamwathan: @MichaelDeBoey Yep they will!
hmaesta: Is there any possibility that this could be extend for shadows?

Instead of hard coding new colors in `tailwind.config.js`, we could just define sizes. The obvious problem I can see is that most shadows have more than one parameter (with different color opacities), so we would need a "template-like" syntax – but, to be honest, that doesn't look good.

```diff
# tailwind.config.js

  boxShadow: {
-   'lg-hard-coded-blue': '0 10px 15px -3px rgba(37, 110, 255, 0.1), 0 4px 6px -2px rgba(37, 110, 255, 0.05)'
+   'lg': '0 10px 15px -3px rgba({color}, 0.1), 0 4px 6px -2px rgba({color}, 0.05)'
  }
```

```diff
- <div class="shadow-lg-hard-coded-blue">
+ <div class="shadow-lg-blue-600">
```

Patch:
diff --git a/src/jit/lib/generateRules.js b/src/jit/lib/generateRules.js
index 027ad707..916a6418 100644
--- a/src/jit/lib/generateRules.js
+++ b/src/jit/lib/generateRules.js
@@ -27,9 +27,9 @@ function* candidatePermutations(candidate, lastIndex = Infinity) {
   if (lastIndex === Infinity && candidate.endsWith(']')) {
     let bracketIdx = candidate.lastIndexOf('[')
 
-    // If character before `[` isn't a dash, this isn't a dynamic class
+    // If character before `[` isn't a dash or a slash, this isn't a dynamic class
     // eg. string[]
-    dashIdx = candidate[bracketIdx - 1] === '-' ? bracketIdx - 1 : -1
+    dashIdx = ['-', '/'].includes(candidate[bracketIdx - 1]) ? bracketIdx - 1 : -1
   } else {
     dashIdx = candidate.lastIndexOf('-', lastIndex)
   }
diff --git a/src/jit/lib/setupContext.js b/src/jit/lib/setupContext.js
index b0e01d3a..3ea367b8 100644
--- a/src/jit/lib/setupContext.js
+++ b/src/jit/lib/setupContext.js
@@ -541,9 +541,10 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
 
         function wrapped(modifier) {
           let { type = 'any' } = options
-          let [value, coercedType] = coerceValue(type, modifier, options.values)
+          type = [].concat(type)
+          let [value, coercedType] = coerceValue(type, modifier, options.values, tailwindConfig)
 
-          if (type !== coercedType || value === undefined) {
+          if (!type.includes(coercedType) || value === undefined) {
             return []
           }
 
diff --git a/src/plugins/fill.js b/src/plugins/fill.js
index 311524f9..7778ece9 100644
--- a/src/plugins/fill.js
+++ b/src/plugins/fill.js
@@ -12,7 +12,7 @@ export default function () {
       {
         values: flattenColorPalette(theme('fill')),
         variants: variants('fill'),
-        type: 'any',
+        type: ['color', 'any'],
       }
     )
   }
diff --git a/src/plugins/gradientColorStops.js b/src/plugins/gradientColorStops.js
index 38d7d39a..e1ebba13 100644
--- a/src/plugins/gradientColorStops.js
+++ b/src/plugins/gradientColorStops.js
@@ -11,7 +11,7 @@ export default function () {
     let options = {
       values: flattenColorPalette(theme('gradientColorStops')),
       variants: variants('gradientColorStops'),
-      type: 'any',
+      type: ['color', 'any'],
     }
 
     matchUtilities(
diff --git a/src/plugins/placeholderColor.js b/src/plugins/placeholderColor.js
index 66e6e27a..412d3647 100644
--- a/src/plugins/placeholderColor.js
+++ b/src/plugins/placeholderColor.js
@@ -26,7 +26,7 @@ export default function () {
       {
         values: flattenColorPalette(theme('placeholderColor')),
         variants: variants('placeholderColor'),
-        type: 'any',
+        type: ['color', 'any'],
       }
     )
   }
diff --git a/src/util/pluginUtils.js b/src/util/pluginUtils.js
index acc9e4a9..a37c6e98 100644
--- a/src/util/pluginUtils.js
+++ b/src/util/pluginUtils.js
@@ -2,6 +2,7 @@ import selectorParser from 'postcss-selector-parser'
 import postcss from 'postcss'
 import createColor from 'color'
 import escapeCommas from './escapeCommas'
+import { withAlphaValue } from './withAlphaVariable'
 
 export function updateAllClasses(selectors, updateClass) {
   let parser = selectorParser((selectors) => {
@@ -148,16 +149,50 @@ export function asList(modifier, lookup = {}) {
   })
 }
 
-export function asColor(modifier, lookup = {}) {
+function isArbitraryValue(input) {
+  return input.startsWith('[') && input.endsWith(']')
+}
+
+function splitAlpha(modifier) {
+  let slashIdx = modifier.lastIndexOf('/')
+
+  if (slashIdx === -1 || slashIdx === modifier.length - 1) {
+    return [modifier]
+  }
+
+  return [modifier.slice(0, slashIdx), modifier.slice(slashIdx + 1)]
+}
+
+function isColor(value) {
+  try {
+    createColor(value)
+    return true
+  } catch (e) {
+    return false
+  }
+}
+
+export function asColor(modifier, lookup = {}, tailwindConfig = {}) {
+  if (lookup[modifier] !== undefined) {
+    return lookup[modifier]
+  }
+
+  let [color, alpha] = splitAlpha(modifier)
+
+  if (lookup[color] !== undefined) {
+    if (isArbitraryValue(alpha)) {
+      return withAlphaValue(lookup[color], alpha.slice(1, -1))
+    }
+
+    if (tailwindConfig.theme?.opacity?.[alpha] === undefined) {
+      return undefined
+    }
+
+    return withAlphaValue(lookup[color], tailwindConfig.theme.opacity[alpha])
+  }
+
   return asValue(modifier, lookup, {
-    validate: (value) => {
-      try {
-        createColor(value)
-        return true
-      } catch (e) {
-        return false
-      }
-    },
+    validate: isColor,
   })
 }
 
@@ -208,14 +243,18 @@ function splitAtFirst(input, delim) {
   return (([first, ...rest]) => [first, rest.join(delim)])(input.split(delim))
 }
 
-export function coerceValue(type, modifier, values) {
-  if (modifier.startsWith('[') && modifier.endsWith(']')) {
+export function coerceValue(type, modifier, values, tailwindConfig) {
+  let [scaleType, arbitraryType = scaleType] = [].concat(type)
+
+  if (isArbitraryValue(modifier)) {
     let [explicitType, value] = splitAtFirst(modifier.slice(1, -1), ':')
 
     if (value.length > 0 && Object.keys(typeMap).includes(explicitType)) {
-      return [asValue(`[${value}]`, values), explicitType]
+      return [asValue(`[${value}]`, values, tailwindConfig), explicitType]
     }
+
+    return [typeMap[arbitraryType](modifier, values, tailwindConfig), arbitraryType]
   }
 
-  return [typeMap[type](modifier, values), type]
+  return [typeMap[scaleType](modifier, values, tailwindConfig), scaleType]
 }


Test patch:
diff --git a/tests/jit/color-opacity-modifiers.test.js b/tests/jit/color-opacity-modifiers.test.js
new file mode 100644
index 00000000..7955a758
--- /dev/null
+++ b/tests/jit/color-opacity-modifiers.test.js
@@ -0,0 +1,200 @@
+import postcss from 'postcss'
+import path from 'path'
+import tailwind from '../../src/jit/index.js'
+
+function run(input, config = {}) {
+  const { currentTestName } = expect.getState()
+
+  return postcss(tailwind(config)).process(input, {
+    from: `${path.resolve(__filename)}?test=${currentTestName}`,
+  })
+}
+
+test('basic color opacity modifier', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-red-500/50"></div>',
+      },
+    ],
+    theme: {},
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .bg-red-500\\/50 {
+        background-color: rgba(239, 68, 68, 0.5);
+      }
+    `)
+  })
+})
+
+test('colors with slashes are matched first', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-red-500/50"></div>',
+      },
+    ],
+    theme: {
+      extend: {
+        colors: {
+          'red-500/50': '#ff0000',
+        },
+      },
+    },
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .bg-red-500\\/50 {
+        --tw-bg-opacity: 1;
+        background-color: rgba(255, 0, 0, var(--tw-bg-opacity));
+      }
+    `)
+  })
+})
+
+test('arbitrary color opacity modifier', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: 'bg-red-500/[var(--opacity)]',
+      },
+    ],
+    theme: {},
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .bg-red-500\\/\\[var\\(--opacity\\)\\] {
+        background-color: rgba(239, 68, 68, var(--opacity));
+      }
+    `)
+  })
+})
+
+test('missing alpha generates nothing', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-red-500/"></div>',
+      },
+    ],
+    theme: {},
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(``)
+  })
+})
+
+test('values not in the opacity config are ignored', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-red-500/29"></div>',
+      },
+    ],
+    theme: {
+      opacity: {
+        0: '0',
+        25: '0.25',
+        5: '0.5',
+        75: '0.75',
+        100: '1',
+      },
+    },
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(``)
+  })
+})
+
+test('function colors are supported', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: '<div class="bg-blue/50"></div>',
+      },
+    ],
+    theme: {
+      colors: {
+        blue: ({ opacityValue }) => {
+          return `rgba(var(--colors-blue), ${opacityValue})`
+        },
+      },
+    },
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .bg-blue\\/50 {
+        background-color: rgba(var(--colors-blue), 0.5);
+      }
+    `)
+  })
+})
+
+test('utilities that support any type are supported', async () => {
+  let config = {
+    mode: 'jit',
+    purge: [
+      {
+        raw: `
+          <div class="from-red-500/50"></div>
+          <div class="fill-red-500/25"></div>
+          <div class="placeholder-red-500/75"></div>
+        `,
+      },
+    ],
+    theme: {
+      extend: {
+        fill: (theme) => theme('colors'),
+      },
+    },
+    plugins: [],
+  }
+
+  let css = `@tailwind utilities`
+
+  return run(css, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(`
+      .from-red-500\\/50 {
+        --tw-gradient-from: rgba(239, 68, 68, 0.5);
+        --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to, rgba(239, 68, 68, 0));
+      }
+      .fill-red-500\\/25 {
+        fill: rgba(239, 68, 68, 0.25);
+      }
+      .placeholder-red-500\\/75::placeholder {
+        color: rgba(239, 68, 68, 0.75);
+      }
+    `)
+  })
+})