Tailwind CSS 4
npm create shadow-app with Tailwind selected generates exactly this setup.
npm create shadow-app with Tailwind selected generates exactly this setup. The scaffolded files are:
import shadow from "@shadow-js/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({ plugins: [shadow(), tailwindcss()] });
/* src/index.css */
@import "tailwindcss";
src/main.tsx imports ./index.css, and package.json lists tailwindcss and @tailwindcss/vite as
dev dependencies. There is no tailwind.config.js and no content array: Tailwind 4 detects the
files that contain class names by itself.
The rest of this section is Tailwind behavior, not ShadowJS behavior, and it is the same as in any Vite project.
Tailwind finds class names by scanning your source files as text. It never runs your code. That decides how you can compose classes:
import { useStore } from "@shadow-js/core";
// Every class name appears in full in the source, so the scanner sees it.
const tones = {
info: "bg-sky-100 text-sky-900",
error: "bg-red-100 text-red-900",
} as const;
export function Alert(props: { tone: keyof typeof tones }) {
const [open] = useStore(true);
return (
<div
class={[
"rounded p-4",
tones[props.tone],
{ hidden: !open(), "ring-2 ring-red-500": props.tone === "error" },
]}
/>
);
}
The pattern that does not work is `bg-${props.tone}-100`: that class name never appears as a whole in
the source, so no CSS is generated for it.
Rules that follow:
- Write complete class names in string literals: in a lookup object, in a ternary, as object keys in
class={{ "ring-2": active() }}. The clsx form is safe because the keys are whole class names. - Never build a class name from parts (
`text-${size}`,"bg-" + color). Map the value to a full name first. - A class name that only exists in data (a CMS field, a JSON file outside the scanned tree) is not
found. Add the folder with
@source "../content";in the CSS file, or list the classes with@source inline("bg-red-500 bg-blue-500");. - Generated CSS contains only the classes found at build time, so there is nothing to purge and no safelist to maintain for the classes you do write literally.
classandclassNameare both scanned: the scanner does not care about the attribute name.
Dark mode#
Tailwind 4's dark: variant follows prefers-color-scheme by default, so class="bg-white dark:bg-black"
works with no setup. To switch with a class or attribute that your app controls, declare the
variant in the CSS file:
/* src/index.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Then the theme is one class on <html>. A signal and an effect keep it in sync:
import { useEffect, useStore } from "@shadow-js/core";
export function ThemeToggle() {
const [dark, setDark] = useStore(false);
useEffect(() => {
document.documentElement.classList.toggle("dark", dark());
});
return (
<button type="button" aria-pressed={dark()} onClick={() => setDark((value) => !value)}>
dark mode
</button>
);
}
Effects do not run on the server, so on a server-rendered page the class must be set before first
paint by a small inline script in the document head, or by your server from a cookie. Otherwise the
page flashes the light theme. Persisting the choice (localStorage) is application code; wrap
reads and writes in try/catch, because storage can throw in private windows.