From hakuto
Configure web fonts via Astro's Fonts API (top-level `fonts` in Astro 6+, `experimental.fonts` in 5.x). Use whenever custom fonts come up — user mentions Google Fonts, Fontsource, local fonts, typography changes, font loading or performance, or asks to "add fonts", "change typography", "use custom fonts", "improve font loading", "optimize fonts". The Fonts API replaces `@import` and `@font-face` in CSS.
How this skill is triggered — by the user, by Claude, or both
Slash command
/hakuto:fontsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Configure performant web fonts with automatic optimization, preloading, and privacy-focused delivery from your own site.
Configure performant web fonts with automatic optimization, preloading, and privacy-focused delivery from your own site.
Font setup MUST follow this exact sequence. The <Font /> component and CSS variables are only available after the dev server processes the config.
Add fonts to the top-level fonts array (Astro 6+ promoted from experimental.fonts):
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
fonts: [{
provider: fontProviders.google(),
name: "Crimson Pro",
cssVariable: "--font-display",
weights: [400, 600, 700],
styles: ["normal"]
}]
});
Note: Astro ≤ 5.x required
experimental: { fonts: [...] }. Astro 6.x graduated the API; wrap inexperimentalonly if you're on an older Astro.
The dev server reads astro.config.mjs once at startup. Without a restart, import { Font } from 'astro:assets' will fail and the CSS variables won't exist yet.
How to restart depends on how the server was started — pick the one that matches your setup:
bun run dev / bun run preview: stop the process (Ctrl-C) and re-run the same command.pm2 list shows it): pm2 restart <process-name> — typical names are preview or the package name.Don't proceed to Step 3 until the server has restarted — the next step depends on the new config being live.
Now you can use the <Font /> component and CSS variables:
Layout.astro - Add <Font /> to head:
---
import { Font } from 'astro:assets';
---
<head>
<Font cssVariable="--font-display" preload />
<!-- other head elements -->
</head>
index.css - Wire into Tailwind:
@import 'tailwindcss';
@theme {
--font-sans: var(--font-display), ui-sans-serif, system-ui, sans-serif;
}
| Provider | Use When | Import |
|---|---|---|
fontProviders.google() | Quick setup, vast selection | Built-in |
fontProviders.fontsource() | Open-source, granular control | Built-in |
fontProviders.bunny() | Privacy-focused, GDPR compliant | Built-in |
fontProviders.fontshare() | Free distinctive fonts | Built-in |
fontProviders.local() | Custom brand fonts, offline support | Built-in |
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
fonts: [
{
provider: fontProviders.google(),
name: "Crimson Pro",
cssVariable: "--font-display",
weights: [400, 600, 700],
styles: ["normal", "italic"],
subsets: ["latin"]
},
{
provider: fontProviders.google(),
name: "DM Sans",
cssVariable: "--font-body",
weights: [400, 500, 600],
styles: ["normal"]
}
]
});
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
fonts: [
{
provider: fontProviders.fontsource(),
name: "JetBrains Mono",
cssVariable: "--font-mono",
weights: [400, 700],
subsets: ["latin"],
fallbacks: ["monospace"]
}
]
});
import { defineConfig, fontProviders } from "astro/config";
export default defineConfig({
fonts: [{
provider: fontProviders.local(),
name: "Brand Font",
cssVariable: "--font-brand",
options: {
variants: [
{
weight: 400,
style: "normal",
src: ["./src/assets/fonts/BrandFont-Regular.woff2"]
},
{
weight: 700,
style: "normal",
src: ["./src/assets/fonts/BrandFont-Bold.woff2"]
}
]
}
}]
});
Important: Store local fonts in src/assets/fonts/, NOT in public/ (avoids duplicate files in build).
{
provider: fontProviders.google(),
name: "Recursive",
cssVariable: "--font-recursive",
weights: ["300 1000"], // Variable weight range
styles: ["normal"]
}
Recursive (and Sora, Inter Tight, Crimson Pro, Spectral) ships as a single variable font file covering the full weight range — one network request instead of 5–9 separate weight files. Pick a distinctive variable font; avoid Inter and Roboto defaults.
Define font families in src/index.css:
@import 'tailwindcss';
@theme {
/* Override default font families */
--font-sans: var(--font-body), ui-sans-serif, system-ui, sans-serif;
--font-serif: var(--font-display), ui-serif, Georgia, serif;
--font-mono: var(--font-mono), ui-monospace, monospace;
/* Or create custom font utilities */
--font-display: var(--font-crimson);
--font-heading: var(--font-sora);
}
Usage in components:
<h1 class="font-display text-4xl">Beautiful Heading</h1>
<p class="font-sans text-base">Body text content</p>
<code class="font-mono">Code snippet</code>
Import CSS via the frontmatter — Astro processes the import and emits a hashed stylesheet link automatically. Don't use <link rel="stylesheet" href="/src/index.css" />; /src/ paths don't resolve in production builds.
---
import { Font } from 'astro:assets';
import '../index.css';
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<!-- Font declarations and preloads -->
<Font cssVariable="--font-display" preload />
<Font cssVariable="--font-body" preload />
</head>
<body class="font-sans">
<slot />
</body>
</html>
Only preload fonts actually used above-the-fold:
<Font
cssVariable="--font-display"
preload={[
{ weight: '700', style: 'normal', subset: 'latin' }
]}
/>
<Font
cssVariable="--font-body"
preload={[
{ weight: '400', style: 'normal', subset: 'latin' },
{ weight: '600', style: 'normal', subset: 'latin' }
]}
/>
// Bad - downloads all weights
weights: [100, 200, 300, 400, 500, 600, 700, 800, 900]
// Good - only what you use
weights: [400, 600, 700]
// Only Latin characters (most Western sites)
subsets: ["latin"]
// Add if needed
subsets: ["latin", "latin-ext", "cyrillic"]
{
name: "Crimson Pro",
cssVariable: "--font-display",
fallbacks: ["Georgia", "serif"] // Shown while loading
}
Pairings work because of contrast, not similarity. The two cardinal rules:
Sora + Inter, Archivo + Nunito, DM Sans + Work Sans — all flat. The reader can't tell where the heading ends and the body begins. Pair a serif with a sans, a display with a monospace, or a humanist sans with a geometric sans.A single distinctive font used decisively across the whole site often beats a weak pair.
Avoid "AI slop" aesthetics with distinctive combinations. Each row pairs across-family deliberately:
| Display | Body | Why this works |
|---|---|---|
| Crimson Pro (serif) | DM Sans (geometric sans) | Editorial weight against modern minimal — clear hierarchy |
| Spectral (serif) | Source Sans Pro (humanist sans) | Refined editorial paired with workhorse legibility |
| Instrument Serif | Instrument Sans | Designed-as-a-pair; complementary contrast built-in |
| Bitter (slab serif) | Open Sans (humanist sans) | Friendly slab against neutral body — warm but readable |
| Fraunces (display serif) | Inter Tight (geometric sans) | Expressive display paired with tight, distraction-free body |
| Sora (geometric sans, 800) | Crimson Pro (serif, 400) | Single-distinctive-font feel by inverting the usual sans-display / serif-body convention |
| Issue | Solution |
|---|---|
Font import fails or CSS variable undefined | Server hasn't picked up the new config — see Step 2. Also verify cssVariable name matches between astro.config.mjs and your CSS/component usage. |
| Fonts not loading | Check <Font /> is in Layout head |
| Build fails | Ensure local font paths are correct |
| FOUT (flash of unstyled text) | Add preload to critical fonts |
| Large bundle | Reduce weights/subsets, prefer a variable font |
Get font data for OpenGraph images or other uses:
import { fontData } from "astro:assets";
const data = fontData["--font-display"];
// Returns array of font face data with src, weight, style
references/font-providers.md - Detailed provider configurationswebsite-builder/SKILL.md - Overall site workflowbrand-designer/SKILL.md - Color and typography decisionsnpx claudepluginhub teamniteo/hakuto --plugin hakutoCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.