Repository: tailwindlabs/tailwindcss
Pull request #6046 (2021-11-10 23:10:32)

Add variants for `orientation` media feature
This PR adds variants for the [orientation media feature](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/orientation)

`orientation-portrait:` for targeting portrait orientation.

`orientation-landscape:` for targeting landscape orientation.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1504a01b..cb4b6758 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-Nothing yet!
+### Fixed
+
+- Enforce the order of some variants (like `before` and `after`) ([#6018](https://github.com/tailwindlabs/tailwindcss/pull/6018))
+
+### Added
+
+- Add `placeholder` variant ([#6106](https://github.com/tailwindlabs/tailwindcss/pull/6106))
+- Add tuple syntax for configuring screens while guaranteeing order ([#5956](https://github.com/tailwindlabs/tailwindcss/pull/5956))
+- Add combinable `touch-action` support ([#6115](https://github.com/tailwindlabs/tailwindcss/pull/6115))
+- Add support for "arbitrary properties" ([#6161](https://github.com/tailwindlabs/tailwindcss/pull/6161))
 
 ## [3.0.0-alpha.2] - 2021-11-08
 
diff --git a/package-lock.json b/package-lock.json
index 17090854..ea8abf0b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,7 +44,7 @@
         "cross-env": "^7.0.3",
         "cssnano": "^5.0.10",
         "esbuild": "^0.13.12",
-        "eslint": "^8.0.1",
+        "eslint": "^8.2.0",
         "eslint-config-prettier": "^8.3.0",
         "eslint-plugin-prettier": "^4.0.0",
         "jest": "^27.3.1",
@@ -639,9 +639,9 @@
       "dev": true
     },
     "node_modules/@eslint/eslintrc": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.3.tgz",
-      "integrity": "sha512-DHI1wDPoKCBPoLZA3qDR91+3te/wDSc1YhKg3jR8NxKKRJq2hwHwcWv31cSwSYvIBrmbENoYMWcenW8uproQqg==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.4.tgz",
+      "integrity": "sha512-h8Vx6MdxwWI2WM8/zREHMoqdgLNXEL4QX3MWSVMdyNJGvXVOs+6lp+m2hc3FnuMHDc4poxFNI20vCk0OmI4G0Q==",
       "dev": true,
       "dependencies": {
         "ajv": "^6.12.4",
@@ -650,7 +650,7 @@
         "globals": "^13.9.0",
         "ignore": "^4.0.6",
         "import-fresh": "^3.2.1",
-        "js-yaml": "^3.13.1",
+        "js-yaml": "^4.1.0",
         "minimatch": "^3.0.4",
         "strip-json-comments": "^3.1.1"
       },
@@ -658,10 +658,16 @@
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
+    "node_modules/@eslint/eslintrc/node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
     "node_modules/@eslint/eslintrc/node_modules/globals": {
-      "version": "13.11.0",
-      "resolved": "https://registry.npmjs.org/globals/-/globals-13.11.0.tgz",
-      "integrity": "sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==",
+      "version": "13.12.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.0.tgz",
+      "integrity": "sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg==",
       "dev": true,
       "dependencies": {
         "type-fest": "^0.20.2"
@@ -673,6 +679,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/@eslint/eslintrc/node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.6.0",
       "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.6.0.tgz",
@@ -2923,12 +2941,12 @@
       }
     },
     "node_modules/eslint": {
-      "version": "8.0.1",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.0.1.tgz",
-      "integrity": "sha512-LsgcwZgQ72vZ+SMp4K6pAnk2yFDWL7Ti4pJaRvsZ0Hsw2h8ZjUIW38a9AFn2cZXdBMlScMFYYgsSp4ttFI/0bA==",
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.2.0.tgz",
+      "integrity": "sha512-erw7XmM+CLxTOickrimJ1SiF55jiNlVSp2qqm0NuBWPtHYQCegD5ZMaW0c3i5ytPqL+SSLaCxdvQXFPLJn+ABw==",
       "dev": true,
       "dependencies": {
-        "@eslint/eslintrc": "^1.0.3",
+        "@eslint/eslintrc": "^1.0.4",
         "@humanwhocodes/config-array": "^0.6.0",
         "ajv": "^6.10.0",
         "chalk": "^4.0.0",
@@ -2962,7 +2980,7 @@
         "progress": "^2.0.0",
         "regexpp": "^3.2.0",
         "semver": "^7.2.1",
-        "strip-ansi": "^6.0.0",
+        "strip-ansi": "^6.0.1",
         "strip-json-comments": "^3.1.0",
         "text-table": "^0.2.0",
         "v8-compile-cache": "^2.0.3"
@@ -3051,9 +3069,9 @@
       }
     },
     "node_modules/eslint-visitor-keys": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.0.0.tgz",
-      "integrity": "sha512-mJOZa35trBTb3IyRmo8xmKBZlxf+N7OnUl4+ZhJHs/r+0770Wh/LEACE2pqMGMe27G/4y8P2bYGk4J70IC5k1Q==",
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.1.0.tgz",
+      "integrity": "sha512-yWJFpu4DtjsWKkt5GeNBBuZMlNcYVs6vRCLoCVEJrTjaSB6LC98gFipNK/erM2Heg/E8mIK+hXG/pJMLK+eRZA==",
       "dev": true,
       "engines": {
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -6295,12 +6313,12 @@
       }
     },
     "node_modules/strip-ansi": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
-      "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
       "dev": true,
       "dependencies": {
-        "ansi-regex": "^5.0.0"
+        "ansi-regex": "^5.0.1"
       },
       "engines": {
         "node": ">=8"
@@ -7276,9 +7294,9 @@
       "dev": true
     },
     "@eslint/eslintrc": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.3.tgz",
-      "integrity": "sha512-DHI1wDPoKCBPoLZA3qDR91+3te/wDSc1YhKg3jR8NxKKRJq2hwHwcWv31cSwSYvIBrmbENoYMWcenW8uproQqg==",
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.0.4.tgz",
+      "integrity": "sha512-h8Vx6MdxwWI2WM8/zREHMoqdgLNXEL4QX3MWSVMdyNJGvXVOs+6lp+m2hc3FnuMHDc4poxFNI20vCk0OmI4G0Q==",
       "dev": true,
       "requires": {
         "ajv": "^6.12.4",
@@ -7287,19 +7305,34 @@
         "globals": "^13.9.0",
         "ignore": "^4.0.6",
         "import-fresh": "^3.2.1",
-        "js-yaml": "^3.13.1",
+        "js-yaml": "^4.1.0",
         "minimatch": "^3.0.4",
         "strip-json-comments": "^3.1.1"
       },
       "dependencies": {
+        "argparse": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+          "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+          "dev": true
+        },
         "globals": {
-          "version": "13.11.0",
-          "resolved": "https://registry.npmjs.org/globals/-/globals-13.11.0.tgz",
-          "integrity": "sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==",
+          "version": "13.12.0",
+          "resolved": "https://registry.npmjs.org/globals/-/globals-13.12.0.tgz",
+          "integrity": "sha512-uS8X6lSKN2JumVoXrbUz+uG4BYG+eiawqm3qFcT7ammfbUHeCBoJMlHcec/S3krSk73/AE/f0szYFmgAA3kYZg==",
           "dev": true,
           "requires": {
             "type-fest": "^0.20.2"
           }
+        },
+        "js-yaml": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+          "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+          "dev": true,
+          "requires": {
+            "argparse": "^2.0.1"
+          }
         }
       }
     },
@@ -8923,12 +8956,12 @@
       }
     },
     "eslint": {
-      "version": "8.0.1",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.0.1.tgz",
-      "integrity": "sha512-LsgcwZgQ72vZ+SMp4K6pAnk2yFDWL7Ti4pJaRvsZ0Hsw2h8ZjUIW38a9AFn2cZXdBMlScMFYYgsSp4ttFI/0bA==",
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.2.0.tgz",
+      "integrity": "sha512-erw7XmM+CLxTOickrimJ1SiF55jiNlVSp2qqm0NuBWPtHYQCegD5ZMaW0c3i5ytPqL+SSLaCxdvQXFPLJn+ABw==",
       "dev": true,
       "requires": {
-        "@eslint/eslintrc": "^1.0.3",
+        "@eslint/eslintrc": "^1.0.4",
         "@humanwhocodes/config-array": "^0.6.0",
         "ajv": "^6.10.0",
         "chalk": "^4.0.0",
@@ -8962,7 +8995,7 @@
         "progress": "^2.0.0",
         "regexpp": "^3.2.0",
         "semver": "^7.2.1",
-        "strip-ansi": "^6.0.0",
+        "strip-ansi": "^6.0.1",
         "strip-json-comments": "^3.1.0",
         "text-table": "^0.2.0",
         "v8-compile-cache": "^2.0.3"
@@ -9047,9 +9080,9 @@
       }
     },
     "eslint-visitor-keys": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.0.0.tgz",
-      "integrity": "sha512-mJOZa35trBTb3IyRmo8xmKBZlxf+N7OnUl4+ZhJHs/r+0770Wh/LEACE2pqMGMe27G/4y8P2bYGk4J70IC5k1Q==",
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.1.0.tgz",
+      "integrity": "sha512-yWJFpu4DtjsWKkt5GeNBBuZMlNcYVs6vRCLoCVEJrTjaSB6LC98gFipNK/erM2Heg/E8mIK+hXG/pJMLK+eRZA==",
       "dev": true
     },
     "espree": {
@@ -11377,12 +11410,12 @@
       }
     },
     "strip-ansi": {
-      "version": "6.0.0",
-      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
-      "integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
       "dev": true,
       "requires": {
-        "ansi-regex": "^5.0.0"
+        "ansi-regex": "^5.0.1"
       }
     },
     "strip-bom": {
diff --git a/package.json b/package.json
index 7dbf09e7..39fe651a 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,7 @@
     "cross-env": "^7.0.3",
     "cssnano": "^5.0.10",
     "esbuild": "^0.13.12",
-    "eslint": "^8.0.1",
+    "eslint": "^8.2.0",
     "eslint-config-prettier": "^8.3.0",
     "eslint-plugin-prettier": "^4.0.0",
     "jest": "^27.3.1",
diff --git a/src/corePlugins.js b/src/corePlugins.js
index 5309475c..beeb7d95 100644
--- a/src/corePlugins.js
+++ b/src/corePlugins.js
@@ -11,6 +11,7 @@ import isPlainObject from './util/isPlainObject'
 import transformThemeValue from './util/transformThemeValue'
 import { version as tailwindVersion } from '../package.json'
 import log from './util/log'
+import { normalizeScreens } from './util/normalizeScreens'
 import { formatBoxShadowValue, parseBoxShadowValue } from './util/parseBoxShadowValue'
 
 export let variantPlugins = {
@@ -23,6 +24,8 @@ export let variantPlugins = {
 
     addVariant('file', '&::file-selector-button')
 
+    addVariant('placeholder', '&::placeholder')
+
     addVariant('before', ({ container }) => {
       container.walkRules((rule) => {
         let foundContent = false
@@ -156,13 +159,17 @@ export let variantPlugins = {
   },
 
   screenVariants: ({ theme, addVariant }) => {
-    for (let screen in theme('screens')) {
-      let size = theme('screens')[screen]
-      let query = buildMediaQuery(size)
+    for (let screen of normalizeScreens(theme('screens'))) {
+      let query = buildMediaQuery(screen)
 
-      addVariant(screen, `@media ${query}`)
+      addVariant(screen.name, `@media ${query}`)
     }
   },
+
+  orientationVariants: ({ addVariant }) => {
+    addVariant('portrait', '@media (orientation: portrait)')
+    addVariant('landscape', '@media (orientation: landscape)')
+  },
 }
 
 export let corePlugins = {
@@ -180,24 +187,10 @@ export let corePlugins = {
   },
 
   container: (() => {
-    function extractMinWidths(breakpoints) {
-      return Object.values(breakpoints ?? {}).flatMap((breakpoints) => {
-        if (typeof breakpoints === 'string') {
-          breakpoints = { min: breakpoints }
-        }
-
-        if (!Array.isArray(breakpoints)) {
-          breakpoints = [breakpoints]
-        }
-
-        return breakpoints
-          .filter((breakpoint) => {
-            return breakpoint?.hasOwnProperty?.('min') || breakpoint?.hasOwnProperty('min-width')
-          })
-          .map((breakpoint) => {
-            return breakpoint['min-width'] ?? breakpoint.min
-          })
-      })
+    function extractMinWidths(breakpoints = []) {
+      return breakpoints
+        .flatMap((breakpoint) => breakpoint.values.map((breakpoint) => breakpoint.min))
+        .filter((v) => v !== undefined)
     }
 
     function mapMinWidthsToPadding(minWidths, screens, paddings) {
@@ -226,16 +219,11 @@ export let corePlugins = {
       }
 
       for (let minWidth of minWidths) {
-        for (let [screen, value] of Object.entries(screens)) {
-          let screenMinWidth =
-            typeof value === 'object' && value !== null ? value.min || value['min-width'] : value
-
-          if (`${screenMinWidth}` === `${minWidth}`) {
-            mapping.push({
-              screen,
-              minWidth,
-              padding: paddings[screen],
-            })
+        for (let screen of screens) {
+          for (let { min } of screen.values) {
+            if (min === minWidth) {
+              mapping.push({ minWidth, padding: paddings[screen.name] })
+            }
           }
         }
       }
@@ -244,12 +232,12 @@ export let corePlugins = {
     }
 
     return function ({ addComponents, theme }) {
-      let screens = theme('container.screens', theme('screens'))
+      let screens = normalizeScreens(theme('container.screens', theme('screens')))
       let minWidths = extractMinWidths(screens)
       let paddings = mapMinWidthsToPadding(minWidths, screens, theme('container.padding'))
 
       let generatePaddingFor = (minWidth) => {
-        let paddingConfig = paddings.find((padding) => `${padding.minWidth}` === `${minWidth}`)
+        let paddingConfig = paddings.find((padding) => padding.minWidth === minWidth)
 
         if (!paddingConfig) {
           return {}
@@ -619,17 +607,54 @@ export let corePlugins = {
 
   cursor: createUtilityPlugin('cursor'),
 
-  touchAction: ({ addUtilities }) => {
+  touchAction: ({ addBase, addUtilities }) => {
+    addBase({
+      '@defaults touch-action': {
+        '--tw-pan-x': 'var(--tw-empty,/*!*/ /*!*/)',
+        '--tw-pan-y': 'var(--tw-empty,/*!*/ /*!*/)',
+        '--tw-pinch-zoom': 'var(--tw-empty,/*!*/ /*!*/)',
+        '--tw-touch-action': 'var(--tw-pan-x) var(--tw-pan-y) var(--tw-pinch-zoom)',
+      },
+    })
+
     addUtilities({
       '.touch-auto': { 'touch-action': 'auto' },
       '.touch-none': { 'touch-action': 'none' },
-      '.touch-pan-x': { 'touch-action': 'pan-x' },
-      '.touch-pan-left': { 'touch-action': 'pan-left' },
-      '.touch-pan-right': { 'touch-action': 'pan-right' },
-      '.touch-pan-y': { 'touch-action': 'pan-y' },
-      '.touch-pan-up': { 'touch-action': 'pan-up' },
-      '.touch-pan-down': { 'touch-action': 'pan-down' },
-      '.touch-pinch-zoom': { 'touch-action': 'pinch-zoom' },
+      '.touch-pan-x': {
+        '@defaults touch-action': {},
+        '--tw-pan-x': 'pan-x',
+        'touch-action': 'var(--tw-touch-action)',
+      },
+      '.touch-pan-left': {
+        '@defaults touch-action': {},
+        '--tw-pan-x': 'pan-left',
+        'touch-action': 'var(--tw-touch-action)',
+      },
+      '.touch-pan-right': {
+        '@defaults touch-action': {},
+        '--tw-pan-x': 'pan-right',
+        'touch-action': 'var(--tw-touch-action)',
+      },
+      '.touch-pan-y': {
+        '@defaults touch-action': {},
+        '--tw-pan-y': 'pan-y',
+        'touch-action': 'var(--tw-touch-action)',
+      },
+      '.touch-pan-up': {
+        '@defaults touch-action': {},
+        '--tw-pan-y': 'pan-up',
+        'touch-action': 'var(--tw-touch-action)',
+      },
+      '.touch-pan-down': {
+        '@defaults touch-action': {},
+        '--tw-pan-y': 'pan-down',
+        'touch-action': 'var(--tw-touch-action)',
+      },
+      '.touch-pinch-zoom': {
+        '@defaults touch-action': {},
+        '--tw-pinch-zoom': 'pinch-zoom',
+        'touch-action': 'var(--tw-touch-action)',
+      },
       '.touch-manipulation': { 'touch-action': 'manipulation' },
     })
   },
diff --git a/src/lib/evaluateTailwindFunctions.js b/src/lib/evaluateTailwindFunctions.js
index fb3474b2..778230e5 100644
--- a/src/lib/evaluateTailwindFunctions.js
+++ b/src/lib/evaluateTailwindFunctions.js
@@ -2,6 +2,7 @@ import dlv from 'dlv'
 import didYouMean from 'didyoumean'
 import transformThemeValue from '../util/transformThemeValue'
 import parseValue from 'postcss-value-parser'
+import { normalizeScreens } from '../util/normalizeScreens'
 import buildMediaQuery from '../util/buildMediaQuery'
 import { toPath } from '../util/toPath'
 
@@ -173,12 +174,14 @@ export default function ({ tailwindConfig: config }) {
     },
     screen: (node, screen) => {
       screen = screen.replace(/^['"]+/g, '').replace(/['"]+$/g, '')
+      let screens = normalizeScreens(config.theme.screens)
+      let screenDefinition = screens.find(({ name }) => name === screen)
 
-      if (config.theme.screens[screen] === undefined) {
+      if (!screenDefinition) {
         throw node.error(`The '${screen}' screen does not exist in your theme.`)
       }
 
-      return buildMediaQuery(config.theme.screens[screen])
+      return buildMediaQuery(screenDefinition)
     },
   }
   return (root) => {
diff --git a/src/lib/expandTailwindAtRules.js b/src/lib/expandTailwindAtRules.js
index 14a1c8af..4b1c6270 100644
--- a/src/lib/expandTailwindAtRules.js
+++ b/src/lib/expandTailwindAtRules.js
@@ -15,6 +15,8 @@ const PATTERNS = [
   /([^<>"'`\s]*\[\w*\("[^'`\s]*"\)\])/.source, // bg-[url("..."),url("...")]
   /([^<>"'`\s]*\['[^"'`\s]*'\])/.source, // `content-['hello']` but not `content-['hello']']`
   /([^<>"'`\s]*\["[^"'`\s]*"\])/.source, // `content-["hello"]` but not `content-["hello"]"]`
+  /([^<>"'`\s]*\[[^<>"'`\s]*:'[^"'`\s]*'\])/.source, // `[content:'hello']` but not `[content:"hello"]`
+  /([^<>"'`\s]*\[[^<>"'`\s]*:"[^"'`\s]*"\])/.source, // `[content:"hello"]` but not `[content:'hello']`
   /([^<>"'`\s]*\[[^"'`\s]+\][^<>"'`\s]*)/.source, // `fill-[#bada55]`, `fill-[#bada55]/50`
   /([^<>"'`\s]*[^"'`\s:])/.source, //  `px-1.5`, `uppercase` but not `uppercase:`
 ].join('|')
diff --git a/src/lib/generateRules.js b/src/lib/generateRules.js
index cdb52947..521b3502 100644
--- a/src/lib/generateRules.js
+++ b/src/lib/generateRules.js
@@ -6,6 +6,9 @@ import prefixSelector from '../util/prefixSelector'
 import { updateAllClasses } from '../util/pluginUtils'
 import log from '../util/log'
 import { formatVariantSelector, finalizeSelector } from '../util/formatVariantSelector'
+import { asClass } from '../util/nameClass'
+import { normalize } from '../util/dataTypes'
+import isValidArbitraryValue from '../util/isValidArbitraryValue'
 
 let classNameParser = selectorParser((selectors) => {
   return selectors.first.filter(({ type }) => type === 'class').pop().value
@@ -245,11 +248,42 @@ function parseRules(rule, cache, options = {}) {
   return [cache.get(rule), options]
 }
 
+function extractArbitraryProperty(classCandidate, context) {
+  let [, property, value] = classCandidate.match(/^\[([a-zA-Z0-9-_]+):(\S+)\]$/) ?? []
+
+  if (value === undefined) {
+    return null
+  }
+
+  let normalized = normalize(value)
+
+  if (!isValidArbitraryValue(normalized)) {
+    return null
+  }
+
+  return [
+    [
+      { sort: context.arbitraryPropertiesSort, layer: 'utilities' },
+      () => ({
+        [asClass(classCandidate)]: {
+          [property]: normalized,
+        },
+      }),
+    ],
+  ]
+}
+
 function* resolveMatchedPlugins(classCandidate, context) {
   if (context.candidateRuleMap.has(classCandidate)) {
     yield [context.candidateRuleMap.get(classCandidate), 'DEFAULT']
   }
 
+  yield* (function* (arbitraryPropertyRule) {
+    if (arbitraryPropertyRule !== null) {
+      yield [arbitraryPropertyRule, 'DEFAULT']
+    }
+  })(extractArbitraryProperty(classCandidate, context))
+
   let candidatePrefix = classCandidate
   let negative = false
 
diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js
index 2701adb9..b242e802 100644
--- a/src/lib/setupContextUtils.js
+++ b/src/lib/setupContextUtils.js
@@ -18,6 +18,7 @@ import { env } from './sharedState'
 import { toPath } from '../util/toPath'
 import log from '../util/log'
 import negateValue from '../util/negateValue'
+import isValidArbitraryValue from '../util/isValidArbitraryValue'
 
 function parseVariantFormatString(input) {
   if (input.includes('{')) {
@@ -130,64 +131,6 @@ function withIdentifiers(styles) {
   })
 }
 
-let matchingBrackets = new Map([
-  ['{', '}'],
-  ['[', ']'],
-  ['(', ')'],
-])
-let inverseMatchingBrackets = new Map(
-  Array.from(matchingBrackets.entries()).map(([k, v]) => [v, k])
-)
-
-let quotes = new Set(['"', "'", '`'])
-
-// Arbitrary values must contain balanced brackets (), [] and {}. Escaped
-// values don't count, and brackets inside quotes also don't count.
-//
-// E.g.: w-[this-is]w-[weird-and-invalid]
-// E.g.: w-[this-is\\]w-\\[weird-but-valid]
-// E.g.: content-['this-is-also-valid]-weirdly-enough']
-function isValidArbitraryValue(value) {
-  let stack = []
-  let inQuotes = false
-
-  for (let i = 0; i < value.length; i++) {
-    let char = value[i]
-
-    // Non-escaped quotes allow us to "allow" anything in between
-    if (quotes.has(char) && value[i - 1] !== '\\') {
-      inQuotes = !inQuotes
-    }
-
-    if (inQuotes) continue
-    if (value[i - 1] === '\\') continue // Escaped
-
-    if (matchingBrackets.has(char)) {
-      stack.push(char)
-    } else if (inverseMatchingBrackets.has(char)) {
-      let inverse = inverseMatchingBrackets.get(char)
-
-      // Nothing to pop from, therefore it is unbalanced
-      if (stack.length <= 0) {
-        return false
-      }
-
-      // Popped value must match the inverse value, otherwise it is unbalanced
-      if (stack.pop() !== inverse) {
-        return false
-      }
-    }
-  }
-
-  // If there is still something on the stack, it is also unbalanced
-  if (stack.length > 0) {
-    return false
-  }
-
-  // All good, totally balanced!
-  return true
-}
-
 function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offsets, classList }) {
   function getConfigValue(path, defaultValue) {
     return path ? dlv(tailwindConfig, path, defaultValue) : tailwindConfig
@@ -575,6 +518,7 @@ function resolvePlugins(context, root) {
     variantPlugins['darkVariants'],
     variantPlugins['printVariant'],
     variantPlugins['screenVariants'],
+    variantPlugins['orientationVariants'],
   ]
 
   return [...corePluginList, ...beforeVariants, ...userPlugins, ...afterVariants, ...layerPlugins]
@@ -617,6 +561,10 @@ function registerPlugins(plugins, context) {
   ])
   let reservedBits = BigInt(highestOffset.toString(2).length)
 
+  // A number one less than the top range of the highest offset area
+  // so arbitrary properties are always sorted at the end.
+  context.arbitraryPropertiesSort = ((1n << reservedBits) << 0n) - 1n
+
   context.layerOrder = {
     base: (1n << reservedBits) << 0n,
     components: (1n << reservedBits) << 1n,
diff --git a/src/lib/substituteScreenAtRules.js b/src/lib/substituteScreenAtRules.js
index 1b61d38f..5a45cff2 100644
--- a/src/lib/substituteScreenAtRules.js
+++ b/src/lib/substituteScreenAtRules.js
@@ -1,16 +1,19 @@
+import { normalizeScreens } from '../util/normalizeScreens'
 import buildMediaQuery from '../util/buildMediaQuery'
 
 export default function ({ tailwindConfig: { theme } }) {
   return function (css) {
     css.walkAtRules('screen', (atRule) => {
-      const screen = atRule.params
+      let screen = atRule.params
+      let screens = normalizeScreens(theme.screens)
+      let screenDefinition = screens.find(({ name }) => name === screen)
 
-      if (!theme.screens?.hasOwnProperty?.(screen)) {
+      if (!screenDefinition) {
         throw atRule.error(`No \`${screen}\` screen found.`)
       }
 
       atRule.name = 'media'
-      atRule.params = buildMediaQuery(theme.screens[screen])
+      atRule.params = buildMediaQuery(screenDefinition)
     })
   }
 }
diff --git a/src/util/buildMediaQuery.js b/src/util/buildMediaQuery.js
index ba3b696e..ccfc63ba 100644
--- a/src/util/buildMediaQuery.js
+++ b/src/util/buildMediaQuery.js
@@ -1,24 +1,20 @@
 export default function buildMediaQuery(screens) {
-  if (typeof screens === 'string') {
-    screens = { min: screens }
-  }
-
-  if (!Array.isArray(screens)) {
-    screens = [screens]
-  }
+  screens = Array.isArray(screens) ? screens : [screens]
 
   return screens
-    .map((screen) => {
-      if (screen?.hasOwnProperty?.('raw')) {
-        return screen.raw
-      }
+    .map((screen) =>
+      screen.values.map((screen) => {
+        if (screen.raw !== undefined) {
+          return screen.raw
+        }
 
-      return Object.entries(screen)
-        .map(([feature, value]) => {
-          feature = { min: 'min-width', max: 'max-width' }[feature] ?? feature
-          return `(${feature}: ${value})`
-        })
-        .join(' and ')
-    })
+        return [
+          screen.min && `(min-width: ${screen.min})`,
+          screen.max && `(max-width: ${screen.max})`,
+        ]
+          .filter(Boolean)
+          .join(' and ')
+      })
+    )
     .join(', ')
 }
diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js
index d24471cc..06d0f489 100644
--- a/src/util/formatVariantSelector.js
+++ b/src/util/formatVariantSelector.js
@@ -74,11 +74,92 @@ export function finalizeSelector(format, { selector, candidate, context }) {
         return p
       })
 
+      // This will make sure to move pseudo's to the correct spot (the end for
+      // pseudo elements) because otherwise the selector will never work
+      // anyway.
+      //
+      // E.g.:
+      //  - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
+      //  - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
+      //
+      // `::before:hover` doesn't work, which means that we can make it work for you by flipping the order.
+      function collectPseudoElements(selector) {
+        let nodes = []
+
+        for (let node of selector.nodes) {
+          if (isPseudoElement(node)) {
+            nodes.push(node)
+            selector.removeChild(node)
+          }
+
+          if (node?.nodes) {
+            nodes.push(...collectPseudoElements(node))
+          }
+        }
+
+        return nodes
+      }
+
+      let pseudoElements = collectPseudoElements(selector)
+      if (pseudoElements.length > 0) {
+        selector.nodes.push(pseudoElements.sort(sortSelector))
+      }
+
       return selector
     })
   }).processSync(selector)
 }
 
+// Note: As a rule, double colons (::) should be used instead of a single colon
+// (:). This distinguishes pseudo-classes from pseudo-elements. However, since
+// this distinction was not present in older versions of the W3C spec, most
+// browsers support both syntaxes for the original pseudo-elements.
+let pseudoElementsBC = [':before', ':after', ':first-line', ':first-letter']
+
+// These pseudo-elements _can_ be combined with other pseudo selectors AND the order does matter.
+let pseudoElementExceptions = ['::file-selector-button']
+
+// This will make sure to move pseudo's to the correct spot (the end for
+// pseudo elements) because otherwise the selector will never work
+// anyway.
+//
+// E.g.:
+//  - `before:hover:text-center` would result in `.before\:hover\:text-center:hover::before`
+//  - `hover:before:text-center` would result in `.hover\:before\:text-center:hover::before`
+//
+// `::before:hover` doesn't work, which means that we can make it work
+// for you by flipping the order.
+function sortSelector(a, z) {
+  // Both nodes are non-pseudo's so we can safely ignore them and keep
+  // them in the same order.
+  if (a.type !== 'pseudo' && z.type !== 'pseudo') {
+    return 0
+  }
+
+  // If one of them is a combinator, we need to keep it in the same order
+  // because that means it will start a new "section" in the selector.
+  if ((a.type === 'combinator') ^ (z.type === 'combinator')) {
+    return 0
+  }
+
+  // One of the items is a pseudo and the other one isn't. Let's move
+  // the pseudo to the right.
+  if ((a.type === 'pseudo') ^ (z.type === 'pseudo')) {
+    return (a.type === 'pseudo') - (z.type === 'pseudo')
+  }
+
+  // Both are pseudo's, move the pseudo elements (except for
+  // ::file-selector-button) to the right.
+  return isPseudoElement(a) - isPseudoElement(z)
+}
+
+function isPseudoElement(node) {
+  if (node.type !== 'pseudo') return false
+  if (pseudoElementExceptions.includes(node.value)) return false
+
+  return node.value.startsWith('::') || pseudoElementsBC.includes(node.value)
+}
+
 function resolveFunctionArgument(haystack, needle, arg) {
   let startIdx = haystack.indexOf(arg ? `${needle}(${arg})` : needle)
   if (startIdx === -1) return null
diff --git a/src/util/isValidArbitraryValue.js b/src/util/isValidArbitraryValue.js
new file mode 100644
index 00000000..f2b10b51
--- /dev/null
+++ b/src/util/isValidArbitraryValue.js
@@ -0,0 +1,61 @@
+let matchingBrackets = new Map([
+  ['{', '}'],
+  ['[', ']'],
+  ['(', ')'],
+])
+let inverseMatchingBrackets = new Map(
+  Array.from(matchingBrackets.entries()).map(([k, v]) => [v, k])
+)
+
+let quotes = new Set(['"', "'", '`'])
+
+// Arbitrary values must contain balanced brackets (), [] and {}. Escaped
+// values don't count, and brackets inside quotes also don't count.
+//
+// E.g.: w-[this-is]w-[weird-and-invalid]
+// E.g.: w-[this-is\\]w-\\[weird-but-valid]
+// E.g.: content-['this-is-also-valid]-weirdly-enough']
+export default function isValidArbitraryValue(value) {
+  let stack = []
+  let inQuotes = false
+
+  for (let i = 0; i < value.length; i++) {
+    let char = value[i]
+
+    if (char === ':' && !inQuotes && stack.length === 0) {
+      return false
+    }
+
+    // Non-escaped quotes allow us to "allow" anything in between
+    if (quotes.has(char) && value[i - 1] !== '\\') {
+      inQuotes = !inQuotes
+    }
+
+    if (inQuotes) continue
+    if (value[i - 1] === '\\') continue // Escaped
+
+    if (matchingBrackets.has(char)) {
+      stack.push(char)
+    } else if (inverseMatchingBrackets.has(char)) {
+      let inverse = inverseMatchingBrackets.get(char)
+
+      // Nothing to pop from, therefore it is unbalanced
+      if (stack.length <= 0) {
+        return false
+      }
+
+      // Popped value must match the inverse value, otherwise it is unbalanced
+      if (stack.pop() !== inverse) {
+        return false
+      }
+    }
+  }
+
+  // If there is still something on the stack, it is also unbalanced
+  if (stack.length > 0) {
+    return false
+  }
+
+  // All good, totally balanced!
+  return true
+}
diff --git a/src/util/nameClass.js b/src/util/nameClass.js
index e3a40f8e..ae737012 100644
--- a/src/util/nameClass.js
+++ b/src/util/nameClass.js
@@ -1,7 +1,7 @@
 import escapeClassName from './escapeClassName'
 import escapeCommas from './escapeCommas'
 
-function asClass(name) {
+export function asClass(name) {
   return escapeCommas(`.${escapeClassName(name)}`)
 }
 
diff --git a/src/util/normalizeScreens.js b/src/util/normalizeScreens.js
new file mode 100644
index 00000000..e7bcefe6
--- /dev/null
+++ b/src/util/normalizeScreens.js
@@ -0,0 +1,42 @@
+/**
+ * A function that normalizes the various forms that the screens object can be
+ * provided in.
+ *
+ * Input(s):
+ *   - ['100px', '200px'] // Raw strings
+ *   - { sm: '100px', md: '200px' } // Object with string values
+ *   - { sm: { min: '100px' }, md: { max: '100px' } } // Object with object values
+ *   - { sm: [{ min: '100px' }, { max: '200px' }] } // Object with object array (multiple values)
+ *   - [['sm', '100px'], ['md', '200px']] // Tuple object
+ *
+ * Output(s):
+ *   - [{ name: 'sm', values: [{ min: '100px', max: '200px' }] }] // List of objects, that contains multiple values
+ */
+export function normalizeScreens(screens) {
+  if (Array.isArray(screens)) {
+    return screens.map((screen) => {
+      if (typeof screen === 'string') {
+        return { name: screen.toString(), values: [{ min: screen, max: undefined }] }
+      }
+
+      let [name, options] = screen
+      name = name.toString()
+
+      if (typeof options === 'string') {
+        return { name, values: [{ min: options, max: undefined }] }
+      }
+
+      if (Array.isArray(options)) {
+        return { name, values: options.map((option) => resolveValue(option)) }
+      }
+
+      return { name, values: [resolveValue(options)] }
+    })
+  }
+
+  return normalizeScreens(Object.entries(screens ?? {}))
+}
+
+function resolveValue({ 'min-width': _minWidth, min = _minWidth, max, raw } = {}) {
+  return { min, max, raw }
+}


Test patch:
diff --git a/tests/arbitrary-properties.test.js b/tests/arbitrary-properties.test.js
new file mode 100644
index 00000000..1b98e49d
--- /dev/null
+++ b/tests/arbitrary-properties.test.js
@@ -0,0 +1,233 @@
+import { run, html, css } from './util/run'
+
+test('basic arbitrary properties', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="[paint-order:markers]"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .\[paint-order\:markers\] {
+        paint-order: markers;
+      }
+    `)
+  })
+})
+
+test('arbitrary properties with modifiers', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="dark:lg:hover:[paint-order:markers]"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      @media (prefers-color-scheme: dark) {
+        @media (min-width: 1024px) {
+          .\[paint-order\:markers\] {
+            paint-order: markers;
+          }
+        }
+      }
+    `)
+  })
+})
+
+test('arbitrary properties are sorted after utilities', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="content-none [paint-order:markers] hover:pointer-events-none"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .content-none {
+        --tw-content: none;
+        content: var(--tw-content);
+      }
+      .\[paint-order\:markers\] {
+        paint-order: markers;
+      }
+      .hover\:pointer-events-none:hover {
+        pointer-events: none;
+      }
+    `)
+  })
+})
+
+test('using CSS variables', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="[--my-var:auto]"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .\[--my-var\:auto\] {
+        --my-var: auto;
+      }
+    `)
+  })
+})
+
+test('using underscores as spaces', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="[--my-var:2px_4px]"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .\[--my-var\:2px_4px\] {
+        --my-var: 2px 4px;
+      }
+    `)
+  })
+})
+
+test('using the important modifier', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="![--my-var:2px_4px]"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .\!\[--my-var\:2px_4px\] {
+        --my-var: 2px 4px !important;
+      }
+    `)
+  })
+})
+
+test('colons are allowed in quotes', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="[content:'foo:bar']"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .\[content\:\'foo\:bar\'\] {
+        content: 'foo:bar';
+      }
+    `)
+  })
+})
+
+test('colons are allowed in braces', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="[background-image:url(http://example.com/picture.jpg)]"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .\[background-image\:url\(http\:\/\/example\.com\/picture\.jpg\)\] {
+        background-image: url(http://example.com/picture.jpg);
+      }
+    `)
+  })
+})
+
+test('invalid class', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div class="[a:b:c:d]"></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind base;
+    @tailwind components;
+    @tailwind utilities;
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css``)
+  })
+})
diff --git a/tests/basic-usage.test.css b/tests/basic-usage.test.css
index 3e491b6b..6eb35b5b 100644
--- a/tests/basic-usage.test.css
+++ b/tests/basic-usage.test.css
@@ -19,6 +19,16 @@
     scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
 }
 
+.touch-pan-x,
+.touch-pan-y,
+.touch-pan-up,
+.touch-pinch-zoom {
+  --tw-pan-x: var(--tw-empty, /*!*/ /*!*/);
+  --tw-pan-y: var(--tw-empty, /*!*/ /*!*/);
+  --tw-pinch-zoom: var(--tw-empty, /*!*/ /*!*/);
+  --tw-touch-action: var(--tw-pan-x) var(--tw-pan-y) var(--tw-pinch-zoom);
+}
+
 .snap-x {
   --tw-scroll-snap-strictness: proximity;
 }
@@ -374,8 +384,21 @@
 .cursor-pointer {
   cursor: pointer;
 }
+.touch-pan-x {
+  --tw-pan-x: pan-x;
+  touch-action: var(--tw-touch-action);
+}
 .touch-pan-y {
-  touch-action: pan-y;
+  --tw-pan-y: pan-y;
+  touch-action: var(--tw-touch-action);
+}
+.touch-pan-up {
+  --tw-pan-y: pan-up;
+  touch-action: var(--tw-touch-action);
+}
+.touch-pinch-zoom {
+  --tw-pinch-zoom: pinch-zoom;
+  touch-action: var(--tw-touch-action);
 }
 .touch-manipulation {
   touch-action: manipulation;
diff --git a/tests/basic-usage.test.html b/tests/basic-usage.test.html
index 9d90c57a..4a64e43c 100644
--- a/tests/basic-usage.test.html
+++ b/tests/basic-usage.test.html
@@ -170,6 +170,7 @@
     <div class="transition transition-all"></div>
     <div class="ease-in-out"></div>
     <div class="translate-x-5 -translate-x-4 translate-y-6 -translate-x-3"></div>
+    <div class="touch-pan-up touch-pan-x touch-pinch-zoom"></div>
     <div class="select-none"></div>
     <div class="align-middle"></div>
     <div class="invisible"></div>
diff --git a/tests/containerPlugin.test.js b/tests/containerPlugin.test.js
index 51b7093b..7dfc7352 100644
--- a/tests/containerPlugin.test.js
+++ b/tests/containerPlugin.test.js
@@ -343,3 +343,39 @@ test('container can use variants', () => {
     `)
   })
 })
+
+test('container can use screens with tuple syntax', () => {
+  let config = {
+    content: [{ raw: html`<div class="container"></div>` }],
+    theme: {
+      screens: [
+        [1800, { min: '1800px' }],
+        [1200, { min: '1200px' }],
+        [768, { min: '768px' }],
+      ],
+    },
+  }
+
+  return run('@tailwind components', config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .container {
+        width: 100%;
+      }
+      @media (min-width: 768px) {
+        .container {
+          max-width: 768px;
+        }
+      }
+      @media (min-width: 1200px) {
+        .container {
+          max-width: 1200px;
+        }
+      }
+      @media (min-width: 1800px) {
+        .container {
+          max-width: 1800px;
+        }
+      }
+    `)
+  })
+})
diff --git a/tests/evaluateTailwindFunctions.test.js b/tests/evaluateTailwindFunctions.test.js
index b2c69a0a..01d7393b 100644
--- a/tests/evaluateTailwindFunctions.test.js
+++ b/tests/evaluateTailwindFunctions.test.js
@@ -1,17 +1,22 @@
 import postcss from 'postcss'
 import plugin from '../src/lib/evaluateTailwindFunctions'
+import { css } from './util/run'
 
 function run(input, opts = {}) {
   return postcss([plugin({ tailwindConfig: opts })]).process(input, { from: undefined })
 }
 
 test('it looks up values in the theme using dot notation', () => {
-  const input = `
-    .banana { color: theme('colors.yellow'); }
+  let input = css`
+    .banana {
+      color: theme('colors.yellow');
+    }
   `
 
-  const output = `
-    .banana { color: #f7cc50; }
+  let output = css`
+    .banana {
+      color: #f7cc50;
+    }
   `
 
   return run(input, {
@@ -27,37 +32,85 @@ test('it looks up values in the theme using dot notation', () => {
 })
 
 test('color can be a function', () => {
-  const input = `
-    .backgroundColor { color: theme('backgroundColor.fn') }
-    .borderColor { color: theme('borderColor.fn') }
-    .caretColor { color: theme('caretColor.fn') }
-    .colors { color: theme('colors.fn') }
-    .divideColor { color: theme('divideColor.fn') }
-    .fill { color: theme('fill.fn') }
-    .gradientColorStops { color: theme('gradientColorStops.fn') }
-    .placeholderColor { color: theme('placeholderColor.fn') }
-    .ringColor { color: theme('ringColor.fn') }
-    .ringOffsetColor { color: theme('ringOffsetColor.fn') }
-    .stroke { color: theme('stroke.fn') }
-    .textColor { color: theme('textColor.fn') }
-  `
-
-  const output = `
-    .backgroundColor { color: #f00 }
-    .borderColor { color: #f00 }
-    .caretColor { color: #f00 }
-    .colors { color: #f00 }
-    .divideColor { color: #f00 }
-    .fill { color: #f00 }
-    .gradientColorStops { color: #f00 }
-    .placeholderColor { color: #f00 }
-    .ringColor { color: #f00 }
-    .ringOffsetColor { color: #f00 }
-    .stroke { color: #f00 }
-    .textColor { color: #f00 }
-  `
-
-  const fn = () => `#f00`
+  let input = css`
+    .backgroundColor {
+      color: theme('backgroundColor.fn');
+    }
+    .borderColor {
+      color: theme('borderColor.fn');
+    }
+    .caretColor {
+      color: theme('caretColor.fn');
+    }
+    .colors {
+      color: theme('colors.fn');
+    }
+    .divideColor {
+      color: theme('divideColor.fn');
+    }
+    .fill {
+      color: theme('fill.fn');
+    }
+    .gradientColorStops {
+      color: theme('gradientColorStops.fn');
+    }
+    .placeholderColor {
+      color: theme('placeholderColor.fn');
+    }
+    .ringColor {
+      color: theme('ringColor.fn');
+    }
+    .ringOffsetColor {
+      color: theme('ringOffsetColor.fn');
+    }
+    .stroke {
+      color: theme('stroke.fn');
+    }
+    .textColor {
+      color: theme('textColor.fn');
+    }
+  `
+
+  let output = css`
+    .backgroundColor {
+      color: #f00;
+    }
+    .borderColor {
+      color: #f00;
+    }
+    .caretColor {
+      color: #f00;
+    }
+    .colors {
+      color: #f00;
+    }
+    .divideColor {
+      color: #f00;
+    }
+    .fill {
+      color: #f00;
+    }
+    .gradientColorStops {
+      color: #f00;
+    }
+    .placeholderColor {
+      color: #f00;
+    }
+    .ringColor {
+      color: #f00;
+    }
+    .ringOffsetColor {
+      color: #f00;
+    }
+    .stroke {
+      color: #f00;
+    }
+    .textColor {
+      color: #f00;
+    }
+  `
+
+  let fn = () => `#f00`
 
   return run(input, {
     theme: {
@@ -81,12 +134,16 @@ test('color can be a function', () => {
 })
 
 test('quotes are optional around the lookup path', () => {
-  const input = `
-    .banana { color: theme(colors.yellow); }
+  let input = css`
+    .banana {
+      color: theme(colors.yellow);
+    }
   `
 
-  const output = `
-    .banana { color: #f7cc50; }
+  let output = css`
+    .banana {
+      color: #f7cc50;
+    }
   `
 
   return run(input, {
@@ -102,12 +159,16 @@ test('quotes are optional around the lookup path', () => {
 })
 
 test('a default value can be provided', () => {
-  const input = `
-    .cookieMonster { color: theme('colors.blue', #0000ff); }
+  let input = css`
+    .cookieMonster {
+      color: theme('colors.blue', #0000ff);
+    }
   `
 
-  const output = `
-    .cookieMonster { color: #0000ff; }
+  let output = css`
+    .cookieMonster {
+      color: #0000ff;
+    }
   `
 
   return run(input, {
@@ -123,12 +184,16 @@ test('a default value can be provided', () => {
 })
 
 test('the default value can use the theme function', () => {
-  const input = `
-    .cookieMonster { color: theme('colors.blue', theme('colors.yellow')); }
+  let input = css`
+    .cookieMonster {
+      color: theme('colors.blue', theme('colors.yellow'));
+    }
   `
 
-  const output = `
-    .cookieMonster { color: #f7cc50; }
+  let output = css`
+    .cookieMonster {
+      color: #f7cc50;
+    }
   `
 
   return run(input, {
@@ -144,12 +209,16 @@ test('the default value can use the theme function', () => {
 })
 
 test('quotes are preserved around default values', () => {
-  const input = `
-    .heading { font-family: theme('fontFamily.sans', "Helvetica Neue"); }
+  let input = css`
+    .heading {
+      font-family: theme('fontFamily.sans', 'Helvetica Neue');
+    }
   `
 
-  const output = `
-    .heading { font-family: "Helvetica Neue"; }
+  let output = css`
+    .heading {
+      font-family: 'Helvetica Neue';
+    }
   `
 
   return run(input, {
@@ -165,12 +234,16 @@ test('quotes are preserved around default values', () => {
 })
 
 test('an unquoted list is valid as a default value', () => {
-  const input = `
-    .heading { font-family: theme('fontFamily.sans', Helvetica, Arial, sans-serif); }
+  let input = css`
+    .heading {
+      font-family: theme('fontFamily.sans', Helvetica, Arial, sans-serif);
+    }
   `
 
-  const output = `
-    .heading { font-family: Helvetica, Arial, sans-serif; }
+  let output = css`
+    .heading {
+      font-family: Helvetica, Arial, sans-serif;
+    }
   `
 
   return run(input, {
@@ -186,8 +259,10 @@ test('an unquoted list is valid as a default value', () => {
 })
 
 test('a missing root theme value throws', () => {
-  const input = `
-    .heading { color: theme('colours.gray.100'); }
+  let input = css`
+    .heading {
+      color: theme('colours.gray.100');
+    }
   `
 
   return expect(
@@ -204,8 +279,10 @@ test('a missing root theme value throws', () => {
 })
 
 test('a missing nested theme property throws', () => {
-  const input = `
-    .heading { color: theme('colors.red'); }
+  let input = css`
+    .heading {
+      color: theme('colors.red');
+    }
   `
 
   return expect(
@@ -223,8 +300,10 @@ test('a missing nested theme property throws', () => {
 })
 
 test('a missing nested theme property with a close alternative throws with a suggestion', () => {
-  const input = `
-    .heading { color: theme('colors.yellw'); }
+  let input = css`
+    .heading {
+      color: theme('colors.yellw');
+    }
   `
 
   return expect(
@@ -241,8 +320,10 @@ test('a missing nested theme property with a close alternative throws with a sug
 })
 
 test('a path through a non-object throws', () => {
-  const input = `
-    .heading { color: theme('colors.yellow.100'); }
+  let input = css`
+    .heading {
+      color: theme('colors.yellow.100');
+    }
   `
 
   return expect(
@@ -259,8 +340,10 @@ test('a path through a non-object throws', () => {
 })
 
 test('a path which exists but is not a string throws', () => {
-  const input = `
-    .heading { color: theme('colors.yellow'); }
+  let input = css`
+    .heading {
+      color: theme('colors.yellow');
+    }
   `
 
   return expect(
@@ -275,8 +358,10 @@ test('a path which exists but is not a string throws', () => {
 })
 
 test('a path which exists but is invalid throws', () => {
-  const input = `
-    .heading { color: theme('colors'); }
+  let input = css`
+    .heading {
+      color: theme('colors');
+    }
   `
 
   return expect(
@@ -289,8 +374,10 @@ test('a path which exists but is invalid throws', () => {
 })
 
 test('a path which is an object throws with a suggested key', () => {
-  const input = `
-    .heading { color: theme('colors'); }
+  let input = css`
+    .heading {
+      color: theme('colors');
+    }
   `
 
   return expect(
@@ -307,12 +394,16 @@ test('a path which is an object throws with a suggested key', () => {
 })
 
 test('array values are joined by default', () => {
-  const input = `
-    .heading { font-family: theme('fontFamily.sans'); }
+  let input = css`
+    .heading {
+      font-family: theme('fontFamily.sans');
+    }
   `
 
-  const output = `
-    .heading { font-family: Inter, Helvetica, sans-serif; }
+  let output = css`
+    .heading {
+      font-family: Inter, Helvetica, sans-serif;
+    }
   `
 
   return run(input, {
@@ -328,14 +419,22 @@ test('array values are joined by default', () => {
 })
 
 test('font sizes are retrieved without default line-heights or letter-spacing', () => {
-  const input = `
-    .heading-1 { font-size: theme('fontSize.lg'); }
-    .heading-2 { font-size: theme('fontSize.xl'); }
+  let input = css`
+    .heading-1 {
+      font-size: theme('fontSize.lg');
+    }
+    .heading-2 {
+      font-size: theme('fontSize.xl');
+    }
   `
 
-  const output = `
-    .heading-1 { font-size: 20px; }
-    .heading-2 { font-size: 24px; }
+  let output = css`
+    .heading-1 {
+      font-size: 20px;
+    }
+    .heading-2 {
+      font-size: 24px;
+    }
   `
 
   return run(input, {
@@ -352,12 +451,16 @@ test('font sizes are retrieved without default line-heights or letter-spacing',
 })
 
 test('outlines are retrieved without default outline-offset', () => {
-  const input = `
-    .element { outline: theme('outline.black'); }
+  let input = css`
+    .element {
+      outline: theme('outline.black');
+    }
   `
 
-  const output = `
-    .element { outline: 2px dotted black; }
+  let output = css`
+    .element {
+      outline: 2px dotted black;
+    }
   `
 
   return run(input, {
@@ -373,12 +476,16 @@ test('outlines are retrieved without default outline-offset', () => {
 })
 
 test('font-family values are joined when an array', () => {
-  const input = `
-    .element { font-family: theme('fontFamily.sans'); }
+  let input = css`
+    .element {
+      font-family: theme('fontFamily.sans');
+    }
   `
 
-  const output = `
-    .element { font-family: Helvetica, Arial, sans-serif; }
+  let output = css`
+    .element {
+      font-family: Helvetica, Arial, sans-serif;
+    }
   `
 
   return run(input, {
@@ -394,12 +501,16 @@ test('font-family values are joined when an array', () => {
 })
 
 test('box-shadow values are joined when an array', () => {
-  const input = `
-    .element { box-shadow: theme('boxShadow.wtf'); }
+  let input = css`
+    .element {
+      box-shadow: theme('boxShadow.wtf');
+    }
   `
 
-  const output = `
-    .element { box-shadow: 0 0 2px black, 1px 2px 3px white; }
+  let output = css`
+    .element {
+      box-shadow: 0 0 2px black, 1px 2px 3px white;
+    }
   `
 
   return run(input, {
@@ -415,12 +526,16 @@ test('box-shadow values are joined when an array', () => {
 })
 
 test('transition-property values are joined when an array', () => {
-  const input = `
-    .element { transition-property: theme('transitionProperty.colors'); }
+  let input = css`
+    .element {
+      transition-property: theme('transitionProperty.colors');
+    }
   `
 
-  const output = `
-    .element { transition-property: color, fill; }
+  let output = css`
+    .element {
+      transition-property: color, fill;
+    }
   `
 
   return run(input, {
@@ -436,12 +551,16 @@ test('transition-property values are joined when an array', () => {
 })
 
 test('transition-duration values are joined when an array', () => {
-  const input = `
-    .element { transition-duration: theme('transitionDuration.lol'); }
+  let input = css`
+    .element {
+      transition-duration: theme('transitionDuration.lol');
+    }
   `
 
-  const output = `
-    .element { transition-duration: 1s, 2s; }
+  let output = css`
+    .element {
+      transition-duration: 1s, 2s;
+    }
   `
 
   return run(input, {
@@ -457,16 +576,18 @@ test('transition-duration values are joined when an array', () => {
 })
 
 test('basic screen function calls are expanded', () => {
-  const input = `
+  let input = css`
     @media screen(sm) {
-      .foo {}
+      .foo {
+      }
     }
   `
 
-  const output = `
-  @media (min-width: 600px) {
-    .foo {}
-  }
+  let output = css`
+    @media (min-width: 600px) {
+      .foo {
+      }
+    }
   `
 
   return run(input, {
@@ -478,16 +599,18 @@ test('basic screen function calls are expanded', () => {
 })
 
 test('screen function supports max-width screens', () => {
-  const input = `
+  let input = css`
     @media screen(sm) {
-      .foo {}
+      .foo {
+      }
     }
   `
 
-  const output = `
-  @media (max-width: 600px) {
-    .foo {}
-  }
+  let output = css`
+    @media (max-width: 600px) {
+      .foo {
+      }
+    }
   `
 
   return run(input, {
@@ -499,16 +622,18 @@ test('screen function supports max-width screens', () => {
 })
 
 test('screen function supports min-width screens', () => {
-  const input = `
+  let input = css`
     @media screen(sm) {
-      .foo {}
+      .foo {
+      }
     }
   `
 
-  const output = `
-  @media (min-width: 600px) {
-    .foo {}
-  }
+  let output = css`
+    @media (min-width: 600px) {
+      .foo {
+      }
+    }
   `
 
   return run(input, {
@@ -520,16 +645,18 @@ test('screen function supports min-width screens', () => {
 })
 
 test('screen function supports min-width and max-width screens', () => {
-  const input = `
+  let input = css`
     @media screen(sm) {
-      .foo {}
+      .foo {
+      }
     }
   `
 
-  const output = `
-  @media (min-width: 600px) and (max-width: 700px) {
-    .foo {}
-  }
+  let output = css`
+    @media (min-width: 600px) and (max-width: 700px) {
+      .foo {
+      }
+    }
   `
 
   return run(input, {
@@ -541,16 +668,18 @@ test('screen function supports min-width and max-width screens', () => {
 })
 
 test('screen function supports raw screens', () => {
-  const input = `
+  let input = css`
     @media screen(mono) {
-      .foo {}
+      .foo {
+      }
     }
   `
 
-  const output = `
-  @media monochrome {
-    .foo {}
-  }
+  let output = css`
+    @media monochrome {
+      .foo {
+      }
+    }
   `
 
   return run(input, {
@@ -562,16 +691,18 @@ test('screen function supports raw screens', () => {
 })
 
 test('screen arguments can be quoted', () => {
-  const input = `
+  let input = css`
     @media screen('sm') {
-      .foo {}
+      .foo {
+      }
     }
   `
 
-  const output = `
-  @media (min-width: 600px) {
-    .foo {}
-  }
+  let output = css`
+    @media (min-width: 600px) {
+      .foo {
+      }
+    }
   `
 
   return run(input, {
diff --git a/tests/format-variant-selector.test.js b/tests/format-variant-selector.test.js
index a32ae502..94e86ddf 100644
--- a/tests/format-variant-selector.test.js
+++ b/tests/format-variant-selector.test.js
@@ -259,3 +259,26 @@ describe('real examples', () => {
     })
   })
 })
+
+describe('pseudo elements', () => {
+  it.each`
+    before                                                   | after
+    ${'&::before'}                                           | ${'&::before'}
+    ${'&::before:hover'}                                     | ${'&:hover::before'}
+    ${'&:before:hover'}                                      | ${'&:hover:before'}
+    ${'&::file-selector-button:hover'}                       | ${'&::file-selector-button:hover'}
+    ${'&:hover::file-selector-button'}                       | ${'&:hover::file-selector-button'}
+    ${'.parent:hover &'}                                     | ${'.parent:hover &'}
+    ${'.parent::before &'}                                   | ${'.parent &::before'}
+    ${'.parent::before &:hover'}                             | ${'.parent &:hover::before'}
+    ${':where(&::before) :is(h1, h2, h3, h4)'}               | ${':where(&) :is(h1, h2, h3, h4)::before'}
+    ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'} | ${':where(&::file-selector-button) :is(h1, h2, h3, h4)'}
+  `('should translate "$before" into "$after"', ({ before, after }) => {
+    let result = finalizeSelector(formatVariantSelector('&', before), {
+      selector: '.a',
+      candidate: 'a',
+    })
+
+    expect(result).toEqual(after.replace('&', '.a'))
+  })
+})
diff --git a/tests/kitchen-sink.test.css b/tests/kitchen-sink.test.css
index 0ebe1f76..729049b2 100644
--- a/tests/kitchen-sink.test.css
+++ b/tests/kitchen-sink.test.css
@@ -509,11 +509,6 @@ div {
     }
   }
 }
-@media (orientation: portrait) {
-  .portrait\:text-center {
-    text-align: center;
-  }
-}
 @media (min-width: 1280px) and (max-width: 1535px) {
   .range\:text-right {
     text-align: right;
diff --git a/tests/kitchen-sink.test.html b/tests/kitchen-sink.test.html
index 6a57cfe0..5215db2a 100644
--- a/tests/kitchen-sink.test.html
+++ b/tests/kitchen-sink.test.html
@@ -8,7 +8,7 @@
     <link rel="stylesheet" href="./tailwind.css" />
   </head>
   <body>
-    <div class="portrait:text-center range:text-right multi:text-left"></div>
+    <div class="range:text-right multi:text-left"></div>
     <div
       class="container hover:container sm:container md:container text-center sm:text-center md:text-center"
     ></div>
diff --git a/tests/kitchen-sink.test.js b/tests/kitchen-sink.test.js
index 6fcbe0be..83b3e6e7 100644
--- a/tests/kitchen-sink.test.js
+++ b/tests/kitchen-sink.test.js
@@ -11,7 +11,6 @@ test('it works', () => {
     theme: {
       extend: {
         screens: {
-          portrait: { raw: '(orientation: portrait)' },
           range: { min: '1280px', max: '1535px' },
           multi: [{ min: '640px', max: '767px' }, { max: '868px' }],
         },
diff --git a/tests/normalize-screens.test.js b/tests/normalize-screens.test.js
new file mode 100644
index 00000000..1a5b4a88
--- /dev/null
+++ b/tests/normalize-screens.test.js
@@ -0,0 +1,98 @@
+import { normalizeScreens } from '../src/util/normalizeScreens'
+
+it('should normalize an array of string values', () => {
+  let screens = ['768px', '1200px']
+
+  expect(normalizeScreens(screens)).toEqual([
+    { name: '768px', values: [{ min: '768px', max: undefined }] },
+    { name: '1200px', values: [{ min: '1200px', max: undefined }] },
+  ])
+})
+
+it('should normalize an object with string values', () => {
+  let screens = {
+    a: '768px',
+    b: '1200px',
+  }
+
+  expect(normalizeScreens(screens)).toEqual([
+    { name: 'a', values: [{ min: '768px', max: undefined }] },
+    { name: 'b', values: [{ min: '1200px', max: undefined }] },
+  ])
+})
+
+it('should normalize an object with object values', () => {
+  let screens = {
+    a: { min: '768px' },
+    b: { max: '1200px' },
+  }
+
+  expect(normalizeScreens(screens)).toEqual([
+    { name: 'a', values: [{ min: '768px', max: undefined }] },
+    { name: 'b', values: [{ min: undefined, max: '1200px' }] },
+  ])
+})
+
+it('should normalize an object with multiple object values', () => {
+  let screens = {
+    a: [{ min: '768px' }, { max: '1200px' }],
+  }
+
+  expect(normalizeScreens(screens)).toEqual([
+    {
+      name: 'a',
+      values: [
+        { max: undefined, min: '768px', raw: undefined },
+        { max: '1200px', min: undefined, raw: undefined },
+      ],
+    },
+  ])
+})
+
+it('should normalize an object with object values (min-width normalized to width)', () => {
+  let screens = {
+    a: { 'min-width': '768px' },
+    b: { max: '1200px' },
+  }
+
+  expect(normalizeScreens(screens)).toEqual([
+    { name: 'a', values: [{ min: '768px', max: undefined }] },
+    { name: 'b', values: [{ min: undefined, max: '1200px' }] },
+  ])
+})
+
+it('should normalize a tuple with string values', () => {
+  let screens = [
+    ['a', '768px'],
+    ['b', '1200px'],
+  ]
+
+  expect(normalizeScreens(screens)).toEqual([
+    { name: 'a', values: [{ min: '768px', max: undefined }] },
+    { name: 'b', values: [{ min: '1200px', max: undefined }] },
+  ])
+})
+
+it('should normalize a tuple with object values', () => {
+  let screens = [
+    ['a', { min: '768px' }],
+    ['b', { max: '1200px' }],
+  ]
+
+  expect(normalizeScreens(screens)).toEqual([
+    { name: 'a', values: [{ min: '768px', max: undefined }] },
+    { name: 'b', values: [{ min: undefined, max: '1200px' }] },
+  ])
+})
+
+it('should normalize a tuple with object values (min-width normalized to width)', () => {
+  let screens = [
+    ['a', { 'min-width': '768px' }],
+    ['b', { max: '1200px' }],
+  ]
+
+  expect(normalizeScreens(screens)).toEqual([
+    { name: 'a', values: [{ min: '768px', max: undefined }] },
+    { name: 'b', values: [{ min: undefined, max: '1200px' }] },
+  ])
+})
diff --git a/tests/parallel-variants.test.js b/tests/parallel-variants.test.js
index e92bee89..5d58ea34 100644
--- a/tests/parallel-variants.test.js
+++ b/tests/parallel-variants.test.js
@@ -27,7 +27,7 @@ test('basic parallel variants', async () => {
       .test\:font-medium *::test {
         font-weight: 500;
       }
-      .hover\:test\:font-black *::test:hover {
+      .hover\:test\:font-black *:hover::test {
         font-weight: 900;
       }
       .test\:font-bold::test {
@@ -36,7 +36,7 @@ test('basic parallel variants', async () => {
       .test\:font-medium::test {
         font-weight: 500;
       }
-      .hover\:test\:font-black::test:hover {
+      .hover\:test\:font-black:hover::test {
         font-weight: 900;
       }
     `)
diff --git a/tests/resolve-defaults-at-rules.test.js b/tests/resolve-defaults-at-rules.test.js
index ae8b3e90..99750779 100644
--- a/tests/resolve-defaults-at-rules.test.js
+++ b/tests/resolve-defaults-at-rules.test.js
@@ -252,12 +252,12 @@ test('with multi-class pseudo-element and pseudo-class variants', async () => {
           scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
       }
       /* --- */
-      .group:hover .group-hover\:hover\:before\:scale-x-110::before:hover {
+      .group:hover .group-hover\:hover\:before\:scale-x-110:hover::before {
         content: var(--tw-content);
         --tw-scale-x: 1.1;
         transform: var(--tw-transform);
       }
-      .peer:focus ~ .peer-focus\:focus\:after\:rotate-3::after:focus {
+      .peer:focus ~ .peer-focus\:focus\:after\:rotate-3:focus::after {
         content: var(--tw-content);
         --tw-rotate: 3deg;
         transform: var(--tw-transform);
diff --git a/tests/tailwind-screens.test.js b/tests/tailwind-screens.test.js
index 784bf40c..232ae011 100644
--- a/tests/tailwind-screens.test.js
+++ b/tests/tailwind-screens.test.js
@@ -65,3 +65,51 @@ test('`@tailwind screens` works as an alias for `@tailwind variants`', async ()
     `)
   })
 })
+
+test('screen names are in the correct order', () => {
+  // Using custom css function here, because otherwise with String.raw, we run
+  // into an issue with `\31 ` escapes. If we use `\31 ` then JS complains
+  // about strict mode. But `\\31 ` is not what it expected.
+  function css(templates) {
+    return templates.join('')
+  }
+
+  let config = {
+    content: [
+      {
+        raw: html`<div
+          class="768:font-light 1200:font-normal 1800:font-bold max-w-768 container"
+        ></div>`,
+      },
+    ],
+    theme: {
+      screens: [
+        [1800, { max: '1800px' }],
+        [1200, { max: '1200px' }],
+        [768, { max: '768px' }],
+      ],
+    },
+  }
+
+  return run('@tailwind utilities;', config).then((result) => {
+    return expect(result.css).toMatchFormattedCss(css`
+      @media (max-width: 1800px) {
+        .\\31 800\\:font-bold {
+          font-weight: 700;
+        }
+      }
+
+      @media (max-width: 1200px) {
+        .\\31 200\\:font-normal {
+          font-weight: 400;
+        }
+      }
+
+      @media (max-width: 768px) {
+        .\\37 68\\:font-light {
+          font-weight: 300;
+        }
+      }
+    `)
+  })
+})
diff --git a/tests/variants.test.css b/tests/variants.test.css
index f1c9b0e4..657d136e 100644
--- a/tests/variants.test.css
+++ b/tests/variants.test.css
@@ -165,6 +165,13 @@
   --tw-text-opacity: 1;
   color: rgb(255 255 255 / var(--tw-text-opacity));
 }
+.placeholder\:font-bold::placeholder {
+  font-weight: 700;
+}
+.placeholder\:text-red-500::placeholder {
+  --tw-text-opacity: 1;
+  color: rgb(239 68 68 / var(--tw-text-opacity));
+}
 .before\:block::before {
   content: var(--tw-content);
   display: block;
@@ -899,3 +906,15 @@
     }
   }
 }
+@media (orientation: portrait) {
+  .portrait\:bg-yellow-300 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(253 224 71 / var(--tw-bg-opacity));
+  }
+}
+@media (orientation: landscape) {
+  .landscape\:bg-yellow-300 {
+    --tw-bg-opacity: 1;
+    background-color: rgb(253 224 71 / var(--tw-bg-opacity));
+  }
+}
diff --git a/tests/variants.test.html b/tests/variants.test.html
index 8a671fe2..af01da26 100644
--- a/tests/variants.test.html
+++ b/tests/variants.test.html
@@ -49,6 +49,7 @@
     <div class="file:bg-blue-500 file:text-white"></div>
     <div class="before:block before:bg-red-500"></div>
     <div class="after:flex after:uppercase"></div>
+    <div class="placeholder:font-bold placeholder:text-red-500"></div>
 
     <!-- Group variants -->
     <div class="group-open:bg-red-200"></div>
@@ -134,6 +135,10 @@
     <div class="lg:animate-spin"></div>
     <div class="hover:animate-spin"></div>
 
+    <!-- Orientation variants -->
+    <div class="portrait:bg-yellow-300"></div>
+    <div class="landscape:bg-yellow-300"></div>
+
     <!-- Stacked variants -->
     <div class="open:hover:bg-red-200"></div>
     <div class="file:hover:bg-blue-600"></div>
diff --git a/tests/variants.test.js b/tests/variants.test.js
index f2d80f42..b241a11c 100644
--- a/tests/variants.test.js
+++ b/tests/variants.test.js
@@ -323,3 +323,63 @@ test('custom addVariant with nested media & format shorthand', () => {
     `)
   })
 })
+
+test('before and after variants are a bit special, and forced to the end', () => {
+  let config = {
+    content: [
+      {
+        raw: html`
+          <div class="before:hover:text-center"></div>
+          <div class="hover:before:text-center"></div>
+        `,
+      },
+    ],
+    plugins: [],
+  }
+
+  return run('@tailwind components;@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchFormattedCss(css`
+      .before\:hover\:text-center:hover::before {
+        content: var(--tw-content);
+        text-align: center;
+      }
+
+      .hover\:before\:text-center:hover::before {
+        content: var(--tw-content);
+        text-align: center;
+      }
+    `)
+  })
+})
+
+test('before and after variants are a bit special, and forced to the end (2)', () => {
+  let config = {
+    content: [
+      {
+        raw: html`
+          <div class="before:prose-headings:text-center"></div>
+          <div class="prose-headings:before:text-center"></div>
+        `,
+      },
+    ],
+    plugins: [
+      function ({ addVariant }) {
+        addVariant('prose-headings', ':where(&) :is(h1, h2, h3, h4)')
+      },
+    ],
+  }
+
+  return run('@tailwind components;@tailwind utilities', config).then((result) => {
+    return expect(result.css).toMatchFormattedCss(css`
+      :where(.before\:prose-headings\:text-center) :is(h1, h2, h3, h4)::before {
+        content: var(--tw-content);
+        text-align: center;
+      }
+
+      :where(.prose-headings\:before\:text-center) :is(h1, h2, h3, h4)::before {
+        content: var(--tw-content);
+        text-align: center;
+      }
+    `)
+  })
+})