From c5b84d89392a78cce52d4202e35ebd01ab2c67b4 Mon Sep 17 00:00:00 2001 From: buyuan-dev <541908090@qq.com> Date: Wed, 18 Mar 2026 10:42:58 +0800 Subject: [PATCH 1/3] fix: preserve opacity values in multiple shadows with color-mix --- .../src/utils/replace-shadow-colors.ts | 91 ++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/utils/replace-shadow-colors.ts b/packages/tailwindcss/src/utils/replace-shadow-colors.ts index e51f9c2a0536..0f7a40c978b4 100644 --- a/packages/tailwindcss/src/utils/replace-shadow-colors.ts +++ b/packages/tailwindcss/src/utils/replace-shadow-colors.ts @@ -3,6 +3,77 @@ import { segment } from './segment' const KEYWORDS = new Set(['inset', 'inherit', 'initial', 'revert', 'unset']) const LENGTH = /^-?(\d+|\.\d+)(.*?)$/g +/** + * Extract the alpha channel from a color value. + * Returns the alpha as a percentage string (e.g., "12%") or null if no alpha is found. + */ +function extractAlpha(color: string): string | null { + // Modern rgba/hsla syntax with slash: rgba(0 0 0 / 0.12) or rgba(0 0 0 / 12%) + const slashAlphaMatch = color.match(/\/\s*([\d.]+)%?\s*\)/) + if (slashAlphaMatch) { + let alpha = slashAlphaMatch[1] + // If it's already a percentage, return it + if (slashAlphaMatch[0].includes('%')) { + return `${alpha}%` + } + // Convert decimal to percentage + const alphaNum = parseFloat(alpha) + if (!isNaN(alphaNum)) { + // Round to avoid floating point precision issues + return `${Math.round(alphaNum * 100)}%` + } + return null + } + + // Legacy rgba/hsla syntax with comma: rgba(0, 0, 0, 0.12) + const commaAlphaMatch = color.match(/,\s*([\d.]+)\s*\)/) + if (commaAlphaMatch) { + const alpha = commaAlphaMatch[1] + const alphaNum = parseFloat(alpha) + if (!isNaN(alphaNum)) { + // Round to avoid floating point precision issues + return `${Math.round(alphaNum * 100)}%` + } + return null + } + + // No alpha found + return null +} + +/** + * Extract the base color without the alpha channel. + * For rgba/hsla, returns rgb/hsl with the same values but without alpha. + * For other colors, returns as-is. + */ +function stripAlpha(color: string): string { + // Modern rgba/hsla syntax with slash: rgba(0 0 0 / 0.12) or hsla(0 0% 0% / 0.3) + const slashMatch = color.match(/^(rgba?|hsla?)\(([\d\s.%]+)\s*\/\s*[\d.]+%?\s*\)$/i) + if (slashMatch) { + const type = slashMatch[1].toLowerCase().replace('a', '') + const values = slashMatch[2].trim() + return `${type}(${values})` + } + + // Legacy rgba/hsla syntax with comma: rgba(0, 0, 0, 0.12) or hsla(0, 0%, 0%, 0.3) + const commaMatch = color.match(/^(rgba?|hsla?)\(([\d\s.,%]+),\s*[\d.]+\s*\)$/i) + if (commaMatch) { + const type = commaMatch[1].toLowerCase().replace('a', '') + const values = commaMatch[2].trim() + return `${type}(${values})` + } + + // No alpha to strip + return color +} + +/** + * Check if a string already contains alpha handling (oklab, color-mix, etc.) + */ +function hasAlphaHandling(value: string): boolean { + return value.includes('oklab(') || value.includes('color-mix(') || value.includes('oklch(') +} + export function replaceShadowColors(input: string, replacement: (color: string) => string) { let shadows = segment(input, ',').map((shadow) => { shadow = shadow.trim() @@ -33,7 +104,25 @@ export function replaceShadowColors(input: string, replacement: (color: string) // we can't know what to replace. if (offsetX === null || offsetY === null) return shadow - let replacementColor = replacement(color ?? 'currentcolor') + // Extract alpha from the original color if present + let alpha: string | null = null + let baseColor: string = color ?? 'currentcolor' + + if (color) { + alpha = extractAlpha(color) + if (alpha) { + baseColor = stripAlpha(color) + } + } + + let replacementColor = replacement(baseColor) + + // Only apply color-mix wrapping if: + // 1. The original color had an alpha channel + // 2. The replacement doesn't already have alpha handling (oklab, color-mix, etc.) + if (alpha && !hasAlphaHandling(replacementColor)) { + replacementColor = `color-mix(in srgb, transparent, ${replacementColor} ${alpha})` + } if (color !== null) { // If a color was found, replace the color. From 0a83e40159c3c68663d358fb0afc9addb37fdca4 Mon Sep 17 00:00:00 2001 From: buyuan-dev <541908090@qq.com> Date: Wed, 18 Mar 2026 10:44:44 +0800 Subject: [PATCH 2/3] test: add tests for alpha preservation in shadow colors --- .../src/utils/replace-shadow-colors.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/packages/tailwindcss/src/utils/replace-shadow-colors.test.ts b/packages/tailwindcss/src/utils/replace-shadow-colors.test.ts index 6303e5a8d20a..69bd46c5dfd1 100644 --- a/packages/tailwindcss/src/utils/replace-shadow-colors.test.ts +++ b/packages/tailwindcss/src/utils/replace-shadow-colors.test.ts @@ -49,6 +49,51 @@ describe('without replacer', () => { `"var(--my-shadow), 1px 1px var(--tw-shadow-color, var(--my-color)), 0 0 1px var(--tw-shadow-color, var(--my-color))"`, ) }) + + it('should preserve alpha from rgba color with modern syntax', () => { + let parsed = replaceShadowColors('rgba(0 0 0 / 0.12) 0px 1px 2px', replacer) + expect(parsed).toMatchInlineSnapshot( + `"color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 12%) 0px 1px 2px"`, + ) + }) + + it('should preserve alpha from rgba color with legacy syntax', () => { + let parsed = replaceShadowColors('rgba(0, 0, 0, 0.12) 0px 1px 2px', replacer) + expect(parsed).toMatchInlineSnapshot( + `"color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0, 0, 0)) 12%) 0px 1px 2px"`, + ) + }) + + it('should preserve alpha from rgba color with percentage', () => { + let parsed = replaceShadowColors('rgba(0 0 0 / 50%) 0px 1px 2px', replacer) + expect(parsed).toMatchInlineSnapshot( + `"color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 50%) 0px 1px 2px"`, + ) + }) + + it('should preserve alpha from hsla color', () => { + let parsed = replaceShadowColors('hsla(0 0% 0% / 0.3) 0px 1px 2px', replacer) + expect(parsed).toMatchInlineSnapshot( + `"color-mix(in srgb, transparent, var(--tw-shadow-color, hsl(0 0% 0%)) 30%) 0px 1px 2px"`, + ) + }) + + it('should preserve different alpha values for multiple shadows', () => { + let parsed = replaceShadowColors( + 'rgba(0 0 0 / 0.12) 0px -1px 3px 0px, rgba(0 0 0 / 0.14) 0px 2px 5px -5px, rgba(0 0 0 / 0.17) 0px 12px 15px -5px', + replacer, + ) + expect(parsed).toMatchInlineSnapshot( + `"color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 12%) 0px -1px 3px 0px, color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 14%) 0px 2px 5px -5px, color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 17%) 0px 12px 15px -5px"`, + ) + }) + + it('should handle mixed colors with and without alpha', () => { + let parsed = replaceShadowColors('#000 0px 1px 2px, rgba(0 0 0 / 0.5) 0px 2px 4px', replacer) + expect(parsed).toMatchInlineSnapshot( + `"var(--tw-shadow-color, #000) 0px 1px 2px, color-mix(in srgb, transparent, var(--tw-shadow-color, rgb(0 0 0)) 50%) 0px 2px 4px"`, + ) + }) }) describe('with replacer', () => { @@ -84,4 +129,11 @@ describe('with replacer', () => { `"var(--my-shadow), 1px 1px var(--tw-shadow-color, oklab(from var(--my-color) l a b / 50%)), 0 0 1px var(--tw-shadow-color, oklab(from var(--my-color) l a b / 50%))"`, ) }) + + it('should use modifier alpha instead of original alpha when both are present', () => { + let parsed = replaceShadowColors('rgba(0 0 0 / 0.12) 0px 1px 2px', replacer) + expect(parsed).toMatchInlineSnapshot( + `"var(--tw-shadow-color, oklab(from rgb(0 0 0) l a b / 50%)) 0px 1px 2px"`, + ) + }) }) From db6d0e1bf882e155ac5294bbeca545d7520ae4c5 Mon Sep 17 00:00:00 2001 From: buyuan-dev <541908090@qq.com> Date: Thu, 19 Mar 2026 16:21:49 +0800 Subject: [PATCH 3/3] fix: correct regex to only match rgba/hsla alpha channels --- packages/tailwindcss/src/utils/replace-shadow-colors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tailwindcss/src/utils/replace-shadow-colors.ts b/packages/tailwindcss/src/utils/replace-shadow-colors.ts index 0f7a40c978b4..61bf22f339c0 100644 --- a/packages/tailwindcss/src/utils/replace-shadow-colors.ts +++ b/packages/tailwindcss/src/utils/replace-shadow-colors.ts @@ -26,7 +26,7 @@ function extractAlpha(color: string): string | null { } // Legacy rgba/hsla syntax with comma: rgba(0, 0, 0, 0.12) - const commaAlphaMatch = color.match(/,\s*([\d.]+)\s*\)/) + // Must be rgba/hsla (with 'a') to have 4 values where the last is alpha`n const commaAlphaMatch = color.match(/^(?:rgba|hsla)\([^,]+,[^,]+,[^,]+,\s*([\d.]+)\s*\)$/i) if (commaAlphaMatch) { const alpha = commaAlphaMatch[1] const alphaNum = parseFloat(alpha)