Printed type specimens and a laptop showing a muted forum thread beside a folder of font files

Typography on a forum is two different jobs that people smash into one ticket. Job one is sizes, weights, and which family name the UI asks for — XenForo already exposes those as style properties. Job two is getting the actual font files onto the board — the official manuals do not walk an upload wizard for that. Community practice is @font-face in the child’s extra.less, files you host yourself, woff2, a tight unicode-range, and font-display: swap so the first screen does not sit blank.

This is not the CSS / Less primer. That article taught you selectors, cascade, @xf- tokens, and why extra.less is the file. This one assumes you can open that file without panic. It is also not a Google Fonts tutorial. This site does not recommend a remote webfont CDN: it is a DNS lookup plus a render-blocking CSS file plus a third-party cookie conversation you do not need. Official 2.3 already self-hosts its UI fonts. Your child style should too.

If you came here because the board “looks small on a phone,” start with the responsive piece. If the type is jumping while a webfont loads, stay here — that is CLS, and it is a font problem.

Two layers: properties, then files

Official style properties include typography. You will find families, sizes, and related CSS-type groups under Appearance → Styles → [your child] → Style properties. The exact group labels vary by style (Default versus a vendor framework). The types do not: text, numbers with units, switches, CSS groups.

What the property screen does:

  • Sets which family name headings, body, and UI chrome request (font-family: 'Your Face', @xf-fontFamilyUi or whatever ID your style uses).
  • Sets sizes (@xf-fontSizeNormal and friends, when those IDs exist on your style).
  • Lets a CSS-type group add line-height, letter-spacing, or extra CSS on that component.

What the property screen does not do, in the official docs notebook:

  • Upload a .woff2.
  • Subset a family.
  • Promise the file will be at a URL next year.

So the honest split is:

Need Tool
Make body type 15 px instead of 13 Typography style property
Make H1 use the same family as the logo wordmark Property first; @font-face only if that family is not already on disk
Ship “the brand sans we bought” @font-face in child extra.less + files you host
Ship every weight of a display family “in case” Do not. Two weights, one family, then measure

Work in a child. Same rule as the primer: not Master, not the vendor parent. Style variations do not need a second family. Light and dark share type. They do not share a hard-coded #111 — that is colour, not type.

Style properties: change these before you touch a file

Open the child → Style properties. Look for typography / fonts / text groups. Typical jobs, in the order that actually pays:

  1. Body size and line-height. If members say “cramped,” this is the knob. A 2 px bump on the body property is worth more than a custom display face on the logo.
  2. UI family versus content family. Some styles keep system UI for chrome and a serif or a second sans for post bodies. Decide if you want two families. Two families are two files (or two stacks). One family is simpler and usually faster.
  3. Heading scale. Thread titles, overlay titles, block headers. If you only change H1 in extra.less and leave the property scale alone, widgets and overlays will disagree with the thread.
  4. Muted / dimmed sizes. Meta lines (dates, “started by”) should stay smaller. If you raise everything to “accessible 18 px,” the index becomes a poster.

If a size exists as @xf-fontSizeNormal (or the ID your style prints), use that token in any Less you write later:

.p-body-header .p-title-value {
	font-size: @xf-fontSizeLargest;
	font-weight: 700;
}

Do not invent @xf-fontSizeHuge because it sounds official. If the ID is not on the property screen, it is not a token.

Line-height belongs next to size. A 20 px body on 1.2 line-height is worse than a 15 px body on 1.5. Post bodies are long. Forums are not posters. Aim for a body line-height you can read for twenty minutes, then stop decorating.

@font-face: the community upload path

Because there is no official “upload font” chapter, treat this as community practice that matches how CSS works everywhere else.

1. Buy or license a face you are allowed to self-host. A foundry desktop license is not a web license. A “free for personal use” file is not a license for a public community. This article will not name a marketplace. Read the EULA. If you cannot host the file on your own origin, pick another face.

2. Produce woff2. That is the format modern browsers want. You may keep a woff fallback if you still have a reason; most 2026 boards do not. Do not serve ttf or otf to visitors. Those are desktop files.

3. Subset. A full Latin-plus-everything file is hundreds of kilobytes. If your board is English-first, a Latin / Latin-ext subset is the default. If you run multilingual rooms, subset per language and declare unicode-range (below). Do not ship CJK in the same file as the English UI.

4. Put the files on your origin. Community convention that matches XenForo’s style assets: a path under the style, for example styles/yourchild/fonts/face-latin.woff2 — not a random dump in /data you will lose track of, and not a third-party CDN. The exact folder is yours; the requirement is “same site, cacheable, HTTPS.”

5. Declare the face in the child’s extra.less:

@font-face {
	font-family: "Board Sans";
	src: url("styles/yourchild/fonts/board-sans-latin.woff2") format("woff2");
	font-weight: 400;
	font-style: normal;
	font-display: swap;
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
	font-family: "Board Sans";
	src: url("styles/yourchild/fonts/board-sans-latin-700.woff2") format("woff2");
	font-weight: 700;
	font-style: normal;
	font-display: swap;
	unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Two files. Regular and bold. That is a complete UI family for most boards. Italic is a third file if you actually use <em> in chrome. Post bodies can italicize with the browser’s synthesized slant for a year while you decide.

6. Point the property at the family name. In the typography property, set the family stack to:

"Board Sans", system-ui, sans-serif

The quoted name must match font-family in @font-face exactly. Board Sans and "BoardSans" are different fonts as far as CSS is concerned.

If the property only accepts a stack and your Less still needs to name it:

.p-body-content {
	font-family: "Board Sans", @xf-fontFamilyUi;
}

Again: only if @xf-fontFamilyUi exists on your style. Otherwise use system-ui, sans-serif as the fallback, not a guessed token.

The URL in src is resolved from the compiled CSS, not from the ACP editor. If the woff2 404s, DevTools → Network will show it. Typical causes: the file is not where the URL says, the style directory name is not yourchild, or the host is not serving font/woff2 (some panels still send application/octet-stream, which is usually fine; a text/plain or a login wall is not). Fonts fetched by CSS need a CORS-friendly response when the CSS and the file disagree on origin — another reason to keep both on the same host and not on a random bucket with no Access-Control-Allow-Origin. Cache them like other static assets (long max-age, filename change when you subset again). Do not put them behind a cookie-gated path.

font-display: swap and CLS

font-display tells the browser what to do while the file is in flight.

Value What the member sees When people use it
swap Fallback text immediately, then your face when it arrives Default recommendation here
optional Fallback; the browser may never swap if it is late Extreme first-paint boards
block Invisible text until the face lands (or a timeout) How you ship a blank thread list
fallback Short block, then swap or stay A compromise; still a flash

Swap is the honest default for a forum. Members came to read. Invisible text (FOIT) is worse than a one-frame fallback (FOUT).

The cost of swap is CLS if the fallback metrics disagree with the real face. The thread title jumps. The first post jumps. PageSpeed calls it Cumulative Layout Shift; members call it “the page is broken.”

Reduce the jump:

  • Pick a fallback that is close: if your face is a compact grotesque, fall back to system-ui or a metric-similar system stack, not to Times.
  • Match weight. A 400 face swapping over a bold system default is a different width.
  • Do not put a display face on the body. Display faces have theatrical metrics. Use them on a logo wordmark or an H1 you reserved space for, not on every post.
  • Give images width and height (the image article). Fonts are not the only CLS source; they are the one this page owns.
  • Preload only the one face that is on the first screen (usually regular, Latin, 400). Preloading four weights is how you recreate the CDN problem on your own origin.

XenForo does not, in the official notebook, document a first-party “preload this font” checkbox. If you add a <link rel="preload">, do it in a template modification on the child, one file, as="font" type="font/woff2" crossorigin. If you do not already know why crossorigin is required for fonts, skip the preload. swap plus a 20 KB subset is enough.

Measure CLS on a guest window after the change. The Core Web Vitals article is the rest of that dashboard. This page’s job is: local file, small file, swap, close fallback.

unicode-range: stop shipping the world

A single @font-face without unicode-range makes the browser download that file as soon as any character might need it. A page with one euro sign and a Polish member name can pull a 200 KB “everything” file.

Split files:

File unicode-range (shape) When
*-latin.woff2 Basic Latin + a few punctuation extras Default English UI
*-latin-ext.woff2 Latin Extended-A Polish, Czech, Vietnamese rooms
A third file The script you actually have Only if the language pack is real

The browser downloads the Latin file for every page and the extended file only when a character in that range appears. That is the point. The ranges in the snippet above are a commonly used Latin set, not a XenForo-official table. Adjust them to your subsetter’s output. The subsetter and the unicode-range must agree; a range that claims glyphs you stripped is missing letters, not a clever optimization.

Do not subset away:

  • Common punctuation used in posts (, quotes, dashes)
  • Currency you actually discuss
  • The board’s own language’s diacritics (a Polish community with an English-only subset is a support ticket)

Do subset away:

  • Unused scripts
  • Old-style figures you will never turn on
  • Twelve decorative ligatures a display face ships “for posters”

Weights, italics, and variable fonts

Weights. Forums need 400 and 700. Maybe 600 if your style properties already ask for semibold on buttons. 100 / 200 / 900 are poster weights. Each weight is a file (unless you use a variable font). Each file is LCP budget.

Italics. Real italic is a different file. Synthesized italic is free and slightly ugly. For UI chrome, synthesized is fine. For a long-form knowledge base you can add the italic file later.

Variable fonts. One woff2 that contains a weight axis can replace 400 + 700. They are not free: the one file is often larger than two static subsets. Use a variable font when you actually need three or more weights. Do not use one because a 2024 blog said they are the future.

If you do ship a variable file:

@font-face {
	font-family: "Board Sans";
	src: url("styles/yourchild/fonts/board-sans-var.woff2") format("woff2");
	font-weight: 400 700;
	font-style: normal;
	font-display: swap;
	unicode-range: U+0000-00FF;
}

The font-weight: 400 700 range is how CSS knows the file can serve both. Properties that ask for 700 will not download a second static bold.

Why this site will not tell you to use Google Fonts

A remote webfont looks convenient. It is three extra problems on a forum:

  1. Performance. Extra DNS, extra TLS, a CSS file that blocks render, then the font files. Official 2.3 already spent engineering on self-hosted UI type and SVG icon sprites. The speed article names a remote Google Font as a classic LCP own-goal.
  2. Privacy. A request to a third-party font host is a request with a Referer. European members will ask. Your privacy page will have to explain it. Self-hosting removes the question.
  3. Availability. You do not control that host’s uptime, subset, or URL stability. Your origin is already a dependency. Do not add another for letters.

If a vendor style’s install notes say “paste this Google Fonts <link>,” ignore that line. Download the same family (if the license allows), subset, host, @font-face. The rest of the vendor parent can stay.

System stacks are underrated:

system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif

Zero bytes. Metrics the OS already loaded. A product community that lives in screenshots often looks more native this way than with a fashionable display face. Use a custom family when the brand requires it, not because a blank board feels “unstyled.”

Discourse, IPS, and “just the CSS”

The same @font-face rules apply on Discourse and anywhere else that lets you paste theme CSS. Discourse theme CSS is not extra.less; it is a theme / theme-component editor. Host the files on your Discourse origin or on the same object store you already use for uploads — still not a public webfont CDN. The hosting cookbook is the ops side. This page will not invent a Discourse setting name for “upload a font.” If your hosted plan has a theme-asset upload, use that and reference the URL it gives you.

Invision Community admins: same CSS, different ACP. Do not paste XenForo @xf- tokens into an IPS custom CSS box. They will not resolve.

A one-hour install that does not wreck first paint

  1. Decide the job. Body readability, or a wordmark face for the logo. Not both in hour one.
  2. License check. Can you self-host? If no, stop.
  3. Subset to Latin (plus Latin-ext if your members need it). Export woff2, weights 400 and 700 only.
  4. Upload to a path under the child style. Note the URL you will put in src.
  5. Paste two @font-face blocks into child extra.less. font-display: swap. unicode-range matching the subset.
  6. Set the typography style property stack to "Board Sans", system-ui, sans-serif. Do not also hard-code the family on .p-body unless the property did not take.
  7. Guest window, hard-refresh, forum list + thread view + editor + a page node if you have one.
  8. Both variations. Type does not flip, but contrast against the new canvas can make a thin face vanish on dark.
  9. DevTools → Network: confirm the woff2 comes from your host, confirm you did not also load a fonts.googleapis.com row.
  10. Layout: does the first thread title jump? If yes, the fallback metrics are wrong — change the fallback, not font-display.

Do not preload four files in the same hour. Do not add italic. Do not change sizes and families in the same save if you want to know which one members are yelling about.

What usually looks like a font bug

Symptom Likely cause First move
Still the old face Family name mismatch, or wrong style Quoted name === @font-face; confirm child is Default
Boxes / tofu Subset too aggressive, or unicode-range disagrees Test a Polish name / a euro; widen the subset
Flash then jump swap + distant fallback metrics Closer system stack; stop using a display face on body
Invisible first second font-display: block (or a vendor default) Set swap
Huge LCP Full family, five weights, remote CDN Two woff2, local, Latin only
ACP looks different from public You styled .p-body only That is fine. Do not chase ACP chrome.
Dark variation looks thin Hairline face on a dark canvas Slightly heavier weight on body, or accept it
Icons broke You set font-family on i or on .fa Never restyle Font Awesome / SVG icon hosts

2.3 icons are SVG sprites, not a webfont. The nodes article already fought that war. A global * { font-family: "Board Sans" } is how you break an icon that still expects its own family. Scope the family to body, headings, and inputs — not to every element.

What this is not

It is not an official XenForo “upload fonts” manual. That chapter is not in the docs notebook. We said so on purpose.

It is not permission to load fonts.googleapis.com, Bunny, Adobe Fonts, or any other remote webfont host “just for staging.” Staging is where bad habits become production.

It is not a branding system. Logo, palette, and email chrome are a later brand article. Here you only decide how letters render.

It is not the CSS primer and not the responsive pass. Fluid type and clamp() live there if you need them. A custom face plus fluid type in the same afternoon is two bugs.

Checklist

  • Typography sizes and stacks set on the child style properties first.
  • Font files are self-hosted woff2, licensed for the web, on your origin.
  • @font-face lives in the child’s extra.less (or an included Less file).
  • Weights: 400 and 700 until you measure a need for more.
  • font-display: **swap**.
  • unicode-range matches the subset you actually built.
  • Fallback stack is metric-similar (system-ui, not a serif under a grotesque).
  • No remote webfont <link>, no @import from a font CDN.
  • No global * { font-family } that can hit icon elements.
  • Guest + both variations + Network panel: one or two local woff2, no third-party font host.
  • CLS checked on forum list and thread view after the swap.

Takeaways

  • Official XenForo typography is properties: families, sizes, CSS groups. There is no official font-file uploader in the manuals we used. @font-face in extra.less is the community path.
  • Self-host woff2. Subset. Declare unicode-range. Use font-display: swap. Two weights beat a fashion family with nine files.
  • Do not add a Google Fonts CDN. Official 2.3 already self-hosts UI type. A remote face is an LCP and privacy problem you can refuse.
  • CLS is a metrics problem, not a reason to hide text. Pick a closer fallback. Keep display faces off the body.
  • Scope the family. Leave icons alone. Measure on a guest window. Then stop.

Change the size property this morning if people cannot read the thread. Add a custom family this afternoon only if the brand actually requires those letterforms — and host the files yourself.