- Published on
Tailwind & Blazor: Making it work with scoped CSS
- Authors
- Name
- Luke Parker
- @LukeParkerDev
The Problem
A little while ago I was wanting to find a way to allow my PostCSS Tailwind styles to be close to my Razor components.
Without any additional work, you might end up building styles & components in the following structure:
📁 BlazorApp/
│
│── 📁 Shared/
│ │
│ │── MyButton.razor
|
│── 📁 Styles/
│── 📄 MyButton.css
MyButton.razor
<button class="bg-amber-800" @onclick="IncrementCount">Click me</button>
MyButton.css
button {
@apply rounded-xl p-8;
width: calc(100% - 2rem);
}
This omits the other files like tailwind.config.js
that come from a project setup.
Checkout Chris Sainty's awesome tutorial for setting up Tailwind in a Blazor project. You can imagine this blog post as an extension to that tutorial.
Note that inline styles already work if you followed Chris's setup, however when we need to get a bit more power, like writing styles once & using it many times, or mixing tailwind with typical css (e.g. the width calculation above), this approach falls short.
So, back to the structure. There are two problems:
- When I want to change
MyButton
I've got the files all scattered about. This violates the proximity principle and makes it harder to maintain - especially as your app grows. - Writing styles that get built using PostCSS into a published CSS file means that you don't gain the benefits of scoped CSS. You have to manually make sure there are no selectors that clash with other components.
- You can't use @apply or write mixed CSS & Tailwind styles.
The Solution
Let's do better now.
First, lets create a SCSS file next to the component.
📁 Shared/
│── MyButton.razor
│── MyButton.razor.scss
This is not going to work out of the box, yet.
The first step is to extend the tailwind.config.js
file to find classes in any razor file or SCSS file.
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./**/*.{razor,css,scss,cs,html,js}"], // 👈 Add SCSS & Razor files
theme: {
extend: {},
},
plugins: [],
}
This is a nice glob of all things that could include a Tailwind class.
We have to make sure that SCSS compilation works for all users no matter the IDE or extensions. Previously for SCSS a lot of users would have to install an extension to get it to work. e.g. this one for Visual Studio.
We actually can make this work in the MSBuild process meaning any IDE or even the command line will work.
In your csproj file, e.g. BlazorApp.csproj
add the following:
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BlazorComponentUtilities" Version="1.8.0" />
...
</ItemGroup>
<!-- 👇 New Code -->
<Target Name="StylesCompile" BeforeTargets="BeforeBuild">
<!-- On Error, write the stderr as a build error -->
<Exec ConsoleToMSBuild="true" Command="npm run build:scoped-css" />
<Exec Command="npm run build:css" />
</Target>
<!-- 👆 New Code -->
</Project>
This allows us to run an NPM script BEFORE any files are added to the build process. You might be able to figure out where this is headed.
Now we need to add the package.json
that contains the scripts build:scoped-css
and build:css
{
"devDependencies": {
// ⚠️ Versions may vary
"autoprefixer": "^10.4.13",
"postcss": "^8.4.19",
"postcss-cli": "^10.0.0",
"tailwindcss": "^3.2.4",
"glob": "^8.0.3"
},
"scripts": {
"build:scoped-css": "node build-scoped-css.js",
"build:css": "postcss wwwroot/css/app.scss -o wwwroot/css/app.min.css"
}
}
Note the differences between the scripts.
build:scoped-css
runs a custom scriptbuild-scoped-css.js
that we will create in a moment.build:css
processes the single 'global' SCSS file into a single CSS file. This is nothing special - however, if we're adding SCSS we might as well make it consistent.
It's magic time! Create a file called build-scoped-css.js
in the root of your project.
const glob = require('glob');
const {exec} = require('child_process');
// 👇 Find every scoped SCSS file
glob("**/*.razor.scss", function (er, files) {
files.forEach(function (file) {
// 👇 Output the processed file as *.razor.css, which is a normal scoped CSS file
const command = `npx postcss "${file}" -o "${file.replace('.razor.scss', '.razor.css')}"`;
console.log(command)
exec(command, (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
return;
}
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
});
});
})
Make sure your index.html file includes both the Scoped CSS file and the global CSS file.
<head>
<link href="css/app.min.css" rel="stylesheet" />
<link href="BlazorApp.styles.css" rel="stylesheet" />
</head>
To summarise what we just added, a few steps occur.
- Before running any Blazor build steps we run 2 scripts
- The first script outputs *.razor.css files for every *.razor.scss file
- The second script processes the global SCSS file into a single CSS file
- The Blazor build process then runs as normal, with the working CSS files included
🥳 That is all it takes!
If you want a complete example to follow rather than a step by step, here is my repository that setup an example project with these steps completed:
⭐ https://github.com/Hona/Blazor.Tailwind
It features a demo website on GitHub pages that you can explore also.