Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052))
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
- Ensure `not-*` correctly negates `@container` queries, including `style(…)` queries ([#20059](https://github.com/tailwindlabs/tailwindcss/pull/20059))
- Ensure `drop-shadow-*` color utilities work with custom shadow values containing `calc(…)` ([#20080](https://github.com/tailwindlabs/tailwindcss/pull/20080))

## [4.3.0] - 2026-05-08

Expand Down
11 changes: 11 additions & 0 deletions packages/tailwindcss/src/utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23847,6 +23847,7 @@ test('filter', async () => {
'drop-shadow-red-500/50',
'drop-shadow-none',
'drop-shadow-inherit',
'drop-shadow-calc',
'saturate-0',
'saturate-[1.75]',
'saturate-[var(--value)]',
Expand All @@ -23857,10 +23858,12 @@ test('filter', async () => {
],
css`
@theme {
--spacing: 0.25rem;
--blur-xl: 24px;
--color-red-500: #ef4444;
--drop-shadow: 0 1px 1px rgb(0 0 0 / 0.05);
--drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
--drop-shadow-calc: 0 0 calc(1 * var(--spacing)) black;
}
@theme inline {
--drop-shadow-multi: 0 1px 1px rgb(0 0 0 / 0.05), 0 9px 7px rgb(0 0 0 / 0.1);
Expand Down Expand Up @@ -23891,10 +23894,12 @@ test('filter', async () => {
}

:root, :host {
--spacing: .25rem;
--blur-xl: 24px;
--color-red-500: #ef4444;
--drop-shadow: 0 1px 1px #0000000d;
--drop-shadow-xl: 0 9px 7px #0000001a;
--drop-shadow-calc: 0 0 calc(1 * var(--spacing)) black;
}

.blur-\\[4px\\] {
Expand Down Expand Up @@ -23951,6 +23956,12 @@ test('filter', async () => {
filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, );
}

.drop-shadow-calc {
--tw-drop-shadow-size: drop-shadow(0 0 calc(1 * var(--spacing)) var(--tw-drop-shadow-color, black));
--tw-drop-shadow: drop-shadow(var(--drop-shadow-calc));
filter: var(--tw-blur, ) var(--tw-brightness, ) var(--tw-contrast, ) var(--tw-grayscale, ) var(--tw-hue-rotate, ) var(--tw-invert, ) var(--tw-saturate, ) var(--tw-sepia, ) var(--tw-drop-shadow, );
}

.drop-shadow-multi {
--tw-drop-shadow-size: drop-shadow(0 1px 1px var(--tw-drop-shadow-color, #0000000d)) drop-shadow(0 9px 7px var(--tw-drop-shadow-color, #0000001a));
--tw-drop-shadow: drop-shadow(0 1px 1px #0000000d) drop-shadow(0 9px 7px #0000001a);
Expand Down
4 changes: 4 additions & 0 deletions packages/tailwindcss/src/utils/is-color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,7 @@ export function isColor(value: string): boolean {
value.charCodeAt(0) === HASH || IS_COLOR_FN.test(value) || NAMED_COLORS.has(value.toLowerCase())
)
}

export function isNamedColor(value: string): boolean {
return NAMED_COLORS.has(value.toLowerCase())
}
54 changes: 54 additions & 0 deletions packages/tailwindcss/src/utils/replace-shadow-colors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { cartesian } from '../cartesian'
import { replaceAlpha } from '../utilities'
import { replaceShadowColors } from './replace-shadow-colors'

Expand Down Expand Up @@ -40,6 +41,59 @@ describe('without replacer', () => {
expect(parsed).toMatchInlineSnapshot(`"1px 2px 3px 4px var(--tw-shadow-color, currentcolor)"`)
})

it('should find the color regardless of its position', () => {
for (let [x, y, blur, spread, color] of cartesian(
['calc(var(--spacing) * 1)', '1', '--spacing(1)'], // x
['calc(var(--spacing) * 2)', '2', '--spacing(2)'], // y
['calc(var(--spacing) * 3)', '3', '--spacing(3)'], // blur
['calc(var(--spacing) * 4)', '4', '--spacing(4)'], // spread
['black', 'rgb(0, 0, 0)', '#000', '--alpha(var(--color) / 50%)', 'var(--uknown-color)'], // color
)) {
let expectedColor = `var(--tw-shadow-color, ${color})`

{
let input = `${x} ${color} ${y} ${blur} ${spread}`
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
}

{
let input = `${x} ${y} ${color} ${blur} ${spread}`
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
}

{
let input = `${x} ${y} ${blur} ${color} ${spread}`
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
}
}
})

// When using `var(…)`, we don't know the types of the used variables, but we
// might be able to find the color itself.
it.each([
'black', // Named color
'#000', // Hex color
'rgb(0, 0, 0)', // Color functions
'--alpha(var(--color) / 50%)', // Known custom functions
])('should find the color (%s)', (color) => {
let expectedColor = `var(--tw-shadow-color, ${color})`

{
let input = `var(--x) var(--y) ${color}`
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
}

{
let input = `var(--x) var(--y) var(--blur) ${color}`
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
}

{
let input = `var(--x) var(--y) var(--blur) var(--spread) ${color}`
expect(replaceShadowColors(input, replacer)).toEqual(input.replace(color, expectedColor))
}
})

it('should handle multiple shadows', () => {
let parsed = replaceShadowColors(
['var(--my-shadow)', '1px 1px var(--my-color)', '0 0 1px var(--my-color)'].join(', '),
Expand Down
139 changes: 113 additions & 26 deletions packages/tailwindcss/src/utils/replace-shadow-colors.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,134 @@
import * as ValueParser from '../value-parser'
import { walk, WalkAction } from '../walk'
import { isNamedColor } from './is-color'
import { segment } from './segment'

const KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset'])
const LENGTH = /^-?(\d+|\.\d+)(.*?)$/g
const LENGTH_FUNCTIONS = new Set(['calc', 'clamp', 'max', 'min', '--spacing'])
const COLOR_FUNCTIONS = new Set([
'color',
'color-mix',
'contrast-color',
'device-cmyk',
'hsl',
'hsla',
'hwb',
'lab',
'lch',
'light-dark',
'oklab',
'oklch',
'rgb',
'rgba',
'--alpha',
])
Comment thread
greptile-apps[bot] marked this conversation as resolved.
const LENGTH = /^-?(\d+|\.\d+)(.*?)$/

export function replaceShadowColors(input: string, replacement: (color: string) => string) {
function replaceAst(node: ValueParser.ValueAstNode): ValueParser.ValueAstNode[] {
let color = ValueParser.toCss([node])
let updatedColor = replacement(color)
let ast = ValueParser.parse(updatedColor)
return ast
}

let shadows = segment(input, ',').map((shadow) => {
shadow = shadow.trim()
let parts = segment(shadow, ' ').filter((part) => part.trim() !== '')
let color = null
let offsetX = null
let offsetY = null

for (let part of parts) {
if (KEYWORDS.has(part)) {
continue
} else if (LENGTH.test(part)) {
if (offsetX === null) {
offsetX = part
} else if (offsetY === null) {
offsetY = part
let ast = ValueParser.parse(shadow)

let unknown: ValueParser.ValueAstNode | null = null
let unknowns = 0
let lengths = 0
let replaced = false

walk(ast, (node) => {
switch (node.kind) {
case 'word': {
// Skip known keywords
if (KEYWORDS.has(node.value.toLowerCase())) {
return WalkAction.Continue
}

// Must be a length
if (LENGTH.test(node.value.toLowerCase())) {
lengths++
return WalkAction.Continue
}

// Must be a color
if (node.value[0] === '#' || isNamedColor(node.value)) {
replaced = true
return WalkAction.ReplaceStop(replaceAst(node))
}

// We're not sure yet
unknown = node
unknowns++
break
}

case 'function': {
// Must be a color
if (COLOR_FUNCTIONS.has(node.value.toLowerCase())) {
replaced = true
return WalkAction.ReplaceStop(replaceAst(node))
}

// Must be a length
if (LENGTH_FUNCTIONS.has(node.value.toLowerCase())) {
lengths++
return WalkAction.Skip
}

// We're not sure yet
unknown = node
unknowns++

// We're not interested in the arguments of the function
return WalkAction.Skip
}

// Reset index, since the regex is stateful.
LENGTH.lastIndex = 0
} else if (color === null) {
color = part
// Ignore separators
case 'separator':
return WalkAction.Continue

default:
node satisfies never
}
})

// We definitely found a color, nothing else to do
if (replaced) {
return ValueParser.toCss(ast)
}

// If the x and y offsets were not detected, the shadow is either invalid or
// using a variable to represent more than one field in the shadow value, so
// we can't know what to replace.
if (offsetX === null || offsetY === null) return shadow

let replacementColor = replacement(color ?? 'currentcolor')

if (color !== null) {
// If a color was found, replace the color.
return shadow.replace(color, replacementColor)
if (lengths < 2) {
return shadow
}

// If no color was found, assume the shadow is relying on the browser
// default shadow color and append the replacement color.
return `${shadow} ${replacementColor}`
if (unknowns === 0) {
return `${shadow} ${replacement('currentcolor')}`
}

// A single left-over, we assume that this is the color
if (unknowns === 1) {
walk(ast, (node) => {
if (node === unknown) {
replaced = true
return WalkAction.ReplaceStop(replaceAst(node))
}

// Keep the walk top-level only, no need to go into functions
return WalkAction.Skip
})
}
Comment thread
RobinMalfait marked this conversation as resolved.

return replaced ? ValueParser.toCss(ast) : shadow
})

return shadows.join(', ')
Expand Down