Takumi

Styling

How CSS reaches a node, what the stylesheet engine supports, and the Tailwind paths.

Pick a path by what you already have:

You haveUse
Tailwind classes, compiled by your bundlercss with the generated CSS
Tailwind classes, no build stepthe tw prop
Plain CSScss or a <style> tag
Design tokens to theme withcssVariables or a :root rule
One-off styles on one nodethe 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:

SelectorsAt-rulesProperties
class, id, descendant@keyframes, @media, @supports, @layercustom 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.

og.tsx
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,
    },
  );
}
vite.config.ts
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 utilityWinner
An unlayered stylesheet rulethe rule
A rule in a named @layerthe utility
The style propthe 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-ruleReads asRejected
@themeits :root rule. Nested @keyframes register. Modifiers like reference and inline change nothingprefix()
@import "tailwindcss"Preflight, at the top level onlyany other import target
@applythe utilities' declarations, expanded in place, ! suffix includedvariants 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:

NamespaceUtilities
--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 declarationResult
Equally specific :root rule in CSScssVariables wins
A more specific selector, :root:rootthe selector wins
Declaration on the elementElement declaration wins

Where this differs from Tailwind

  • Colours reach gradients and shadows. from-brand-500 and shadow-brand-500 read --color-brand-500 like bg-brand-500 does.
  • Same as Tailwind, but easy to miss: a gradient needs bg-linear-*, bg-radial or bg-conic. Stops alone paint nothing.
  • --color-red-500: initial falls back to the built-in red instead of removing bg-red-500.
  • A bare rounded keeps its built-in value. rounded-sm and the rest read the variable.
  • --spacing needs a unit. A bare number makes p-4 compute pixels here, where a browser rejects the declaration.
  • An aliased token stays live. --color-brand: var(--background) follows a subtree --background override. In a browser, @theme inline exists to arrange that.

Last updated on

On this page