Takumi

Typography & Fonts

Load local or Google Fonts, choose fallbacks for multilingual text, and control typography in Takumi.

Load a font for every script your text uses. Takumi never reads system fonts. The only built-in font is Geist, which covers Latin at weights 300 to 800 and comes last in every fallback chain. Generic families such as sans-serif resolve to the fonts you load.

Loading fonts

The fonts option

Pass the fonts a render needs through fonts. Each entry takes one of three forms:

EntryResolves to
URL stringFetched on demand, keyed by the URL. Name, weight, and style come from the file.
Raw bytesA Uint8Array, ArrayBuffer, or Buffer, registered directly.
Descriptor{ name, data, weight, style, generic }. The fields override what the file reports.

render, renderSvg, renderAnimation, measure, and ImageResponse all take fonts. It also takes a promise of the list, so pass googleFonts(...) without await.

The renderer decodes each file once and keeps it until you release it, or until fontCacheMaxBytes evicts it.

Load a font URL
import {  } from "takumi-js/response";

export function () {
  return new (<>Hello Takumi</>, {
    : ["https://example.com/Inter.woff2"], 
  });
}

A descriptor's data is raw bytes or a function that returns them:

Override font metadata
import {  } from "takumi-js/response";

export function () {
  return new (<>Hello Takumi</>, {
    : [
      {
        : "Inter",
        : 700,
        : () => ("https://example.com/Inter.woff2").(() => .()),
      },
    ],
  });
}

generic assigns the font to a CSS generic family keyword. A stack that ends in monospace or serif then reaches the font without naming it. This is how Tailwind's font-mono finds it:

Assign a generic font family
import {  } from "takumi-js/response";

export function () {
  return new (< ="font-mono">let x = 1;</>, {
    : [
      {
        : "Geist Mono",
        : "monospace", 
        : () => ("https://example.com/GeistMono.woff2").(() => .()),
      },
    ],
  });
}

From Google Fonts

Load multilingual fonts
import {  } from "takumi-js";
import {  } from "takumi-js/helpers";

const  = await (< ={{ : "Inter" }}>Hello 你好 こんにちは</>, {
  : 1200,
  : 630,
  : (["Inter", "Noto Sans JP", "Noto Sans TC"]),
});

googleFonts makes one CSS request for all the families. Font files download only when the text needs them; see subsetting. Use the family name in fontFamily.

Pass an object instead of a name to set a family's weight, style, or variable axes:

Choose font weights and axes
import {  } from "takumi-js/helpers";

await ([
  {
    : "Inter",
    : [400, 700],
    : "italic",
  },
  {
    : "Noto Sans JP",
    : "600..700", // range loads the variable font
  },
  {
    : "Fraunces",
    : "100..900",
    : { : "9..144" }, // vary the optical-size axis too
  },
]);
FieldAcceptsEffect
namea family nameThe family to load. Known families autocomplete their weights and axes.
weighta weight, an array, or a range "100..900"Defaults to 400. A range loads the variable font, and font-weight drives it.
style"normal", "italic", or bothDefaults to "normal". Which styles to fetch.
axes{ tag: value | "min..max" }Variable axes to load, such as { opsz: "9..144" }. A range keeps them live.
generica CSS generic family keywordEvery loaded subset claims it, so font-mono can resolve to the family.

Pass an object instead of an array for request options:

Limit font requests
import {  } from "takumi-js/helpers";

const  = await ({
  : ["Inter"],
  : 3000, 
});
OptionAcceptsEffect
familiesthe list aboveThe families to load, in fallback priority order.
displaya font-display valueSent as display in the CSS request.
cachea MapReplaces the default cache of CSS responses.
baseUrla css2 endpointPoint at a mirror such as https://fonts.bunny.net/css2.

googleFonts also takes the fetch options: timeout, signal, maxBytes, allowUrl, and fetch. The timeout covers each request on its own: the CSS request, then each font file. It does not cover the whole render.

The default CSS cache is bounded and shares concurrent requests for the same URL. A request with signal or allowUrl skips the cache, so cancellation and URL checks stay with the caller. maxBytes applies to cache hits too.

Fonts in CI and offline renders

Bundle font files when a render must work without Google Fonts:

Fonts in CI and offline renders
import {  } from "node:fs/promises";
import {  } from "takumi-js";

const  = await (new ("./fonts/Inter.woff2", import..));

const  = await (< ={{ : "Inter" }}>Hello</>, {
  : [{ : "Inter", :  }],
});

Retries recover a brief outage. They do not help when the host is down or the CI network blocks it. Run unit tests against local files or an injected fetch, and test the live service separately.

An outer test or route timeout can end the render before the font timeout does. Set timeout below that outer limit, and leave time for layout and encoding.

CJK families can have many coverage subsets. Takumi downloads only the subsets your text uses. Matching subsets from several families or weights still download together, so request only the families and weights you use. A timeout during await googleFonts(...) comes from the CSS request, before any font file downloads.

Subsetting

render registers only the subsets the content draws. The same fonts list works for any text, and unused ranges never download. This runs on every render.

Font kindBehavior
Ranged subsets (from googleFonts)Kept only when the text uses their range.
Whole files (URL string, raw bytes)No unicode-range, so they always load.

To filter a list yourself, call subsetFonts({ fonts, source }) from takumi-js/helpers. source is the string, node, or node array you render.

Preloading with registerFont

To load fonts before the first request, register them on a renderer. Then pass that renderer to each render. registerFont takes the same entries as fonts and returns the families it added.

Preload a local font
import {  } from "node:fs/promises";
import {  } from "takumi-js/node";
import {  } from "takumi-js/response";

const  = await (new ("./fonts/Inter.woff2", import..));
const  = new ();

await .({ : "Inter", :  }); 

export function () {
  return new (< ={{ : "Inter" }}>Hello Takumi</>, {
    : 1200,
    : 630,
    , 
  });
}

Releasing fonts

A renderer keeps every font it loads. Release the ones you no longer need:

renderer.unregisterFont({ name: "Inter" }); // every face and subset of Inter
renderer.clearFonts(); // back to the built-in Geist

A later render that lists a released font loads it again.

To release fonts automatically, cap the decoded bytes that fonts lists may keep:

const renderer = new Renderer({ fontCacheMaxBytes: 64 * 1024 * 1024 });

Past the cap, the least recently used families go first. Fonts from registerFont always stay. The font loaded last also stays, even if it alone exceeds the cap. Set the cap above what one render needs.

Choosing a font

Fallback chain

fontFamilies is the ordered list of families to try when a glyph is missing. Set it to choose which family wins and what backs it up.

Choose font fallback order
import {  } from "takumi-js";
import {  } from "takumi-js/helpers";

const  = await (<>Hello こんにちは</>, {
  : (["Inter", "Noto Sans JP"]),
  : ["Inter", "Noto Sans JP"], 
});

Without fontFamilies, the chain is the render's fonts in list order. A render without fonts uses every registered family in registration order. Geist always comes last.

A missing glyph walks the chain until a family covers it. googleFonts registers every subset under its family name with its unicode-range. Each character then uses the subset that covers it, as in a browser.

Variable axes & OpenType features

Set font axes with font-variation-settings and OpenType features with font-feature-settings. In a variable font, font-weight drives the wght axis and font-stretch drives wdth. Kerning is on by default; font-kerning: none turns it off.

Set axes and OpenType features
<div
  style={{
    fontFamily: "Fraunces",
    fontVariationSettings: "'opsz' 72, 'wght' 700",
    fontFeatureSettings: "ss01",
  }}
>
  Variable Font Text
</div>

Register a variable font once, with no weight, to keep its axes live:

Register a variable font
// a `weight` here pins the file to that instance instead
await renderer.registerFont({ name: "Fraunces", data: variableWoff2 });

googleFonts does this for you: a weight range or an axes entry fetches the variable file and leaves its axes free.

Which axes a file exposes depends on the build. Google Fonts fixes the axes you did not request at their defaults, so Fraunces from there exposes only opsz and wght. Other builds keep SOFT and WONK live, and setting them changes the letterforms.

Synthetic bold & italic

When a family lacks the weight or style you asked for, Takumi draws a synthetic bold or oblique. font-synthesis controls this.

Synthetic bold & italic
<div style={{ fontFamily: "Inter", fontSynthesis: "none" }}>No faux bold</div>

List weight, style, small-caps, or any mix of them to allow each one. none turns them all off, so a missing bold weight draws at regular weight.

Styling text

Color, stroke, and fill

color takes modern CSS color spaces, such as oklch(), lab(), and color(display-p3 ...), as well as rgb() and hsl().

Outline glyphs with -webkit-text-stroke. Set a separate fill with -webkit-text-fill-color. The stroke uses color when you leave its color out.

Color, stroke, and fill
<div
  style={{
    color: "oklch(0.7 0.15 200)",
    WebkitTextStroke: "2px black",
    WebkitTextFillColor: "white",
  }}
>
  Outlined
</div>

Decoration & transform

text-decoration draws underline, line-through, and overline. The shorthand also takes a style, color, and thickness, in any order. text-transform changes the case.

Decoration & transform
<div
  style={{
    textDecoration: "underline overline 2px red",
    textTransform: "uppercase",
  }}
>
  Marked up
</div>

Shadow

text-shadow takes offsets, a blur radius, and a color. Separate layers with commas.

Shadow
<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 work as in a browser, justify included.

Spacing & alignment
<div
  style={{
    letterSpacing: "0.05em",
    lineHeight: 1.4,
    textAlign: "justify",
    textIndent: "2em",
  }}
>
  Body copy
</div>

Flowing text

Wrapping

text-wrap: balance evens out line lengths. text-wrap: pretty keeps the last line from holding a single word.

Wrapping
<div style={{ textWrap: "balance" }}>Super Long Text</div>

word-break and overflow-wrap decide where a long word splits. Use word-break: keep-all to keep CJK (Chinese, Japanese, Korean) words whole, and break-all or overflow-wrap: anywhere to split long URLs.

Truncation

line-clamp cuts text after a number of lines and ends the last line with an ellipsis, as Chrome does. line-clamp: 3 none cuts without one. The legacy -webkit-line-clamp works only with display: -webkit-box and -webkit-box-orient: vertical, as in browsers.

Truncation
<div
  style={{
    lineClamp: 3,
  }}
>
  Super Long Text
</div>

text-overflow: ellipsis works only on a box that clips its overflow, as in Chrome, so set overflow: hidden with it. It ends a line that is wider than the box, such as white-space: nowrap text or a word too long to break. Text cut off by the box's height gets no ellipsis; use line-clamp for that.

Fit to container

text-fit scales text to fit its line box instead of wrapping or overflowing. The value starts with a mode: grow, shrink, or none. An optional target and a percentage cap follow.

Fit to container
<div style={{ textFit: "shrink" }}>Headline that always fits</div>
<div style={{ textFit: "grow per-line 150%" }}>Per-line scaled, capped at 150%</div>

The target decides which lines share a scale:

TargetEffect
consistent (default)One scale for the whole block.
per-lineScales each line except the last.
per-line-allScales every line.

The percentage caps how far the scale can go.

With text-overflow: ellipsis, Takumi fits the text first. Only a line that still overflows at its fitted size gets an ellipsis, such as a line the cap holds back. The line-clamp count still applies.

RTL & bidirectional text

Takumi runs the Unicode Bidirectional Algorithm, so Arabic and Hebrew text, mixed with left-to-right text, orders correctly. Set the base direction with the dir attribute or direction: rtl. unicode-bidi controls embedding, isolation, and overrides, as in a browser.

Right-to-left paragraph
<p dir="rtl">مرحبا بالعالم, Hello</p>

Language-aware text

The lang attribute sets a node's BCP-47 language. Descendants inherit it, and a nested lang overrides it. Takumi uses it in three places:

  • Shaping: one code point can draw a different glyph for zh-Hans, zh-Hant, ja, or ko (Han unification).
  • Case mapping: text-transform follows Turkish, Azerbaijani, and Greek rules for those languages.
  • Selectors: :lang() matches it.
Set each element’s language
<div>
  <p lang="ja">日本語</p>
  <p lang="zh-Hant">繁體中文</p>
</div>

Pass lang to set a default for the whole render. A node's own lang still overrides it.

Set the default language
const image = await render(node, { width: 1200, height: 630, fonts, lang: "ja" });

Glyphs change only when the font has per-language variants, such as a pan-CJK font. A font made for one region draws the same glyph for every language.

Emoji have their own section: Images & emoji.

Last updated on