Styling
How CSS reaches a node, what the stylesheet engine supports, and the Tailwind paths.
Pick a path by what you already have:
| You have | Use |
|---|---|
| Tailwind classes, compiled by your bundler | css with the generated CSS |
| Tailwind classes, no build step | the tw prop |
| Plain CSS | css or a <style> tag |
| Design tokens to theme with | cssVariables or a :root rule |
| One-off styles on one node | the style prop |
The paths combine. Each section says who wins when they collide.
Inline styles
The style prop puts CSSProperties on one node. It overrides the tag default and every matching stylesheet rule at the same importance.
<div style={{ display: "flex", padding: 48, background: "#0f172a" }}>Hello</div>CSS
Pass real CSS through css, one string or a list. The engine matches it by className or id.
import { } from "takumi-js";
const = await (< ="card">Hello</>, {
: 1200,
: 630,
: `.card { display: flex; padding: 48px; background: #0f172a; color: white; }`,
});The engine handles:
| Selectors | At-rules | Properties |
|---|---|---|
| class, id, descendant | @keyframes, @media, @supports, @layer | custom properties with var(), shorthands, gradients, box-shadow, filter, backdrop-filter, mix-blend-mode, transform |
A render is one static frame, so interactive pseudo-classes like :hover and :focus parse but
never match.
A <style> tag feeds the same engine. Takumi extracts it from the JSX for you.
<div className="card">
<style>{`.card { display: flex; padding: 48px; }`}</style>
Hello
</div>Tailwind with your bundler
Compile Tailwind with your bundler, then pass the generated CSS through css. Tailwind does the compiling, so every directive and class name works. What renders is still bounded by the engine's CSS support. With Vite, import the stylesheet using ?inline.
import { ImageResponse } from "takumi-js/response";
import stylesheet from "~/styles/global.css?inline";
export function GET() {
return new ImageResponse(
<div className="bg-background text-foreground flex justify-center items-center w-full h-full text-4xl">
Hello Tailwind!
</div>,
{
width: 1200,
height: 630,
css: stylesheet,
},
);
}import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});Tailwind without a build step
The tw prop runs a built-in parser with no build step. It matches the satori prop, so next/og code ports as-is.
import { } from "takumi-js";
const = await (
< ="bg-blue-500 p-4 rounded-lg">
< ="text-white text-2xl font-bold">Hello Tailwind!</>
</>,
{ : 1200, : 630 },
);Arbitrary values work. The parser does not cover every Tailwind feature. The parser mapping lists every supported class.
tw is a plain prop, so build it dynamically:
import clsx from "clsx";
const isError = true;
<div tw={clsx("p-4 rounded", isError ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700")}>
{isError ? "Something went wrong" : "Success!"}
</div>;Preflight
The native parser applies no Tailwind Preflight by default, so elements keep their UA margins. A <h1> gets a 0.67em top margin until you add mt-0. box-sizing still defaults to border-box.
The same import line a Tailwind stylesheet starts with turns Preflight on. It drops the UA margins, list markers and heading font tweaks.
const image = await render(node, {
css: `@import "tailwindcss";`,
});Utilities in the cascade
Utilities sit in the last declared layer, Tailwind's own order:
| Against a utility | Winner |
|---|---|
| An unlayered stylesheet rule | the rule |
A rule in a named @layer | the utility |
The style prop | the style prop |
Prefix a utility with ! to flip the result. Important declarations reverse the layer order, so a ! utility beats an unlayered rule and a normal style declaration.
Tailwind at-rules
A Tailwind source stylesheet drops into css unchanged. The engine reads these directives and skips the rest:
| At-rule | Reads as | Rejected |
|---|---|---|
@theme | its :root rule. Nested @keyframes register. Modifiers like reference and inline change nothing | prefix() |
@import "tailwindcss" | Preflight, at the top level only | any other import target |
@apply | the utilities' declarations, expanded in place, ! suffix included | variants like md: |
.card {
@apply mt-4 bg-brand-500;
}Other Tailwind directives do not parse: @utility, @custom-variant, @source, @plugin and @config. Compile those with Tailwind and use the bundler path.
Design tokens
A utility reads a CSS custom property, the way Tailwind compiles it. bg-red-500 resolves var(--color-red-500), and p-4 resolves calc(var(--spacing) * 4). The built-in scale sits behind them as the fallback, so tokens stay optional until you want your own palette.
Declaring tokens
Three equivalent places. All of them feed every var(), in stylesheets, in <style> tags, and in utilities.
A :root rule, or a Tailwind @theme block pasted as-is:
:root {
--color-brand-500: #5b21b6;
--spacing-gutter: 2.5rem;
}Or the cssVariables option, which compiles into a :root rule. The leading -- is optional.
import { } from "takumi-js";
const = await (< ="bg-brand-500 p-gutter">Hello</>, {
: 1200,
: 630,
: { "--color-brand-500": "#5b21b6", "--spacing-gutter": "2.5rem" },
});Takumi drops an entry whose value contains ;, {, }, /* or !important. It also drops an entry whose name contains :, ;, { or }. Those entries could escape the generated rule.
A nested palette flattens with the cssVariables helper. Keys join with - into one variable name.
import { } from "takumi-js/helpers";
const = ({ : { : { 500: "#5b21b6" } } });
// { "--color-brand-500": "#5b21b6" }Which utilities read a token
The namespace picks which utilities a token reaches:
| Namespace | Utilities |
|---|---|
--color-* | color utilities: bg-*, text-*, border-*, outline-*, decoration-* |
--spacing, --spacing-* | length utilities: p-*, m-*, w-*, gap-*, inset-*. p-4 reads calc(var(--spacing) * 4), p-gutter reads var(--spacing-gutter) |
--container-* | max-w-* |
--text-* | --text-xl sets text-xl, --text-xl--line-height sets its leading |
--font-* | font families, font-sans |
--font-weight-* | font weights, font-bold |
--tracking-* | tracking-* |
--leading-* | leading-* |
--radius-* | rounded-*, including corners and sides |
--aspect-* | aspect-* |
--blur-* | blur-* and backdrop-blur-* presets |
--drop-shadow-* | drop-shadow-* presets |
--shadow-*, --inset-shadow-*, --text-shadow-* | shadow preset shapes. A custom shape carries its own colours, so shadow colour utilities only reach the built-in fallback |
--animate-* | animate-*. An unknown token like animate-wiggle reads var(--animate-wiggle). Pair it with its @keyframes |
--breakpoint-* | the sm:–2xl: variants, and new ones like 3xl:. Variants gate before the cascade, so only an unconditional :root declaration moves them |
Overriding tokens
Tokens declared in a stylesheet follow the CSS cascade. A media query or a selector can override them:
@media (prefers-color-scheme: dark) {
:root {
--color-brand-500: #a78bfa;
}
}Tokens passed as cssVariables sit in a rule after every stylesheet:
| Competing declaration | Result |
|---|---|
Equally specific :root rule in CSS | cssVariables wins |
A more specific selector, :root:root | the selector wins |
| Declaration on the element | Element declaration wins |
Where this differs from Tailwind
- Colours reach gradients and shadows.
from-brand-500andshadow-brand-500read--color-brand-500likebg-brand-500does. - Same as Tailwind, but easy to miss: a gradient needs
bg-linear-*,bg-radialorbg-conic. Stops alone paint nothing. --color-red-500: initialfalls back to the built-in red instead of removingbg-red-500.- A bare
roundedkeeps its built-in value.rounded-smand the rest read the variable. --spacingneeds a unit. A bare number makesp-4compute pixels here, where a browser rejects the declaration.- An aliased token stays live.
--color-brand: var(--background)follows a subtree--backgroundoverride. In a browser,@theme inlineexists to arrange that.
Last updated on