Typography & Fonts
Load fonts, route scripts across them, and style text.
Takumi never reads system fonts. Load every font a render needs, or its glyphs fall back to tofu.
Each package ships its own built-in families. Add your own for anything else.
| Environment | Package | Bundled families |
|---|---|---|
| Node.js | @takumi-rs/core | Geist, Geist Mono |
| Browser, edge | @takumi-rs/wasm | Manrope |
Loading fonts
The fonts option
Pass the fonts a render needs through fonts. Each entry is raw bytes, or a { name, data, weight, style } descriptor. data can be a loader that returns the bytes. A reused renderer decodes each file once.
import { } from "takumi-js/response";
export function () {
return new (< />, {
: [
{
: "Inter",
: () => ("/path-to-inter.woff2").(() => .()),
},
],
});
}The takumi-js functions render, renderAnimation, and renderSvg take the same fonts option.
From Google Fonts
googleFont fetches a family from Google Fonts and returns fonts entries. You get one lazy loader per woff2 file. Each file downloads only when the renderer first needs it. Use the family by its name in font-family.
import { render } from "takumi-js";
import { googleFont } from "takumi-js/helpers";
const image = await render(<div style={{ fontFamily: "Inter" }}>Hello</div>, {
width: 1200,
height: 630,
fonts: await googleFont("Inter", { weight: [400, 700] }),
});| Option | Accepts | Effect |
|---|---|---|
weight | one weight, an array, or a range like "100..900" | A range loads the variable font; CSS font-weight drives it. |
style | "normal", "italic", or both | Which styles to fetch. |
text | a string | Downloads only the glyphs that string uses. |
display | a font-display value | Maps to CSS font-display. |
fonts: await googleFont("Inter", { weight: 700, style: "italic", text: title }),Multilingual content
googleFontSubsets loads only the subsets the content needs, for when you can't predict which scripts appear. Give it your content and the families to draw from. It scans the text, fetches each family's metadata in one request, and keeps the subsets that match.
import { render } from "takumi-js";
import { fromJsx } from "takumi-js/helpers/jsx";
import { googleFontSubsets } from "takumi-js/helpers";
const element = <div style={{ fontFamily: "Inter" }}>Hello 你好 こんにちは</div>;
const { node } = await fromJsx(element);
const fonts = await googleFontSubsets(node, ["Inter", "Noto Sans JP", "Noto Sans TC"]);
const image = await render(node, { width: 1200, height: 630, fonts });Each subset registers under its own internal name. font-family: Inter still expands across all of them, so each script finds the file that covers it. Pass a Map as cache to reuse the metadata across renders, skipping the metadata fetch on each re-render.
const fonts = await googleFontSubsets(node, ["Inter"], { cache });Preloading with registerFont
registerFont is the escape hatch for preloading. Register a font on a renderer up front, outside the request path. Then reuse that renderer. It takes the same entries as fonts and returns the families it made.
import { Renderer } from "@takumi-rs/core";
const renderer = new Renderer();
await renderer.registerFont({ name: "Inter", data: inter });
return new ImageResponse(<OgImage />, { renderer });Choosing a font
Fallback chain
fontFamilies is the ordered list of families to try when a glyph is missing. It defaults to all registered families, in registration order. Set it to pin which family wins and what backs it up.
const image = await renderer.render(node, {
fontFamilies: ["Inter", "Noto Sans JP"],
});A missing glyph walks the chain until a family covers it. googleFontSubsets builds on this: each subset registers under its own internal name, while one logical family name in font-family expands across them.
Variable axes & OpenType features
Control font axes with font-variation-settings. Control OpenType features with font-feature-settings. For variable fonts, font-weight is shorthand for the wght axis, and font-stretch drives wdth.
<div
style={{
fontFamily: "Manrope",
fontVariationSettings: "'wght' 700, 'wdth' 150",
fontFeatureSettings: "ss01",
}}
>
Variable Font Text
</div>Synthetic bold & italic
When a family lacks the weight or style you asked for, Takumi fakes a bold or oblique. font-synthesis turns that off. Missing weights then stay regular.
<div style={{ fontFamily: "Inter", fontSynthesis: "none" }}>No faux bold</div>Values are weight, style, or none. Pass weight, style, or both to allow each one; none disables them. Emoji never get a fake bold.
Styling text
Color, stroke, and fill
color accepts modern CSS color spaces:
rgbhsloklchlabdisplay-p3- and more
Outline glyphs with -webkit-text-stroke. Set a separate fill with -webkit-text-fill-color.
<div
style={{
color: "oklch(0.7 0.15 200)",
WebkitTextStroke: "2px black",
WebkitTextFillColor: "white",
}}
>
Outlined
</div>The stroke color defaults to color when you omit it.
Decoration & transform
text-decoration draws underline, line-through, and overline. Combine them, and set color and thickness. text-transform changes the case.
<div
style={{
textDecoration: "underline overline",
textDecorationColor: "red",
textTransform: "uppercase",
}}
>
Marked up
</div>Shadow
text-shadow takes offset, blur, and color. Stack layers with commas.
<div style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.5), 0 0 8px blue" }}>Glow</div>Spacing & alignment
letter-spacing, word-spacing, line-height, text-indent, and text-align (including justify) work as in the browser.
<div
style={{
letterSpacing: "0.05em",
lineHeight: 1.4,
textAlign: "justify",
textIndent: "2em",
}}
>
Body copy
</div>Flowing text
Wrapping
Takumi supports balance and pretty wrapping, adapted from satori. balance evens out line lengths. pretty stops the last line from stranding one word.
<div style={{ textWrap: "balance" }}>Super Long Text</div>word-break and overflow-wrap decide where a long token splits. break-all, keep-all, and anywhere cover CJK and long URLs.
Truncation
When text-overflow is ellipsis, Takumi truncates at the line-clamp count or the container's max height, whichever comes first. Multiline ellipsis works; no white-space: nowrap needed.
<div
style={{
textOverflow: "ellipsis",
lineClamp: 3,
}}
>
Super Long Text
</div>Fit to container
text-fit scales text to fit its line box instead of wrapping or overflowing. The mode comes first: grow, shrink, or none. An optional target and percentage cap follow.
<div style={{ textFit: "shrink" }}>Headline that always fits</div>
<div style={{ textFit: "grow per-line 150%" }}>Per-line scaled, capped at 150%</div>The optional target controls which lines share a scale:
| Target | Effect |
|---|---|
consistent (default) | One scale for the whole block. |
per-line | Scales each line except the last. |
per-line-all | Scales every line. |
The percentage caps how far the scale can move.
RTL & bidirectional text
Takumi handles Right-to-Left scripts like Arabic and Hebrew, including mixed runs. There's no manual override for text direction yet (see issue #330). The direction property controls layout, not the text run.
Language-aware text
The lang attribute sets the BCP-47 language for a node. Descendants inherit it, and a nested lang overrides. It drives locale-aware shaping: Han unification (one code point draws a different glyph for zh-Hans, zh-Hant, ja, or ko) and language-correct line breaking.
<div>
<p lang="ja">日本語</p>
<p lang="zh-Hant">繁體中文</p>
</div>To set a default for the whole render, pass lang. Per-node lang still overrides it.
const image = await render(node, { width: 1200, height: 630, fonts, lang: "ja" });The glyphs only change when the font carries per-language variants, such as a pan-CJK font. With a single-region font every language draws the same glyph.
Emoji rendering lives on its own page: Images & emoji.
Last updated on