Takumi

Styling

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

Four ways to put CSS on a node:

  • style prop: inline CSSProperties on one node. Overrides the tag default.
  • tw prop: Tailwind classes on one node.
  • <style> tags: CSS that travels inside the JSX.
  • stylesheets option: full stylesheets matched against className and id.

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, @supportscustom 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>

CSS variables

The cssVariables option declares CSS custom properties without writing CSS. Each entry becomes one :root declaration. The leading -- is optional.

import {  } from "takumi-js";

const  = await (< ="card">Hello</>, {
  : 1200,
  : 630,
  : [`.card { color: var(--brand) }`],
  : { "--brand": "#5b21b6" },
});

Every var() reads them, in stylesheets, in <style> tags, and in tw.

Competing declarationResult
Equally specific :root rule in CSScssVariables wins
Declaration on the elementElement declaration wins

The generated rule comes after every stylesheet, so a media query at equal specificity still loses. Override an entry with a more specific selector, such as :root:root.

Values containing ;, {, }, /* or !important 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" }

Tailwind

Bring your stylesheet

Compile Tailwind with your bundler. Pass the generated CSS through stylesheets. With Vite, import it 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()],
});

Native parser

The tw prop runs a built-in parser with no build step. It won't cover every Tailwind feature.

  • Arbitrary values work.
  • See the parser mapping for every supported class.

tw has 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.

To reset those defaults, pass Preflight through stylesheets, wrapped in a layer so it stays below your utilities:

import preflight from "tailwindcss/preflight.css?inline";

const image = await render(node, {
  stylesheets: [`@layer base { ${preflight} }`],
});

Unlayered stylesheet rules override tw utilities, and rules in a named @layer lose to them. That is Tailwind's own order: utilities are the last declared layer. Prefix a utility with ! to override an unlayered rule.

<div tw="bg-blue-500 p-4 rounded-lg">
  <h1 tw="text-white text-2xl font-bold">Hello Tailwind!</h1>
</div>

Utilities read CSS variables

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.

Declare tokens in a :root rule, in a Tailwind @theme block pasted as-is, or pass them as the cssVariables option. @theme reads as the :root rule it compiles to, @keyframes inside it register, and @theme reference emits nothing.

:root {
  --color-brand-500: #5b21b6;
  --spacing-gutter: 2.5rem;
}
<div tw="bg-brand-500 p-gutter" />

Tokens declared in a stylesheet follow the CSS cascade. A media query or a selector can override them. Tokens passed as cssVariables sit after every stylesheet, so overriding one takes a more specific selector.

@media (prefers-color-scheme: dark) {
  :root {
    --color-brand-500: #a78bfa;
  }
}

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

Some things behave differently from Tailwind here:

  • Colours reach gradients and shadows: from-brand-500 and shadow-brand-500 read --color-brand-500 like bg-brand-500 does.
  • A gradient needs bg-linear-*, bg-radial or bg-conic. Stops alone paint nothing, as in Tailwind.
  • --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.

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>;

Last updated on

On this page