Styling
Style Takumi templates with inline CSS, stylesheets, Tailwind utilities, and design tokens.
Pick the approach that matches what you 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 |
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.
< ={{ : "flex", : 48, : "#0f172a" }}>Hello</>CSS
Pass stylesheet text, or a list of stylesheets, through css.
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.
< ="card">
<>{`.card { display: flex; padding: 48px; }`}</>
Hello
</>The stylesheet supports:
- Selectors: type, class, id, attribute, and combinators. Structural pseudo-classes such as
:first-childand:nth-child(), plus:is(),:where(),:not(),:root, and:lang(). The::beforeand::afterpseudo-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:
| Feature | Reads |
|---|---|
width, height | the viewport size |
aspect-ratio | the viewport ratio, as 16/9 or a number |
orientation | portrait or landscape |
resolution | the device pixel ratio, in dppx, x, dpi, or dpcm |
screen, print, all | the 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.
| 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 |
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.
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 Tailwind parser. It needs no build step. Satori uses the same prop name.
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:
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.
import { } from "takumi-js";
const = await (< ="text-4xl">Hello Takumi</>, {
: 1200,
: 630,
: `@import "tailwindcss";`,
});Tailwind's other entry points work the same way:
| Import | Preflight |
|---|---|
tailwindcss, tailwindcss/index.css, tailwindcss/preflight.css | on |
tailwindcss/theme.css, tailwindcss/utilities.css | off |
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 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. Takumi reads these directives and skips the rest:
| At-rule | Reads as | Rejected |
|---|---|---|
@theme | its :root rule. Nested @keyframes register. Modifiers such as reference and inline change nothing | prefix() |
@import "tailwindcss" | Preflight when the entry point brings it, at the top level only | any other import target |
@apply | the utilities' declarations, expanded in place, ! suffix included | variants such as md: |
.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:
: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.
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:
| 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, 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:
@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 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
- Colors reach gradients and shadows.
from-brand-500andshadow-brand-500read--color-brand-500, asbg-brand-500does. - As in Tailwind, a gradient needs
bg-linear-*,bg-radial, orbg-conic. Color stops alone paint nothing. --color-red-500: initialfalls back to the built-in red. It does not removebg-red-500.- A bare
roundedkeeps its built-in value.rounded-smand the others read the variable. --spacingneeds a unit. With a bare number,p-4computes pixels here. A browser rejects that declaration.- An aliased token stays live.
--color-brand: var(--background)follows a--backgroundoverride in a subtree. In a browser, you need@theme inlinefor that.
Last updated on