A custom font on the web is not one file with a name. It is a family, and each weight or style is a separate member the browser has to find.
I hit this with Satoshi: font-family worked, font-bold did not. The class was fine. The bold file was never registered.
One-sentence answer: the browser uses your custom font only for weights (and styles) you actually registered — a real file per weight, or one variable font that covers that range.
Why it matters
Custom fonts fail in quiet ways: “bold” looks thin, headings feel off-brand, or text flashes as Arial then jumps. If you know what the browser is looking for, those bugs take minutes instead of hours.
Think of it as a family name plus members:
font-family: 'Satoshi'→ “use the Satoshi family”font-weight: 700→ “use the bold member of that family”
If you only invited Regular to the party, Bold never shows up.
What the browser actually does
When CSS says:
font-family: "Satoshi", sans-serif;
font-weight: 700;the browser:
- Looks for a family named
Satoshi - Looks for a face in that family with weight
700 - Uses it if it exists
- Otherwise synthesizes a fake bold, falls back to
sans-serif, or leaves the text looking regular
Tailwind / HeroUI never download fonts. They only set CSS:
| Class | What it really means |
|---|---|
font-medium | font-weight: 500 |
font-semibold | font-weight: 600 |
font-bold | font-weight: 700 |
The stack is:
Font files (WOFF2)
→ @font-face (register name + weight)
→ CSS font-family
→ Tailwind / HeroUI classesMiss a step, and font-bold has nothing real to apply.
Font formats: OTF vs WOFF2
| Format | Full name | Use it for |
|---|---|---|
| TTF | TrueType | Desktop apps, older web |
| OTF | OpenType | Figma / design tools, not the web |
| WOFF | Web Open Font Format | Web, compressed |
| WOFF2 | Web Open Font Format 2 | Production web (best default) |
OTF is not “unsupported.” It is just the wrong default:
- Files are heavier
- Packs from design tools often include only Regular
- You can register that one file, see the family load, then wonder why
font-bolddoes nothing
WOFF2 is built for the web: smaller files, the format CDNs and Fontshare actually ship for production, and usually one file per weight.
@font-face is the registration desk
@font-face is how you tell the browser the family exists. Same family name, one block per weight:
@font-face {
font-family: "Satoshi";
src: url("/fonts/Satoshi-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Satoshi";
src: url("/fonts/Satoshi-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}Rules that actually matter:
font-familymust be identical in every block (Satoshi, notSatoshi Bold)font-weight: 700is whatfont-boldmaps to- If you only declare
400,font-boldhas no real file srcmust point at a file the browser can fetch (public URL, not a local Figma export)
The broken setup (only Regular)
@font-face {
font-family: "Satoshi";
src: url("/fonts/Satoshi-Regular.otf") format("opentype");
font-weight: 400;
}<div class="font-sans font-bold">This will not look bold</div>The family loads. Weight 700 does not.
Static fonts vs variable fonts
Static: one file per weight.
Satoshi-Regular.woff2→ 400Satoshi-Medium.woff2→ 500Satoshi-Bold.woff2→ 700
Variable: one file covers a range:
@font-face {
font-family: "Satoshi";
src: url("/fonts/Satoshi-Variable.woff2") format("woff2");
font-weight: 300 900;
font-style: normal;
font-display: swap;
}Then font-medium, font-semibold, and font-bold can all come from that single file. Don’t also register static files under the same name unless you mean to — two sources for one family is how the wrong face wins.
font-display: swap
While the custom file downloads, the browser still has to show something.
| Value | Behavior | Typical use |
|---|---|---|
swap | Show fallback text, then swap in the font | Product UI default |
block | Hide text briefly until the font is ready | Rare; feels like a flash |
optional | Browser may skip the custom font if slow | Extreme performance |
For most apps, swap is the right default: readable immediately, branded a moment later.
Wire it into Tailwind
Register the files first. Then tell Tailwind the family name:
fontFamily: {
sans: ["Satoshi", "system-ui", "sans-serif"],
}In Tailwind v4 that often lives in CSS:
@theme {
--font-sans: "Satoshi", system-ui, sans-serif;
}On Next.js you can skip hand-written @font-face and use next/font/local. The mental model does not change: you still need a file for every weight you use.
import localFont from "next/font/local";
const satoshi = localFont({
src: [
{ path: "../public/fonts/Satoshi-Regular.woff2", weight: "400" },
{ path: "../public/fonts/Satoshi-Bold.woff2", weight: "700" },
],
display: "swap",
variable: "--font-sans",
});Production checklist
- Prefer WOFF2
- Ship every weight you actually use (
400,500,600,700, …) - Register each weight with
@font-face(ornext/font) - Keep one family name
- Set
font-display: swap - Keep the license file next to the fonts
- Self-host when you can
- Map the family in Tailwind / your theme
- In DevTools → Network, confirm the bold file is requested when a
font-boldnode is on screen
Common mistakes
- Using an OTF Regular from Figma and expecting
font-boldto invent Bold - Naming faces
SatoshiandSatoshi Boldas two families instead of one family, two weights - Pointing
srcat a path that 404s (the fallback font silently takes over) - Mapping Tailwind
font-sansto Satoshi but never registering@font-face - Registering only
400and700, then usingfont-medium(500) and wondering why it looks wrong
Takeaway
A utility class does not load a font. font-bold only asks for weight 700. Give the browser a WOFF2 (or variable) face at that weight, register it under one font-family name, then let CSS set the number.