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 | 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>Rule objects
A css entry can be an object. Object entries keep application data out of
CSS strings.
| Entry | Writes |
|---|---|
{ selector, style, rules } | a style rule; rules nests child rules |
{ keyframes, steps } | a @keyframes rule |
{ media, rules } | a @media group holding entries of its own |
{ supports, rules } | a @supports group |
{ layer, rules } | a @layer block; without rules it declares order alone |
css: [
{ layer: "theme" },
{
media: "(prefers-color-scheme: dark)",
rules: [{ selector: ".card", style: { background: "#0f172a" } }],
},
];Takumi parses each prelude with the grammar its rule takes. A prelude that does not parse entirely is an error, so it cannot close the rule and open another.
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 a rule object in css, so a token that comes from application data never
needs string concatenation. Takumi rejects a value that is not a value for its
property, so it cannot escape the rule it was written for.
import { } from "takumi-js";
const = await (< ="bg-brand-500 p-gutter">Hello</>, {
: 1200,
: 630,
: {
: ":root",
: { "--color-brand-500": "#5b21b6", "--spacing-gutter": "2.5rem" },
},
});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;
}
}A rule object sits where its entry sits in css, so the array's order is the
cascade order:
| Competing declaration | Result |
|---|---|
Equally specific :root rule earlier | The later entry 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