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 bundlerstylesheets with the generated CSS
Tailwind classes, no build stepclassName + a future flag
Plain CSSstylesheets 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 spells out 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>

Stylesheets

Pass real CSS through stylesheets and match 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 and gets extracted from the JSX automatically.

<div className="card">
  <style>{`.card { display: flex; padding: 48px; }`}</style>
  Hello
</div>

Tailwind with your bundler

Compile Tailwind with your bundler and pass the generated CSS through stylesheets. Tailwind itself does the compiling, so every directive and class name works; the engine's CSS support still bounds what renders. 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,
      stylesheets: [stylesheet],
    },
  );
}
vite.config.ts
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [tailwindcss()],
});

Tailwind without a build step

Turn on the classNameUtilities future flag and className tokens run through a built-in parser. JSX pasted from an app renders without renaming a prop.

import {  } from "takumi-js";

const  = await (
  < ="bg-blue-500 p-4 rounded-lg">
    < ="text-white text-2xl font-bold">Hello Tailwind!</>
  </>,
  {
    : 1200,
    : 630,
    : { : true },
  },
);

Arbitrary values work. The parser won't cover every Tailwind feature; the parser mapping lists every supported class.

className is a plain prop, so build it dynamically:

import clsx from "clsx";

const isError = true;

<div
  className={clsx(
    "p-4 rounded",
    isError ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700",
  )}
>
  {isError ? "Something went wrong" : "Success!"}
</div>;

The tw prop feeds the same parser without any flag. It is a plain prop too, takes the same dynamic expressions, exists for satori compatibility, and wins a tie against a className utility on the same node.

<div tw="bg-blue-500 p-4 rounded-lg">Hello</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, {
  stylesheets: [`@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 stylesheets 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 (@utility, @custom-variant, @source, @plugin, @config) do not parse. 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), p-4 resolves calc(var(--spacing) * 4). The built-in scale sits behind them as the fallback, so tokens are 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" },
  : { : true },
});

cssVariables entries whose value contains ;, {, }, /* or !important, or whose name contains :, ;, { or }, are dropped. They 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