Version 0.4.0 now released! See the release notes on GitHub Releases

→ Register a font once and reference it everywhere, or pull in any external stylesheet.

Fonts & Imports

Two small APIs live on this page, and they solve the same shape of problem from opposite ends: getting CSS that isn't a styled component into your build. defineFont registers a font — local files or a hosted stylesheet — and hands you back something you can drop straight into a style object. defineImport pulls in any external CSS you already have: a vendor reset, a print stylesheet, a syntax-highlight theme, a Google Fonts URL.

Both are define* factories, so the rules are the ones you already know from the rest of Salty: write them in a *.css.ts file, export them, and the compiler folds the result into your single static stylesheet at build time. Nothing here ships a runtime.

One honest note up front, because it sets expectations for the whole page: defineFont writes correct @font-face CSS — that's the job it's built to do, and the whole job. If you've used next/font, you've had subsetting and automatic preloading done for you behind the scenes. Salty doesn't do that. It gives you a clean, modular way to declare the font and reference it everywhere; the loading-performance best practices stay yours to own, and there's a section below on exactly which ones matter.

If you want the deeper why behind build-time extraction, The compiler is the read.

How the two APIs fit

defineFont and defineImport both take CSS that originates outside your component styles and give it a home inside the build. The difference is how much Salty knows about what you handed it:

  • defineFont knows it's a font. So beyond emitting the @font-face (or @import, for a hosted sheet), it exposes a CSS variable, a class name, and a font-family value you can reference by token — the modular part. You register the font once and consume it however each call site needs.
  • defineImport knows nothing about the file. It just emits an @import line and gets out of the way — the least clever function in the library, and proud of it. That's the point of it: a plain, logical "pull this stylesheet in" function for the cases defineFont doesn't cover.

There's deliberate overlap at the edges — a Google Fonts link is just a stylesheet, so you could load it with either. The rule of thumb: if it's a font you'll reference in your styles, reach for defineFont so you get the variable and token back. If it's CSS you only need present — a reset, a third-party widget's styles — reach for defineImport.

Where they land in the cascade matters, and it's worth saying once: anything from defineImport goes into @layer imports, the earliest layer Salty declares (the full order is imports, reset, global, templates, fonts, l0…l8). External CSS therefore loses to anything you write with styled or className, regardless of selector specificity — and, symmetrically, your imports can't be accidentally overridden by your own component styles either. More on that at the end.

Example 1 — A local font with defineFont

Start with self-hosted font files, since that's where defineFont earns its keep.

How you define it

Pass a variants array — one entry per @font-face rule. Each entry is a source plus the descriptors that tell the browser what that file is:

/styles/fonts.css.ts
import { defineFont } from "@salty-css/core/factories";

export const inter = defineFont({
  name: "Inter",
  fallback: "system-ui, sans-serif",
  display: "swap",
  variants: [
    { src: "/fonts/Inter-Regular.woff2", weight: 400, style: "normal" },
    { src: "/fonts/Inter-Italic.woff2",  weight: 400, style: "italic" },
    { src: "/fonts/Inter-Bold.woff2",    weight: 700, style: "normal" },
  ],
});

A few things worth knowing about the inputs:

  • src takes a string URL, a { url, format?, tech? } object, or an array mixing the two. Given a string, Salty detects the format() from the file extension (woff2, woff, ttf, otf, …). A /-prefixed path resolves against your app's public root; a relative path resolves against the emitted rule — pick one convention and stay with it.
  • fallback is appended to the generated font-family, so the value you consume is already "Inter, system-ui, sans-serif" — the fallback renders while the web font loads.
  • display defaults to "swap". Set it here for every variant, or per-variant to override.

How you use it

defineFont returns an object with four members, and it stringifies to .fontFamily — so the simplest usage is to drop the whole thing into a style object:

/components/heading.css.ts
import { styled } from "@salty-css/react/styled";
import { inter } from "../styles/fonts.css";

export const Heading = styled("h1", {
  base: {
    fontFamily: inter,   // → "Inter, system-ui, sans-serif"
    fontWeight: 700,
  },
});

The other three members are there for when you want something more specific than the raw family value:

MemberWhat it isReach for it when…
.fontFamilythe font-family string (fallback appended)you want the plain value — this is what the object stringifies to.
.variablethe CSS custom property, e.g. --font-inter-abc123you want to read var(--font-inter-…) so it can be themed or overridden downstream.
.classNamea class string, e.g. font-interyou want to apply the font to a whole subtree by toggling one class.
.stylean object to spread onto a style propyou want it inline on a single element.

So the same registration covers a component that reads the variable:

Example
import { inter } from "../styles/fonts.css";

export const Body = styled("p", {
  base: { fontFamily: `var(${inter.variable})` },
});

…and a wrapper that scopes the font to everything beneath it without touching a single child component:

Example
import { inter } from "./styles/fonts.css";

export const Page = ({ children }) => (
  <main className={inter.className}>{children}</main>
);

Example 2 — A hosted font, and the token that makes it swappable

Two ideas here: loading a font that already ships its own stylesheet, and the modular pattern that stops your components from ever naming a specific font.

A remote stylesheet

When the font is hosted somewhere that already serves a @font-face sheet — Google Fonts being the obvious case — use import instead of variants. Salty emits the @import url(...) so the remote rules load correctly:

/styles/fonts.css.ts
import { defineFont } from "@salty-css/core/factories";

export const outfit = defineFont({
  name: "Outfit",
  fallback: "system-ui, sans-serif",
  import:
    "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap",
});

variants and import are mutually exclusive — one or the other, never both. The returned object is identical either way, so outfit consumes exactly like inter did above.

The pragmatic part: a fontFamily token

Here's the modular move. Pair defineFont with defineVariables so your components reference a rolebody, heading — not a font import:

/styles/fonts.css.ts
import { defineFont, defineVariables } from "@salty-css/core/factories";

export const inter = defineFont({
  name: "Inter",
  fallback: "system-ui, sans-serif",
  variants: [{ src: "/fonts/Inter-Regular.woff2", weight: 400 }],
});

export default defineVariables({
  fontFamily: {
    body: inter.fontFamily,
    heading: inter.fontFamily,
  },
});

Now components read the token, and never the font:

Example
export const Body = styled("p", { base: { fontFamily: "{fontFamily.body}" } });

Swap the whole typeface later by pointing fontFamily.body at a different defineFont — every call site updates, and you didn't have to touch a single component. Same layer of indirection that theming uses for color, applied to type.

Getting @font-face right yourself

This is the section the honest note at the top was pointing at — and it starts with a fork in the road.

If a framework loader like next/font or Astro's font handling would save you real bandwidth — subsetting, automatic preload — or otherwise smooth your workflow, it's worth a look, and you lose nothing by leaning on it. Salty reads the var(--font-…) those loaders expose, so you feed that variable (or the family name) into a fontFamily token exactly like Example 2, and you're done — same modular consumption, someone else's optimization. If you're not gaining anything from them, defineFont on its own is completely fine; don't add a loader just to have one.

But if you'd rather own the font setup yourself — or you're outside a framework that offers a loader — then it's worth doing @font-face well, because Salty writes the rule faithfully but doesn't optimize delivery for you. The good news: "correct @font-face" plus a handful of well-understood practices covers most of the gap, and they map cleanly onto defineFont's options. Mozilla's @font-face reference is the canonical background; the parts that matter most:

  • Serve WOFF2. It compresses better than older formats and is supported across every current browser, which makes it the safe default for self-hosted files. Keep an older format around only if your support matrix genuinely needs it.
  • One @font-face per weight/style, with the descriptors set. This is why variants is an array of individually-described entries rather than a single blob. Give each face its real weight and style so the browser picks the right file — and so it doesn't synthesize a fake bold or italic from the regular file when it can't find a match.
  • Choose font-display deliberately. defineFont defaults to swap (show fallback text immediately, swap in the web font when it arrives). That's a sensible default, but it's a real tradeoff between flash-of-unstyled-text and flash-of-invisible-text — set it per project rather than inheriting it by accident.
  • Preload the critical font yourself. A framework loader might inject a <link rel="preload"> for you; Salty won't. For the one or two fonts above the fold, add the preload tag to your document head by hand so the download starts early.
  • Cut layout shift with the metric overrides. The ascentOverride, descentOverride, lineGapOverride, and sizeAdjust variant fields let you match the fallback's metrics to the web font, so the swap doesn't jump the layout. They're exposed precisely so you can reach for them when a font swap is visibly shifting content.
  • Subset with unicodeRange if you split by script. When you self-host, unicodeRange on a variant lets the browser download a face only when the page actually uses those code points.
  • Mind the origin. Self-hosted font files are subject to the same-origin rule unless you serve them with the right CORS headers — worth checking if a font 404s only when loaded cross-domain.

defineImport — pull in any stylesheet

For CSS that isn't a font and isn't authored in Salty, defineImport is the plain tool. Give each source its own call and its own named export — one import per line reads far better than a pile dumped into a single call:

/styles/imports.css.ts
import { defineImport } from "@salty-css/core/factories";

export const normalize = defineImport("modern-normalize/modern-normalize.css"); // an npm package
export const highlight = defineImport("./vendor/highlight-theme.css");          // relative to this file
export const legacy    = defineImport("/styles/legacy.css");                    // your public/asset root
export const interSheet = defineImport("https://fonts.googleapis.com/css2?family=Inter"); // a remote sheet

Salty picks the file up like any other *.css.ts — register it once (make sure it's reached by the build graph) and the imports appear in the output. The string you pass is handed to your bundler's CSS resolver, so package names, relative paths, public-root paths, and full URLs all resolve the way they would in a hand-written @import.

One thing to watch before you reach for that first import: Salty already ships its own reset. Pulling in a widely-used normalization or reset stylesheet — modern-normalize and friends — on top of it means two resets fighting over the same base rules. Use a single normalization for the job; don't stack several unless you already have a deliberate reason to. And if the third-party reset is the one you want, turn Salty's off with defineConfig({ reset: 'none' }) so they don't both apply.

Conditional imports

For media- or feature-gated sheets, pass an object instead of a string — same one-per-export shape:

Example
export const printStyles = defineImport({ url: "./print.css", media: "print" });
export const wideGamut   = defineImport({ url: "./wide-gamut.css", supports: "color(display-p3 1 1 1)" });

media and supports translate to the matching @import descriptor, so the print sheet only applies when printing and the wide-gamut sheet only where the browser supports it.

Where imports sit in the cascade

Everything from defineImport lands in @layer imports, declared before every other Salty layer. The practical effect: an imported stylesheet can't accidentally win against a rule you wrote with styled or className, no matter how specific its selectors are. If you actually need a third-party rule to win, override the specific properties in your own styles, or bump the relevant Salty rule's priority.