Fonts
Self-host fonts with defineFont() — typed @font-face generation, automatic preload links, deduped head output, and layout-shift-free fallbacks. No files are fetched at build time.
Quick Start
Put the font file in public/ and describe it once with defineFont():
import { defineFont } from "@pracht/core";
export const inter = defineFont({
family: "Inter",
src: "/fonts/inter-latin.woff2",
weight: "100 900", // variable font range
fallbacks: ["Arial", "sans-serif"],
});Register it in a shell (site-wide) or route head() via the fonts array, and use it in components:
import { inter } from "../fonts";
export function head() {
return { title: "My Site", fonts: [inter] };
}
export function Shell({ children }) {
return <div style={inter.style}>{children}</div>;
}The server expands each font into head HTML — routes with hydration: "none" get the exact same output, since no JavaScript is involved:
<link data-pracht-font-preload rel="preload" as="font" type="font/woff2" href="/fonts/inter-latin.woff2" crossorigin="anonymous">
<style data-pracht-fonts>
@font-face{font-family:"Inter";src:url("/fonts/inter-latin.woff2") format("woff2");font-weight:100 900;font-display:swap}
.pracht-font-inter-xxxx{font-family:"Inter", "Arial", sans-serif}
</style>Font preloads always carry crossorigin="anonymous" — fonts are fetched in CORS mode even from your own origin, and a preload without it would be fetched twice.
Using the Font in Components
Every font object exposes three ways to apply it:
import { inter } from "../fonts";
export default function Home() {
return (
<>
{/* class name — the rule ships with the injected font CSS */}
<h1 class={inter.className}>Hello</h1>
{/* inline style object */}
<p style={inter.style}>Body copy</p>
{/* raw font stack for your own CSS variables */}
<div style={{ "--font-sans": inter.fontFamily }} />
</>
);
}inter.fontFamily is the full stack including fallbacks, e.g. "Inter", "Arial", sans-serif.
Importing a font or using its className does not register it by itself: list
the font in the active shell or route head().fonts. Pracht updates generated
font CSS and preload links when client navigation changes the active route,
including clearing both when the destination registers no fonts.
Options
defineFont({
family: "Inter", // required — @font-face family name
src: "/fonts/inter.woff2", // required — public path, or an array of variants
weight: "100 900", // font-weight descriptor (number, string, or range)
style: "italic", // font-style descriptor
display: "swap", // font-display (default "swap")
preload: true, // emit <link rel="preload"> (default true)
unicodeRange: "U+0000-00FF",
fallbacks: ["Arial", "sans-serif"],
// fallback metric overrides — see below
metricsFallback: "Arial",
sizeAdjust: "107%",
ascentOverride: "90%",
descentOverride: "22.5%",
lineGapOverride: "0%",
});src accepts multiple variants of the same face. woff2 is assumed unless a
format is given. Pracht emits WOFF2 variants before fallback formats and
preloads the first WOFF2 source (or the first source when no WOFF2 variant is
present), so the preload and browser-selected source stay aligned without
downloading every fallback. Legacy variable-font hints such as
woff2-variations stay intact in the generated format() descriptor while
their preload uses the underlying container MIME type (font/woff2):
defineFont({
family: "Custom",
src: [
{ url: "/fonts/custom.woff2" },
{ url: "/fonts/custom.woff", format: "woff" },
],
});One defineFont() call describes one face. For multiple weights of a static font, define one font per weight file. Faces using the same local metrics fallback share one font stack and class; weight/style descriptors keep their adjusted fallback metrics separate:
export const interRegular = defineFont({ family: "Inter", src: "/fonts/inter-400.woff2", weight: 400 });
export const interBold = defineFont({ family: "Inter", src: "/fonts/inter-700.woff2", weight: 700 });
// head: { fonts: [interRegular, interBold] }Deduplication
The same font registered by a shell and a route (or by several routes sharing a shell) emits exactly one preload link and one @font-face block. Preloads dedupe by href; @font-face blocks, fallback faces, and class rules dedupe by content, so unicode-range subsets of one family (same family, weight, and style — only src and unicodeRange differ) each keep their own face. Register site-wide fonts once in the shell's head() and page-specific fonts in the route's head() — overlap is free.
Fallback Metrics (no layout shift)
With font-display: swap, text renders in a fallback font first and swaps when the web font loads. If the fallback has different metrics, the page shifts. The metric override options generate an adjusted fallback face that reshapes a local font to match your web font:
export const inter = defineFont({
family: "Inter",
src: "/fonts/inter-latin.woff2",
fallbacks: ["Arial", "sans-serif"],
sizeAdjust: "107.64%",
ascentOverride: "90.44%",
descentOverride: "22.52%",
lineGapOverride: "0%",
});This emits an extra face — local() requires a real installed font, so the first entry in fallbacks that is neither a generic family (sans-serif, system-ui, ...) nor a vendor keyword (-apple-system) is used. When your stack starts with names local() cannot match, point metricsFallback at the font the numbers were computed against:
fallbacks: ["-apple-system", "BlinkMacSystemFont", "Segoe UI", "Arial", "sans-serif"],
metricsFallback: "Arial",The generated face:
@font-face {
font-family: "Inter Fallback 1a2b3c";
src: local("Arial");
font-weight: 400;
size-adjust: 107.64%;
ascent-override: 90.44%;
descent-override: 22.52%;
line-gap-override: 0%;
}and the stack becomes "Inter", "Inter Fallback 1a2b3c", "Arial", sans-serif. The name carries a short hash of the local font. Faces that use that same local font share the stack and generated class, while their weight, style, and unicode-range descriptors select the correct per-face metric values.
Computing the values: the overrides are ratios of the web font's metrics (ascent, descent, lineGap, per-glyph advance widths) to the fallback font's, expressed as percentages. You can:
- copy them from Fontaine or the fallback metrics tables published by Capsize (
@capsizecss/metricshas data for common families), - or compute them once with
npx fontpie ./public/fonts/inter-latin.woff2 --fallback arial.
Pracht deliberately does not read the font binary at build time, so these stay explicit inputs. Automatic metric extraction (and a Google Fonts downloader) are candidates for future work.
Security Notes
Everything interpolated into the generated CSS is escaped or validated: family names and URLs are CSS-string-escaped (including <, so the inline <style> can never be closed early), and descriptor values like weight, display, unicodeRange, and the metric overrides are validated against strict grammars — invalid values throw at defineFont() time rather than reaching the document.
For a nonce-based Content Security Policy, return the request-specific nonce as
fontNonce from a shared shell head() and include the same nonce in
style-src. Pracht places it on the generated font style and preserves that
style element across client navigation:
export function head({ context }) {
return { fonts: [inter], fontNonce: context.cspNonce };
}Use font.className rather than font.style under a strict policy, because
inline style attributes need a separate CSP allowance. Static SSG/ISG output
cannot safely reuse a request nonce; use a stable style hash or an external
stylesheet policy for those routes.