Takumi

Styling

Style Takumi templates with inline CSS, stylesheets, Tailwind utilities, and design tokens.

Pick the approach that matches what you 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 witha :root rule
One-off styles on one nodethe style prop

You can mix them. The sections below say which declaration wins.

Inline styles

The style prop sets CSSProperties on one node. It beats the tag default and every matching stylesheet rule of the same importance.

Style one element
< ={{ : "flex", : 48, : "#0f172a" }}>Hello</>

CSS

Pass stylesheet text, or a list of stylesheets, through css.

Apply a stylesheet
import {  } from "takumi-js";

const  = await (< ="card">Hello</>, {
  : 1200,
  : 630,
  : `.card { display: flex; padding: 48px; background: #0f172a; color: white; }`,
});

A <style> tag in the JSX or HTML feeds the same stylesheet. Takumi pulls it out of the tree for you.

Embed a style tag
< ="card">
  <>{`.card { display: flex; padding: 48px; }`}</>
  Hello
</>

The stylesheet supports:

  • Selectors: type, class, id, attribute, and combinators. Structural pseudo-classes such as :first-child and :nth-child(), plus :is(), :where(), :not(), :root, and :lang(). The ::before and ::after pseudo-elements.
  • At-rules: @media, @supports, @layer, and @keyframes.
  • Values: custom properties with var(), calc(), and shorthands.

A render is one static frame. Interactive pseudo-classes such as :hover and :focus parse but never match.

Media queries

@media reads the render's viewport and output:

FeatureReads
width, heightthe viewport size
aspect-ratiothe viewport ratio, as 16/9 or a number
orientationportrait or landscape
resolutionthe device pixel ratio, in dppx, x, dpi, or dpcm
screen, print, allthe media type: image renders are screen, PDF renders are print

width, height, aspect-ratio, and resolution take min- and max- prefixes and the range syntax. Both (min-width: 768px) and (400px < width <= 700px) work. Combine queries with and, or, not, and parentheses.

Any other feature, prefers-color-scheme included, matches nothing. The rest of the stylesheet still applies.

Rule objects

A css entry can be an object instead of text. Use objects to keep application data out of CSS strings.

EntryWrites
{ 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
Build CSS rule objects
import {  } from "takumi-js";

const  = await (< ="card">Hello Takumi</>, {
  : 1200,
  : 630,
  : [{ : "theme" }, { : ".card", : { : "#ffffff", : 48 } }],
});

Takumi parses each prelude with its rule's grammar. A prelude that contains a block is rejected, so data 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. The output is still limited to what the stylesheet supports. With Vite, import the stylesheet with ?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 Tailwind parser. It needs no build step. Satori uses the same prop name.

Use Tailwind utilities
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. Check it when you migrate next/og templates.

tw is a plain string prop, so you can build it at runtime:

Build classes dynamically
import  from "clsx";

const  = true;

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

Preflight

The built-in parser applies no Tailwind Preflight by default. Elements keep their default margins: an <h1> gets a 0.67em top margin until you add mt-0. box-sizing still defaults to border-box.

Without Preflight, a border style utility draws on every side. border-dashed sets border-style on all four sides, and an unset border width is medium. So border-l-2 border-dashed draws a 2px left border and 3px borders on the other sides. Preflight's border: 0 solid reset removes them.

Add the import line a Tailwind stylesheet starts with to turn Preflight on. It removes default margins, list markers, and heading font sizes and weights.

Enable Preflight
import {  } from "takumi-js";

const  = await (< ="text-4xl">Hello Takumi</>, {
  : 1200,
  : 630,
  : `@import "tailwindcss";`,
});

Tailwind's other entry points work the same way:

ImportPreflight
tailwindcss, tailwindcss/index.css, tailwindcss/preflight.csson
tailwindcss/theme.css, tailwindcss/utilities.cssoff

This matches Tailwind's setup without Preflight. Modifiers such as layer(base) and source(none) are ignored.

Utilities in the cascade

Utilities sit in the last declared layer, as Tailwind orders them:

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. Takumi reads these directives and skips the rest:

At-ruleReads asRejected
@themeits :root rule. Nested @keyframes register. Modifiers such as reference and inline change nothingprefix()
@import "tailwindcss"Preflight when the entry point brings it, at the top level onlyany other import target
@applythe utilities' declarations, expanded in place, ! suffix includedvariants such as md:
Define Tailwind utilities
.card {
  @apply mt-4 bg-brand-500; /* [!code highlight] */
}

@utility, @custom-variant, @source, @plugin, and @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), and p-4 resolves calc(var(--spacing) * 4). The built-in scale is the fallback, so you only declare tokens you want to change.

Declaring tokens

Declare tokens in a stylesheet or in a rule object. Both feed every var(): in stylesheets, in <style> tags, and in utilities.

A :root rule, or a Tailwind @theme block pasted as-is:

Define design tokens
:root {
  --color-brand-500: #5b21b6;
  --spacing-gutter: 2.5rem;
}

A rule object in css, so a token from application data needs no string concatenation. Takumi rejects a value that is not valid for its property, so the value cannot escape its rule.

Apply design tokens
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 token's namespace decides which utilities read it:

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, such as font-sans
--font-weight-*font weights, such as 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 colors, so shadow color utilities only reach the built-in fallback
--animate-*animate-*. An unknown token such as animate-wiggle reads var(--animate-wiggle). Pair it with its @keyframes
--breakpoint-*the sm: to 2xl: variants, and new ones such as 3xl:. Variants apply before the cascade, so only an unconditional :root declaration moves them

Overriding tokens

Tokens in a stylesheet follow the cascade. A media query or a selector can override them:

Override tokens by viewport
@media (width >= 1000px) {
  :root {
    --color-brand-500: #a78bfa;
  }
}

A rule object sits where its entry sits in css, so array order is cascade order:

Competing declarationResult
Equally specific :root rule earlierThe later entry wins
A more specific selector, :root:rootThe selector wins
Declaration on the elementElement declaration wins

Where this differs from Tailwind

  • Colors reach gradients and shadows. from-brand-500 and shadow-brand-500 read --color-brand-500, as bg-brand-500 does.
  • As in Tailwind, a gradient needs bg-linear-*, bg-radial, or bg-conic. Color stops alone paint nothing.
  • --color-red-500: initial falls back to the built-in red. It does not remove bg-red-500.
  • A bare rounded keeps its built-in value. rounded-sm and the others read the variable.
  • --spacing needs a unit. With a bare number, p-4 computes pixels here. A browser rejects that declaration.
  • An aliased token stays live. --color-brand: var(--background) follows a --background override in a subtree. In a browser, you need @theme inline for that.

Last updated on