WebProCMS gives every install a GUI for setting site-wide typography — body and heading fonts, optional heading accent font, per-heading size/weight/letter-spacing/line-height, text-wrap behaviour (avoid orphans, even line lengths), and font antialiasing. Editors pick from dropdowns; the CMS emits the underlying Tailwind-aligned CSS so the public site stays consistent without anyone hand-writing classes.
What editors get
The Typography settings page (/dashboard/settings/typography) exposes the full typographic surface of the public site without requiring the editor to touch CSS:
- Body font, heading font, and optional heading accent font — picked from a curated font browser
- Antialiasing mode — smooth, subpixel, or auto (subpixel on light, smooth on dark)
- Per-heading defaults for
h1–h4— size, weight, letter spacing, line height, text wrap, and an "extra classes" override field - Body defaults — size, weight, text wrap, and extra classes
Every dropdown maps to a Tailwind-aligned token. The editor never writes utility classes; the CMS turns those choices into @layer base { h1 { … } h2 { … } body { … } } CSS so individual row-level utilities (text-2xl, tracking-tight, etc.) still win where applied.
Tailwind-grade typographic controls
The power of Tailwind's typography utilities — features that the broader web only got reliable browser support for in the last few years — surfaced as plain dropdowns:
Letter spacing (tracking-*)
Six steps from tracking-tighter (−0.05em) through tracking-widest (0.1em), plus a "Default" option that emits no rule and lets the browser/font handle it. Most useful for tightening display headings (tracking-tight on a 60px hero) or loosening short caps labels (tracking-wider).
Line height (leading-*)
Six steps from leading-none (1.0) through leading-loose (2.0), plus "Default". Lets editors tighten heading line height for impact (leading-tight on multi-line h1s) or loosen body-adjacent headings for readability.
Text wrap
Modern CSS line-breaking algorithms, dropdown-exposed:
- Pretty (
text-wrap: pretty) — avoids single-word orphans on the last line. Drop this on body/paragraph defaults and headlines and the site stops finishing paragraphs with a lonely word at the bottom. - Balance (
text-wrap: balance) — distributes words evenly across lines, eliminating long-line/short-line headline ragging without manual<br>tags.
Per-heading + body-default — pick balance on h1s and pretty on body and the whole site benefits without anyone editing a row.
Antialiasing
Controls -webkit-font-smoothing / -moz-osx-font-smoothing on the public site body:
- Smooth — antialiased (Tailwind default). Best for most modern designs.
- Subpixel — browser native rendering. Slightly heavier on Windows ClearType, useful for serif body type.
- Auto — subpixel on light backgrounds, smooth on dark. Adapts to the
.darkclass so dark mode stays crisp without making light mode look washed.
Font browser
Three roles can each be assigned a font: body, heading, and optional heading accent. The accent is for highlighting a single word inside a heading (the page editor's "Aa" toolbar button) — pair Instrument Sans for headings with Caveat for one swooshy word in a hero.
The browser groups fonts by category — sans, serif, mono, display (Bebas, Anton, Abril Fatface), and handwriting (Pacifico, Caveat, Dancing Script, Great Vibes, Sacramento) — and live-loads the chosen font's stylesheet on hover so editors see real previews before saving.
Self-hosted web fonts (with system-font defaults)
Web fonts come from the Bunny Fonts catalogue — a privacy-first drop-in replacement for Google Fonts — but visitors download them from the site itself, so a visitor's browser never contacts a font CDN at all.
- When fonts are saved, the selected families (body, heading, heading accent) are downloaded once, after the save's response has gone out: Bunny's stylesheet plus every
.woff2file it names, into the public disk understorage/app/public/fonts/. The stylesheet is rewritten to point at those files with relative URLs, and only the woff2 source is kept (every browser in use supports it). - Two places a font file may come from, and no others. Bunny's stylesheet names each file either in Bunny's own folder (
fonts.bunny.net/{family}/files/) or in the Fontsource font folder on jsDelivr (cdn.jsdelivr.net/fontsource/fonts/), and one stylesheet can name both. The server fetches a file from whichever of the two it was named in and stores it under the same name either way. A stylesheet that names a file anywhere else — another folder on jsDelivr included — is refused whole, and the site keeps linking Bunny until a later run succeeds. That matters for more than tidiness: while the site links Bunny, a stylesheet that names jsDelivr sends each visitor's browser there too, and only the local copy ends that. - Every path that changes fonts triggers it — the Typography page, a theme install or switch, the AI site generator, a brand-design import — because each one busts the branding CSS cache, and that is where the download is scheduled. An hourly
fonts:localizeLazy Cron run catches anything else: a restore, a download that failed, and existing sites after the update that introduced this. Runphp artisan fonts:localizeto do it by hand. - Never unstyled, never both. Each font selection is stored as its own set, keyed by a hash of the families and weights. The page links the local copy only once that exact set is complete — its stylesheet is written last, after every font file is in place — and links
fonts.bunny.netuntil then. So a failed download, an interrupted one, or a selection nobody has downloaded yet keeps working exactly as before, and the Bunny link and its preconnect disappear the moment the local copy exists. A download that fails is logged as a warning and retried by the next run; an interrupted one resumes from the files it already saved. Once a new set is in place the response cache is cleared and older sets are deleted — except the selection that is live when the download finishes, so switching fonts back and forth mid-download never strands the current choice on Bunny. - Only where
/storageserves. The local copy is reached through thepublic/storagelink, which some hosts can't create (see the dashboard's storage-link alert). While that link is broken no set is downloaded, and a run that finds it broken after a set exists removes that set's stylesheet, so the page linksfonts.bunny.netagain until the link is repaired; the next run then brings the fonts back (the font files are kept). - The dashboard's own font previews (the font browser, design-library preview frames) still load from Bunny; only staff see those.
The public layout attaches the font stylesheet — local or Bunny — only after the window load event (a small inline loader in partials/head.blade.php, with a <noscript> fallback): display=swap already paints the text in the fallback face at once, but the browser fetches font files at its highest priority, so a stylesheet that is merely non-blocking still puts ~100 KB of top-priority third-party requests level with the hero image and the critical CSS — which Lighthouse counts against FCP and LCP, and which really does slow the hero on a poor connection. Deferring to load keeps the whole chain off the critical path; on a fast connection the swap lands a couple of hundred milliseconds later than before, on a slow one the hero arrives first. Measured on a two-family site: mobile PageSpeed 96 → 100, LCP 2.4 s → 1.35 s.
The default install ships with system fonts selected (Tailwind's font-sans/font-serif/font-mono stacks), which means:
- Zero external font requests
- Zero load penalty — visitors render in their OS's native UI font (San Francisco on macOS/iOS, Segoe UI on Windows, Roboto on Android)
- Zero CLS / FOUT from font loading
Only when an editor explicitly picks a web font is a font downloaded at all. Choosing system fonts everywhere is a valid, performance-optimal answer for many sites.
Output: CSS, not classes
Saving the Typography page writes to branding.typography in settings and busts the branding CSS cache. On the next request, BrandingStyleService::typographyCssBlock() regenerates a CSS block that emits:
@layer base {
h1 { font-size: 3rem; font-weight: 700; letter-spacing: -0.025em; line-height: 1.25; text-wrap: balance; }
h2 { font-size: 2.25rem; font-weight: 600; line-height: 1.375; }
body { font-size: 1rem; font-weight: 400; text-wrap: pretty; }
}
The @layer base wrapper is intentional — unlayered Tailwind utilities (.text-xl, .font-bold, .tracking-tight) beat layered rules regardless of specificity, so a row template that hard-codes <h2 class="text-4xl tracking-wide"> still overrides the global default for that one element. Editors get a global baseline; designers retain per-row control.
Font-family CSS variables (--font-sans, --font-heading, --font-heading-accent) are emitted alongside so font-heading and the accent class on a highlighted word resolve to the right stacks automatically.
What lives where
| Path | Purpose |
|---|---|
resources/views/pages/dashboard/settings/⚡typography.blade.php |
The Typography page — font browser, antialiasing select, per-heading and body dropdowns. |
app/Services/BrandingStyleService.php |
FONTS registry (Bunny slug + weights per face), typographyCssBlock() (cached <style> block for the public layout), buildHeadingElementCss() (size/weight/tracking/leading/text-wrap/antialiasing → CSS). |
app/Support/Fonts/LocalFonts.php |
The self-hosting mirror: which stylesheet the public head links (stylesheet()), the download (sync()), the after-save trigger (syncAfterResponse(), called from BrandingStyleService::bust()). php artisan fonts:localize runs sync(). |
resources/views/pages/dashboard/settings/partials/font-browser.blade.php |
The categorised font picker partial used by body / heading / heading-accent. |
Settings reference
| Key | Default | Notes |
|---|---|---|
branding.typography.body_font |
'instrument-sans' |
Slug from BrandingStyleService::FONTS. 'system-sans' for zero load. |
branding.typography.heading_font |
'instrument-sans' |
Same slug pool. |
branding.typography.heading_accent_font |
'' |
Optional. Empty = no accent font. |
branding.typography.antialiasing_mode |
'smooth' |
One of smooth, subpixel, auto. |
branding.typography.heading_defaults.{h1..h4}.size |
text-5xl / text-4xl / text-3xl / text-2xl |
Tailwind size token. |
branding.typography.heading_defaults.{h1..h4}.weight |
font-semibold |
Tailwind weight token (font-thin..font-black). |
branding.typography.heading_defaults.{h1..h4}.letter_spacing |
'' |
Empty = no rule. Otherwise tracking-tighter..tracking-widest. |
branding.typography.heading_defaults.{h1..h4}.line_height |
'' |
Empty = no rule. Otherwise leading-none..leading-loose. |
branding.typography.heading_defaults.{h1..h4}.text_wrap |
'' |
Empty / text-pretty / text-balance. |
branding.typography.heading_defaults.{h1..h4}.classes |
'' |
Free-form Tailwind override field (autocomplete-assisted). |
branding.typography.body_defaults.size |
text-base |
|
branding.typography.body_defaults.weight |
font-light |
|
branding.typography.body_defaults.text_wrap |
'' |
Empty / text-pretty. |
branding.typography.body_defaults.classes |
'' |
Free-form Tailwind override field. |