Repository: tailwindlabs/tailwindcss
Pull request #2071 (2020-07-24 19:30:39)

Add reduce-motion variant
**Updated 2020-07-27:**

This PR adds new `motion-reduced` and `motion-safe` variants that allow you to conditionally apply CSS based on the [`prefers-reduced-motion` media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion).

It can be useful in conjunction with transition and animation utilities to disable problematic motion for users who are sensitive to it:

```html
<div class="... transition duration-150 ease-in-out motion-reduced:transition-none ...">
``` 

...or to explicitly opt-in to motion to make sure it's only being shown to users who haven't opted out:

```html
<div class="... motion-safe:transition duration-150 ease-in-out ...">
``` 

Generally I think `motion-safe` is the better approach, but I've included both for completeness.

These can be combined with responsive variants and pseudo-class variants as well:

```html
<!-- With responsive variants -->
<div class="sm:motion-reduced:translate-y-0"></div>

<!-- With pseudo-class variants -->
<div class="motion-reduced:hover:translate-y-0"></div>

<!-- With responsive and pseudo-class variants -->
<div class="sm:motion-reduced:hover:translate-y-0"></div>
```

This is the first example of "stackable" variants in Tailwind and is something we may expose to plugin authors in the future, but for now is just hardcoded for these core variants while we work out the internal details.


Patch:
diff --git a/src/lib/substituteVariantsAtRules.js b/src/lib/substituteVariantsAtRules.js
index d553beba..692a92d7 100644
--- a/src/lib/substituteVariantsAtRules.js
+++ b/src/lib/substituteVariantsAtRules.js
@@ -23,6 +23,33 @@ function ensureIncludesDefault(variants) {
 
 const defaultVariantGenerators = config => ({
   default: generateVariantFunction(() => {}),
+  'motion-safe': generateVariantFunction(({ container, separator, modifySelectors }) => {
+    const modified = modifySelectors(({ selector }) => {
+      return selectorParser(selectors => {
+        selectors.walkClasses(sel => {
+          sel.value = `motion-safe${separator}${sel.value}`
+        })
+      }).processSync(selector)
+    })
+    const mediaQuery = postcss.atRule({
+      name: 'media',
+      params: '(prefers-reduced-motion: no-preference)',
+    })
+    mediaQuery.append(modified)
+    container.append(mediaQuery)
+  }),
+  'motion-reduced': generateVariantFunction(({ container, separator, modifySelectors }) => {
+    const modified = modifySelectors(({ selector }) => {
+      return selectorParser(selectors => {
+        selectors.walkClasses(sel => {
+          sel.value = `motion-reduced${separator}${sel.value}`
+        })
+      }).processSync(selector)
+    })
+    const mediaQuery = postcss.atRule({ name: 'media', params: '(prefers-reduced-motion: reduce)' })
+    mediaQuery.append(modified)
+    container.append(mediaQuery)
+  }),
   'group-hover': generateVariantFunction(({ modifySelectors, separator }) => {
     return modifySelectors(({ selector }) => {
       return selectorParser(selectors => {
@@ -63,6 +90,28 @@ const defaultVariantGenerators = config => ({
   even: generatePseudoClassVariant('nth-child(even)', 'even'),
 })
 
+function prependStackableVariants(atRule, variants) {
+  const stackableVariants = ['motion-safe', 'motion-reduced']
+
+  if (!_.some(variants, v => stackableVariants.includes(v))) {
+    return variants
+  }
+
+  if (_.every(variants, v => stackableVariants.includes(v))) {
+    return variants
+  }
+
+  const variantsParent = postcss.atRule({
+    name: 'variants',
+    params: variants.filter(v => stackableVariants.includes(v)).join(', '),
+  })
+  atRule.before(variantsParent)
+  variantsParent.append(atRule)
+  variants = _.without(variants, ...stackableVariants)
+
+  return variants
+}
+
 export default function(config, { variantGenerators: pluginVariantGenerators }) {
   return function(css) {
     const variantGenerators = {
@@ -70,25 +119,34 @@ export default function(config, { variantGenerators: pluginVariantGenerators })
       ...pluginVariantGenerators,
     }
 
-    css.walkAtRules('variants', atRule => {
-      const variants = postcss.list.comma(atRule.params).filter(variant => variant !== '')
+    let variantsFound = false
 
-      if (variants.includes('responsive')) {
-        const responsiveParent = postcss.atRule({ name: 'responsive' })
-        atRule.before(responsiveParent)
-        responsiveParent.append(atRule)
-      }
+    do {
+      variantsFound = false
+      css.walkAtRules('variants', atRule => {
+        variantsFound = true
 
-      _.forEach(_.without(ensureIncludesDefault(variants), 'responsive'), variant => {
-        if (!variantGenerators[variant]) {
-          throw new Error(
-            `Your config mentions the "${variant}" variant, but "${variant}" doesn't appear to be a variant. Did you forget or misconfigure a plugin that supplies that variant?`
-          )
+        let variants = postcss.list.comma(atRule.params).filter(variant => variant !== '')
+
+        if (variants.includes('responsive')) {
+          const responsiveParent = postcss.atRule({ name: 'responsive' })
+          atRule.before(responsiveParent)
+          responsiveParent.append(atRule)
         }
-        variantGenerators[variant](atRule, config)
-      })
 
-      atRule.remove()
-    })
+        const remainingVariants = prependStackableVariants(atRule, variants)
+
+        _.forEach(_.without(ensureIncludesDefault(remainingVariants), 'responsive'), variant => {
+          if (!variantGenerators[variant]) {
+            throw new Error(
+              `Your config mentions the "${variant}" variant, but "${variant}" doesn't appear to be a variant. Did you forget or misconfigure a plugin that supplies that variant?`
+            )
+          }
+          variantGenerators[variant](atRule, config)
+        })
+
+        atRule.remove()
+      })
+    } while (variantsFound)
   }
 }


Test patch:
diff --git a/__tests__/variantsAtRule.test.js b/__tests__/variantsAtRule.test.js
index 12f836f9..13ecddfc 100644
--- a/__tests__/variantsAtRule.test.js
+++ b/__tests__/variantsAtRule.test.js
@@ -177,6 +177,166 @@ test('it can generate focus-visible variants', () => {
   })
 })
 
+test('it can generate motion-reduced variants', () => {
+  const input = `
+    @variants motion-reduced {
+      .banana { color: yellow; }
+      .chocolate { color: brown; }
+    }
+  `
+
+  const output = `
+    .banana { color: yellow; }
+    .chocolate { color: brown; }
+    @media (prefers-reduced-motion: reduce) {
+      .motion-reduced\\:banana { color: yellow; }
+      .motion-reduced\\:chocolate { color: brown; }
+    }
+  `
+
+  return run(input).then(result => {
+    expect(result.css).toMatchCss(output)
+    expect(result.warnings().length).toBe(0)
+  })
+})
+
+test('it can generate motion-safe variants', () => {
+  const input = `
+    @variants motion-safe {
+      .banana { color: yellow; }
+      .chocolate { color: brown; }
+    }
+  `
+
+  const output = `
+    .banana { color: yellow; }
+    .chocolate { color: brown; }
+    @media (prefers-reduced-motion: no-preference) {
+      .motion-safe\\:banana { color: yellow; }
+      .motion-safe\\:chocolate { color: brown; }
+    }
+  `
+
+  return run(input).then(result => {
+    expect(result.css).toMatchCss(output)
+    expect(result.warnings().length).toBe(0)
+  })
+})
+
+test('it can generate motion-safe and motion-reduced variants', () => {
+  const input = `
+    @variants motion-safe, motion-reduced {
+      .banana { color: yellow; }
+      .chocolate { color: brown; }
+    }
+  `
+
+  const output = `
+    .banana { color: yellow; }
+    .chocolate { color: brown; }
+    @media (prefers-reduced-motion: no-preference) {
+      .motion-safe\\:banana { color: yellow; }
+      .motion-safe\\:chocolate { color: brown; }
+    }
+    @media (prefers-reduced-motion: reduce) {
+      .motion-reduced\\:banana { color: yellow; }
+      .motion-reduced\\:chocolate { color: brown; }
+    }
+  `
+
+  return run(input).then(result => {
+    expect(result.css).toMatchCss(output)
+    expect(result.warnings().length).toBe(0)
+  })
+})
+
+test('motion-reduced variants stack with basic variants', () => {
+  const input = `
+    @variants motion-reduced, hover {
+      .banana { color: yellow; }
+      .chocolate { color: brown; }
+    }
+  `
+
+  const output = `
+    .banana { color: yellow; }
+    .chocolate { color: brown; }
+    .hover\\:banana:hover { color: yellow; }
+    .hover\\:chocolate:hover { color: brown; }
+    @media (prefers-reduced-motion: reduce) {
+      .motion-reduced\\:banana { color: yellow; }
+      .motion-reduced\\:chocolate { color: brown; }
+      .motion-reduced\\:hover\\:banana:hover { color: yellow; }
+      .motion-reduced\\:hover\\:chocolate:hover { color: brown; }
+    }
+  `
+
+  return run(input).then(result => {
+    expect(result.css).toMatchCss(output)
+    expect(result.warnings().length).toBe(0)
+  })
+})
+
+test('motion-safe variants stack with basic variants', () => {
+  const input = `
+    @variants motion-safe, hover {
+      .banana { color: yellow; }
+      .chocolate { color: brown; }
+    }
+  `
+
+  const output = `
+    .banana { color: yellow; }
+    .chocolate { color: brown; }
+    .hover\\:banana:hover { color: yellow; }
+    .hover\\:chocolate:hover { color: brown; }
+    @media (prefers-reduced-motion: no-preference) {
+      .motion-safe\\:banana { color: yellow; }
+      .motion-safe\\:chocolate { color: brown; }
+      .motion-safe\\:hover\\:banana:hover { color: yellow; }
+      .motion-safe\\:hover\\:chocolate:hover { color: brown; }
+    }
+  `
+
+  return run(input).then(result => {
+    expect(result.css).toMatchCss(output)
+    expect(result.warnings().length).toBe(0)
+  })
+})
+
+test('motion-safe and motion-reduced variants stack with basic variants', () => {
+  const input = `
+    @variants motion-reduced, motion-safe, hover {
+      .banana { color: yellow; }
+      .chocolate { color: brown; }
+    }
+  `
+
+  const output = `
+    .banana { color: yellow; }
+    .chocolate { color: brown; }
+    .hover\\:banana:hover { color: yellow; }
+    .hover\\:chocolate:hover { color: brown; }
+    @media (prefers-reduced-motion: reduce) {
+      .motion-reduced\\:banana { color: yellow; }
+      .motion-reduced\\:chocolate { color: brown; }
+      .motion-reduced\\:hover\\:banana:hover { color: yellow; }
+      .motion-reduced\\:hover\\:chocolate:hover { color: brown; }
+    }
+    @media (prefers-reduced-motion: no-preference) {
+      .motion-safe\\:banana { color: yellow; }
+      .motion-safe\\:chocolate { color: brown; }
+      .motion-safe\\:hover\\:banana:hover { color: yellow; }
+      .motion-safe\\:hover\\:chocolate:hover { color: brown; }
+    }
+  `
+
+  return run(input).then(result => {
+    expect(result.css).toMatchCss(output)
+    expect(result.warnings().length).toBe(0)
+  })
+})
+
 test('it can generate first-child variants', () => {
   const input = `
     @variants first {