[bug] box-shadow doesn't work in shadow-dom due to reliance on @property #16772
Replies: 10 comments 5 replies
-
| Hey @snaptopixel! Unfortunately The issue is that we rely on |
Beta Was this translation helpful? Give feedback.
-
| Hi, Just started a new project and found this discussion. The plugin looks for I guess i'll see some errors when it comes to animations since the css rules will not have an initial value to animate from. Anyway, here is the Plugin code: /** * PostCSS plugin to convert @property declarations to CSS custom properties * This helps with using property values inside Shadow DOM */ export default (opts = {}) => { return { postcssPlugin: 'postcss-property-to-custom-prop', prepare(result) { // Store all the properties we find const properties = []; return { AtRule: { property: (rule) => { // Extract the property name and initial value const propertyName = rule.params.match(/--[\w-]+/)?.[0]; let initialValue = ''; rule.walkDecls('initial-value', (decl) => { initialValue = decl.value; }); if (propertyName && initialValue) { // Store the property properties.push({ name: propertyName, value: initialValue }); // Remove the original @property rule rule.remove(); } }, }, OnceExit(root, { Rule, Declaration }) { // If we found properties, add them to :root, :host if (properties.length > 0) { // Create the :root, :host rule using the Rule constructor from helpers const rootRule = new Rule({ selector: ':root, :host' }); // Add all properties as declarations properties.forEach((prop) => { // Create a new declaration for each property const decl = new Declaration({ prop: prop.name, value: prop.value, }); rootRule.append(decl); }); // Add the rule to the beginning of the CSS root.prepend(rootRule); } }, }; }, }; }; export const postcss = true;Usage: import propertyToCustomProp from './plugins/postcss-property-to-custom-prop'; import tailwindcss from '@tailwindcss/postcss'; export default { plugins: [ tailwindcss(), propertyToCustomProp() ], };Inside my web component i'm using the import globalCss from '@/styles/global.css?inline'; const sheet = new CSSStyleSheet(); sheet.replaceSync(globalCss); export class MyComponent extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot!.adoptedStyleSheets = [sheet]; // ... } } |
Beta Was this translation helpful? Give feedback.
-
| I stumbled upon this too. The decision to go for |
Beta Was this translation helpful? Give feedback.
-
| Is there an update to this? Same for :root / :host |
Beta Was this translation helpful? Give feedback.
-
| Working on an app thats going to be used as a widget on other 3rd party websites hence I need to use the shadow dom and then realised some variables are not defined. Spent some time debugging and then found this, can there be an opt in. I guess I will have to handroll my css |
Beta Was this translation helpful? Give feedback.
-
| @benkissi as you can read, I had the same issue, i fixed it by converting the |
Beta Was this translation helpful? Give feedback.
-
| i just faced the problem, don't want to add postcss. |
Beta Was this translation helpful? Give feedback.
-
| OK - I had same problem, but I needed this as a module. Also didn't want top copy properties over as the project is evolving and I want this to manage itself... What I found was the colour calculations were not working in shadow-dom and they needed to be simplified so I added a utility to convert these to RGBA and now my shadows are working nicely, this builds on @rikgirbes code and makes it adapt any shadows your using into css that shadow dom should be happy with. /** * PostCSS plugin to convert @property declarations to CSS custom properties * AND fix shadow utilities for Shadow DOM compatibility */ const property_to_custom_prop = () => ({ postcssPlugin: 'postcss-property-to-custom-prop', prepare() { const properties = []; let shadowsProcessed = 0; return { AtRule: { property: (rule) => { const property_name = rule.params.match(/--[\w-]+/)?.[0]; let initial_value = ''; rule.walkDecls('initial-value', (decl) => { initial_value = decl.value; }); if (property_name && initial_value) { properties.push({ name: property_name, value: initial_value }); rule.remove(); } }, }, Rule(rule) { // Fix shadow utilities for Shadow DOM compatibility if (rule.selector.includes('shadow-')) { rule.walkDecls((decl) => { // Convert complex Tailwind shadow variables to direct values if (decl.prop === 'box-shadow' && (decl.value.includes('var(--tw-') || decl.value.includes('oklab('))) { // Handle direct oklab values in box-shadow (when not using variables) if (decl.value.includes('oklab(') && !decl.value.includes('var(--tw-')) { let shadowValue = decl.value; // Convert NEW oklab(from rgb()) patterns directly in box-shadow shadowValue = shadowValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\s+l\s+a\s+b\s*\/\s*(\d+)%\)\)/g, (match, r, g, b, baseAlpha, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); shadowValue = shadowValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\)\s+l\s+a\s+b\s*\/\s*(\d+)%\)/g, (match, r, g, b, baseAlpha, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); shadowValue = shadowValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\)\s+l\s+a\s+b\)/g, (match, r, g, b, alpha) => { return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); shadowValue = shadowValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\)\s+l\s+a\s+b\s*\/\s*(\d+)%\)/g, (match, r, g, b, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); decl.value = shadowValue; shadowsProcessed++; if (process.env.NODE_ENV !== 'production') { console.log(`🔧 PostCSS: Fixed direct shadow ${rule.selector} -> ${shadowValue}`); } return; // Don't process further if we handled direct values } // Extract shadow from --tw-shadow variable if present const shadowMatch = decl.value.match(/var\(--tw-shadow\)/); if (shadowMatch) { // Find the --tw-shadow declaration in the same rule rule.walkDecls('--tw-shadow', (shadowDecl) => { // Convert all shadow values dynamically let shadowValue = shadowDecl.value; // NEW: Convert oklab(from rgb()) patterns - Tailwind CSS 4 format // Handle: oklab(from rgb(0 0 0 / 0.1 l a b / 30%)) shadowValue = shadowValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\s+l\s+a\s+b\s*\/\s*(\d+)%\)\)/g, (match, r, g, b, baseAlpha, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); // Handle: oklab(from rgb(0 0 0 / 0.1) l a b / 30%) shadowValue = shadowValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\)\s+l\s+a\s+b\s*\/\s*(\d+)%\)/g, (match, r, g, b, baseAlpha, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); // Handle: oklab(from rgb(0 0 0 / 0.1) l a b) shadowValue = shadowValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\)\s+l\s+a\s+b\)/g, (match, r, g, b, alpha) => { return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); // Handle: oklab(from rgb(0 0 0) l a b / 30%) shadowValue = shadowValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\)\s+l\s+a\s+b\s*\/\s*(\d+)%\)/g, (match, r, g, b, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); // Convert oklab patterns - extract alpha from oklab(0% 0 0/ALPHA) shadowValue = shadowValue.replace(/oklab\(0% 0 0\/\.(\d+)\)/g, (match, alpha) => { const alphaValue = parseFloat('0.' + alpha); return `rgba(0, 0, 0, ${alphaValue})`; }); // Convert oklab patterns with decimal alpha - oklab(0% 0 0/.05) shadowValue = shadowValue.replace(/oklab\(0% 0 0\/(\.\d+)\)/g, (match, alpha) => { const alphaValue = parseFloat(alpha); return `rgba(0, 0, 0, ${alphaValue})`; }); // Convert var(--tw-shadow-color,xxx) patterns shadowValue = shadowValue.replace(/var\(--tw-shadow-color,([^)]+)\)/g, '$1'); // Convert hex colors with alpha to rgba - dynamically parse hex values shadowValue = shadowValue.replace(/#([0-9a-fA-F]{6})([0-9a-fA-F]{2})/g, (match, rgb, alpha) => { const r = parseInt(rgb.substr(0, 2), 16); const g = parseInt(rgb.substr(2, 2), 16); const b = parseInt(rgb.substr(4, 2), 16); const a = parseInt(alpha, 16) / 255; return `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`; }); // Set the box-shadow directly to the converted value decl.value = shadowValue; shadowsProcessed++; // Debug logging in development if (process.env.NODE_ENV !== 'production') { console.log(`🔧 PostCSS: Fixed shadow ${rule.selector} -> ${shadowValue}`); } }); // Remove the Tailwind variable declarations as they're no longer needed rule.walkDecls((varDecl) => { if (varDecl.prop.startsWith('--tw-shadow') || varDecl.prop === '--tw-inset-shadow' || varDecl.prop === '--tw-ring-shadow') { varDecl.remove(); } }); } } // Also handle --tw-shadow-color declarations directly if (decl.prop === '--tw-shadow-color') { // Convert oklab and color-mix patterns in shadow colors let colorValue = decl.value; // Convert NEW oklab(from rgb()) patterns in color values colorValue = colorValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\s+l\s+a\s+b\s*\/\s*(\d+)%\)\)/g, (match, r, g, b, baseAlpha, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); colorValue = colorValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\)\s+l\s+a\s+b\s*\/\s*(\d+)%\)/g, (match, r, g, b, baseAlpha, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); colorValue = colorValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\s*\/\s*([\d.]+)\)\s+l\s+a\s+b\)/g, (match, r, g, b, alpha) => { return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); colorValue = colorValue.replace(/oklab\(from\s+rgb\((\d+)\s+(\d+)\s+(\d+)\)\s+l\s+a\s+b\s*\/\s*(\d+)%\)/g, (match, r, g, b, percentAlpha) => { const alpha = (parseFloat(percentAlpha) / 100); return `rgba(${r}, ${g}, ${b}, ${alpha})`; }); // Convert hex with alpha colorValue = colorValue.replace(/#([0-9a-fA-F]{6})([0-9a-fA-F]{2})/g, (match, rgb, alpha) => { const r = parseInt(rgb.substr(0, 2), 16); const g = parseInt(rgb.substr(2, 2), 16); const b = parseInt(rgb.substr(4, 2), 16); const a = parseInt(alpha, 16) / 255; return `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`; }); // Convert color-mix patterns colorValue = colorValue.replace(/color-mix\(in oklab,([^)]+)\)/g, (match, content) => { // Parse percentage and convert to rgba const percentMatch = content.match(/(\d+)%/); if (percentMatch) { const percent = parseInt(percentMatch[1]) / 100; return `rgba(0, 0, 0, ${percent})`; } return match; }); decl.value = colorValue; } }); } }, OnceExit(root, { Rule, Declaration }) { if (properties.length > 0) { const root_rule = new Rule({ selector: ':root, :host' }); for (const prop of properties) { root_rule.append( new Declaration({ prop: prop.name, value: prop.value, }), ); } root.prepend(root_rule); } // Debug logging if (process.env.NODE_ENV !== 'production' && shadowsProcessed > 0) { console.log(`✅ PostCSS: Processed ${shadowsProcessed} shadow utilities for Shadow DOM`); } }, }; }, }); property_to_custom_prop.postcss = true; module.exports = { plugins: [ require('@tailwindcss/postcss'), property_to_custom_prop(), require('autoprefixer'), ], };edited to add support for watch (watch uses more exotic from rgb syntax) |
Beta Was this translation helpful? Give feedback.
-
| If you have runtime access to the CSS (I do in my case because I import tailwind via Vite's You can just do something like this and use a simple Regex to extract all import mainCSS from 'main.css?inline' // only works for Vite // find all @property --foo {...} blocks const cssPropertyDefinitions = tailwindStyles.match(/(@property ([^}]*)\})/gms) ?? [] let propertyDefinitions = '' for (const definition of cssPropertyDefinitions) { propertyDefinitions += `${definition}\n` } // supports hot code replacements or rendering the same web component twice const tailwindStyleFixId = 'tailwind-shadow-root-style-fix' let styleElement = document.getElementById(tailwindStyleFixId) const didExist = !!styleElement if (!styleElement) { styleElement = document.createElement('style') styleElement.id = tailwindStyleFixId document.head.appendChild(styleElement) } styleElement.textContent = ` ${propertyDefinitions} ` |
Beta Was this translation helpful? Give feedback.
-
| If you are using Vite and Lit, you can construct the style sheet via Lit's import { unsafeCSS } from "lit"; import style from "../styles/global.css?inline"; const twCss = unsafeCSS(style); // Workaround for https://github.com/tailwindlabs/tailwindcss/issues/15005 if (twCss.styleSheet != undefined) { // Extract CSS property rules and add them to a separate stylesheet... const propertyStyleSheet = new CSSStyleSheet(); // CSSStyleSheet.deleteRule() modifies the stylesheet in place, so we need to // do some trickery to keep the indexes in sync const originalLength = twCss.styleSheet.cssRules.length; [...twCss.styleSheet.cssRules] .reverse() .forEach((rule, i) => { if (rule instanceof CSSPropertyRule) { propertyStyleSheet.insertRule(rule.cssText); twCss.styleSheet!.deleteRule(originalLength - 1 - i); } }); // ...then add the new stylesheet to the document document.adoptedStyleSheets.push(propertyStyleSheet); } export const globalStyles = twCss; |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
What version of Tailwind CSS are you using?
v4.0.6
What build tool (or framework if it abstracts the build tool) are you using?
tailwind-cli
What version of Node.js are you using?
v20.0.0
What browser are you using?
Chrome
What operating system are you using?
macOS
Reproduction URL
https://codepen.io/snaptopixel/pen/GgRZebj
Describe your issue
Tailwind's box-shadow based utils (ring, shadow, etc) use multiple shadow syntax with custom properties:
Since these vars don't have default/fallback values the whole box-shadow breaks if any one of them is undefined. In non-shadow this is a non-issue since
@propertyprovides default values. However in shadow-dom@propertydoes nothing, so ironically, in shadow-dom you will have no shadows on your dom.Essentially it seems there should be suitable fallbacks for any utils that currently rely on
@propertysince it doesn't work in shadow-dom land.Beta Was this translation helpful? Give feedback.
All reactions