Tailwind CSS

Tailwind CSS classes written in the className of a Devup UI component (Box, Flex, Text ...) are compiled at build time to Devup UI classes, the same way style props are. Nothing runs in the browser, and no Tailwind build is needed for those classes. The classes follow Tailwind CSS v4.

1<Box className="flex items-center gap-4 bg-red-500/50 hover:bg-primary md:p-4!" />
1<Box className="flex items-center gap-4 bg-red-500/50 hover:bg-primary md:p-4!" />

When a class compiles

A class compiles only when the build understands all of it: every variant (md:, hover:, data-[state=open]:, [&>*]: ...), the utility, its value and its modifiers (/50, !). Then it becomes the declarations Tailwind CSS v4 writes for it.

Every other class stays in the className exactly as written, so whatever else defines it keeps working. This covers:

  • your own classes (card) and plugin classes (prose),
  • a class Tailwind CSS v4 does not define (bg-gradient-to-r, which v3 had),
  • a class the build cannot reproduce exactly (see what stays as written),
  • a class whose theme value the project's Tailwind CSS defines in a way the build cannot read.

A class is never rewritten into something different from what Tailwind CSS would generate: it compiles to the declarations of v4 or it is left alone. A class with a Tailwind name compiles with the Tailwind meaning, so do not give a class of your own the name of a Tailwind utility.

Where classes are read

Classes compile in the className of Devup UI components, and the build only reads what it can know:

  • string literals, template literals, cond ? 'a' : 'b', a && 'b', a || 'b' and parentheses,
  • a const of the same file bound to a string (const base = 'p-4 card', read as the classes it holds wherever the className reads it; a let, a parameter or a template with ${} is not),
  • the arguments of clsx(...), classnames(...) and classNames(...) (strings, arrays, and the keys of objects), and ['a', cond && 'b'].join(' '), each class compiled where it is written,
  • cn(...) and twMerge(...) when every argument is a string, a template without ${}, or an array of them: the build merges them as tailwind-merge does, so a Tailwind class that a later one sets again is dropped (cn('p-4 m-2', 'p-2') keeps m-2 and p-2; a variant (hover:p-2), a breakpoint and ! each count apart, and px-4 and p-2 both stay) and compiles what is left. cn is read as twMerge(clsx(...)): if your cn does something else, give it another name. A cn() with a condition or any other argument is left alone, since its result is only known at runtime and the strings in it must reach twMerge as written,
  • the strings of cva(base, { variants, compoundVariants }) in a file that imports Devup UI: the base, every variant option and every compound variant class/className compile where they are written (cva only joins them, so each class stays a class). A file without a Devup UI import is not compiled at all, so put cva calls in a file that has one.

A class that is only known at runtime (a prop, a computed string, an imported constant, a cn() with a condition) cannot be compiled. In every case the className keeps working as before, as plain text.

A plain DOM element (<div className="p-4" />) or a component that is not a Devup UI component is not touched: its className is passed through as written, so Tailwind (its own build) can style it. Use <Box as="div" className="p-4" /> when you want the class compiled.

Theme values

Colors from Tailwind CSS v4's default palette (bg-red-500, in oklch) and the values of devup.json work together:

  • devup.json colors are Tailwind colors named by their token: bg-primary, text-text, border-primary/50 become var(--primary), var(--text) and so on. When a name is both a devup.json color and a palette color, the devup.json color is used.
  • devup.json shadow tokens work as shadow-<token> (shadow-card).
  • Breakpoints are the ones of Devup UI (theme.breakpoints in devup.json), not Tailwind's: sm:, md:, lg:, xl: and 2xl: are the first five breakpoints after the base. Change them in devup.json.
  • dark: follows the Devup UI theme (prefers-color-scheme until a theme is set with data-theme).

Tailwind CSS definitions

The CSS file of your project that imports tailwindcss can define more, as it does for Tailwind itself:

1@import 'tailwindcss';
2
3@theme {
4  --color-brand: oklch(60% 0.2 250);
5  --spacing-gutter: 3rem;
6  --breakpoint-3xl: 120rem;
7  --animate-wiggle: wiggle 1s ease-in-out infinite;
8  @keyframes wiggle {
9    50% {
10      transform: rotate(3deg);
11    }
12  }
13}
14
15@utility content-auto {
16  content-visibility: auto;
17}
18
19@custom-variant theme-midnight (&:where([data-theme='midnight'] *));
1@import 'tailwindcss';
2
3@theme {
4  --color-brand: oklch(60% 0.2 250);
5  --spacing-gutter: 3rem;
6  --breakpoint-3xl: 120rem;
7  --animate-wiggle: wiggle 1s ease-in-out infinite;
8  @keyframes wiggle {
9    50% {
10      transform: rotate(3deg);
11    }
12  }
13}
14
15@utility content-auto {
16  content-visibility: auto;
17}
18
19@custom-variant theme-midnight (&:where([data-theme='midnight'] *));

Every plugin reads the file when the build starts, and again when it, or a CSS file it imports with a relative path, changes: Vite, webpack, Next.js and Rsbuild compile every module again, and the Bun plugin (a runtime plugin, with no rebuild of its own) reads the definitions again, so a module compiles with them the next time Bun loads it. So bg-brand, p-gutter, 3xl:flex, animate-wiggle, content-auto and theme-midnight:bg-black compile.

The file is found, in this order: the tailwind.css option of devup.json, else the first of src/app/globals.css, app/globals.css, src/globals.css, src/index.css, src/styles/globals.css, src/styles.css, src/style.css, src/app.css, styles/globals.css, app/app.css that imports tailwindcss.

1{
2  "tailwind": { "css": "src/styles/tailwind.css" }
3}
1{
2  "tailwind": { "css": "src/styles/tailwind.css" }
3}

Use "css": false to read none.

What is read:

  • @theme variables of the color, spacing (--spacing, --spacing-*), shadow, inset-shadow, text-shadow, drop-shadow, blur, radius, ease, animate, perspective, container (@sm:), default-transition-* and breakpoint namespaces (and @keyframes inside @theme), and resets such as --color-*: initial and --*: initial.
  • @utility name { ... } with declarations and @apply lines. @apply writes out the utilities it names (@apply px-4 py-2!), also through other @utility that apply utilities; a utility with a variant (hover:p-4), one that styles children (space-x-4), an unknown one, or a loop leaves the whole @utility as written.
  • Functional @utility name-* { ... } whose declarations read --value(...) and --modifier(...). An argument is integer, number, percentage, a literal ('inherit'), a theme namespace (--tab-size-*, --color-*, --spacing-*) or an arbitrary type ([length], [color], [number], [url]); the first argument the value fits is used. A declaration that reads a --modifier() that is not given is dropped; a --value() no argument fits makes the class stay as written; --spacing(), --alpha() and the other functions of a utility are not run, so a utility using them stays as written.
  • @custom-variant as a selector list, or a block holding @slot; in a selector, @media or @supports. A @custom-variant with the name of a built-in variant (dark) replaces it.
  • theme(--name) in arbitrary values (p-[theme(--spacing-gutter)], bg-[theme(--color-brand)], w-[calc(theme(--spacing)*2)]): a theme variable of the project, a color, or a spacing step. A call for a variable the build cannot read (including the v3 form theme(colors.red.500)) makes the class stay as written.

What is not read keeps its classes as written: @plugin and @config (they run JavaScript, whose output the build does not know: the classes a plugin such as @tailwindcss/typography makes, like prose, stay in the className for Tailwind's own build), nested rules in @utility, theme() in CSS (outside arbitrary values), --theme(), and a file whose braces do not balance. If the project changes the values of a namespace the build does not read (--text-*, --font-*, --leading-*, --tracking-*, --container-*, --perspective-*, --aspect-*), the classes that read it (text-lg, font-bold, max-w-sm ...) also stay as written, instead of compiling to a default the project replaced.

Classes and style props on one element

Tailwind classes and Devup UI props both become one atomic class per property in the same stylesheet, so they follow the same rules as two props:

  • The Tailwind classes of an element compile first, its Devup props after them. For the same property (p-4 and p={12}), the prop comes later in the stylesheet and wins, whichever comes first in the JSX.
  • Properties are ordered with shorthands before longhands. bg="green" is background, while bg-red-500 is background-color, which comes after it, so the Tailwind class wins there. Use one of the two for a property when both would set it.
  • Responsive and hover: styles come after the base styles at the same property, as with props.

What stays as written

Utilities this build does not compile stay in the className: container (it sets a width per breakpoint, which one class cannot hold), the gradient masks (mask-linear-*, mask-radial-*, mask-conic-*, mask-t|r|b|l|x|y-from|to-*), and the variants and values that are not listed in Tailwind CSS v4. Container queries (@sm:, @max-md:, @min-[400px]:, @sm/main:) and the @container utilities, the other masks (mask-none, mask-[...], sizes, positions, repeats, clips, origins, composites, modes), text-shadow-*, inset-shadow-*, font-variant-numeric (tabular-nums and the like, composing), 3D transforms (rotate-x|y|z-*, translate-z-*, scale-z-*, translate-3d, scale-3d, transform-3d, backface-*, perspective-*), contain-* and outline-hidden compile as Tailwind CSS v4 writes them.