# Properties Over Raw Font Tags

**MUST** · **ID:** `content-font-properties-over-tags` · **Category:** content
**Source:** [jakubkrehel](https://github.com/jakubkrehel/skills)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/content-font-properties-over-tags

> MUST: Use the CSS PROPERTY whenever one exists — `font-weight: 650`, `font-optical-sizing: auto`, `font-variant-numeric: tabular-nums` — not `font-variation-settings: "wght" 650` / `font-feature-settings: "tnum" 1`. Raw-tag declarations address axes that only exist inside the variable file, so when a non-variable fallback renders they SILENTLY DO NOTHING (weight collapses to 400, no console error). And `font-feature-settings` is ONE property, so a later declaration CLOBBERS an earlier one — add `"ss01" 1` to a rule that carried `"tnum" 1` and the tabular figures leave with it. Reserve the raw tags for custom axes (`"GRAD" 80`) and niche features (`"ss01" 1`) that have no property of their own.

Reach for font-weight and font-variant-numeric, not font-variation-settings and font-feature-settings

This is an API rule, not a typography rule, and it is the one people get wrong precisely because both spellings look equivalent while the variable font is loading fine. They are not. `font-variation-settings: "wght" 650` addresses an axis that only exists inside a variable file, so the moment the fallback stack renders — the file 404s, the CDN is blocked, the user is on a metered connection with fonts disabled — the declaration is not overridden, it is inert. The weight collapses to 400, the console says nothing, and the semibold heading you designed is now body copy. `font-weight: 650` is a real property that every font stack understands: the browser picks the closest available face and the hierarchy survives. The same asymmetry applies below the surface for features: `font-feature-settings` is a single low-level property rather than a set of independent switches, so the day someone adds `"ss01" 1` to a rule that already carried `"tnum" 1`, the tabular figures leave with it and the ticker starts jittering again. `font-variant-numeric: tabular-nums` cannot be clobbered that way, because it lives on its own property. Note this is orthogonal to content-tabular-numbers, which says *what* to apply and never *which API* — this is the rule that picks the API. Raw tags are not banned; they are the escape hatch, and the only correct use of them, for custom axes like `"GRAD" 80` and niche features like `"ss01" 1` that have no property of their own.

## Rule snippet

```tsx
/* ✗ inert under a non-variable fallback; "tnum" clobbered by the next rule */
.price { font-variation-settings: "wght" 650; font-feature-settings: "tnum" 1; }
/* ✓ real properties, survive the fallback, cannot clobber each other */
.price { font-weight: 650; font-variant-numeric: tabular-nums; font-feature-settings: "ss01" 1; }
```

## Bad — do not do this

`content-font-properties-over-tags-bad`

```tsx
import { useEffect, useState } from 'react';

/**
 * Bad: the low-level tags are used where a real CSS property exists.
 *
 * 1. The heading sets `font-variation-settings: "wght" 650`. The variable file
 *    failed to load, so the static fallback stack renders — and on a static font
 *    the `wght` axis does not exist. The declaration is not overridden, it is
 *    simply inert: the heading collapses to 400 and no one gets an error.
 * 2. The ticker asked for tabular figures with `font-feature-settings: "tnum" 1`,
 *    then a later commit added a stylistic set the same way. `font-feature-settings`
 *    is ONE property, not a list of independent switches, so `"ss01" 1` replaced
 *    `"tnum" 1` wholesale. The digits went proportional again and the "USD" after
 *    them jitters on every tick.
 */

const PRICES = ['1,118.40', '911.05', '1,041.11', '888.19', '1,180.00', '999.71'];

export function FontPropertiesOverTagsBad() {
  const [i, setI] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setI((n) => (n + 1) % PRICES.length), 900);
    return () => clearInterval(id);
  }, []);

  return (
    <div className="w-full space-y-4">
      <div className="rounded-lg border border-destructive/40 bg-card p-3">
        <p className="mb-2 text-xs font-medium text-destructive">
          Heading — weight via the raw axis tag, on a non-variable fallback
        </p>
        {/* The variable file never loaded; Georgia has no wght axis, so this does nothing. */}
        <p
          className="text-2xl text-foreground"
          style={{
            fontFamily: '"AcmeSans Variable", Georgia, serif',
            fontVariationSettings: '"wght" 650',
          }}
        >
          Quarterly revenue
        </p>
        <p className="mt-2 text-xs text-muted-foreground">
          It renders at 400. <code>font-variation-settings</code> silently does nothing on the
          fallback — the semibold heading you designed is now indistinguishable from body copy.
        </p>
      </div>

      <div className="rounded-lg border border-destructive/40 bg-card p-3">
        <p className="mb-2 text-xs font-medium text-destructive">
          Ticker — numerics via the raw feature tag, clobbered by the next feature
        </p>
        <p
          className="text-2xl text-foreground"
          /* Was '"tnum" 1'. Someone added the stylistic set the same way, and since this
             is a single property, the tabular figures went with it. */
          style={{ fontFeatureSettings: '"ss01" 1' }}
        >
          {PRICES[i]} <span className="text-base text-muted-foreground">USD</span>
        </p>
        <p className="mt-2 text-xs text-muted-foreground">
          Watch the <code>USD</code>: proportional digits have different widths, so it slides left
          and right on every tick. Both declarations are still sitting in the stylesheet, doing
          nothing anyone can see.
        </p>
      </div>
    </div>
  );
}
```

## Good — do this

`content-font-properties-over-tags-good`

```tsx
import { useEffect, useState } from 'react';

/**
 * Good: use the CSS property whenever one exists.
 *
 * 1. `font-weight: 650` on the heading. The variable file still failed to load and
 *    the static fallback still renders — but `font-weight` is a real property, so
 *    the browser picks the closest available face and the heading stays heavy.
 * 2. `font-variant-numeric: tabular-nums` on the ticker. It is its own property, so
 *    adding the stylistic set with `font-feature-settings: "ss01" 1` cannot clobber
 *    it — the two coexist.
 *
 * Raw tags are not banned; they are the escape hatch for things with no property of
 * their own: custom axes like `"GRAD" 80`, and niche features like `"ss01" 1`.
 */

const PRICES = ['1,118.40', '911.05', '1,041.11', '888.19', '1,180.00', '999.71'];

export function FontPropertiesOverTagsGood() {
  const [i, setI] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setI((n) => (n + 1) % PRICES.length), 900);
    return () => clearInterval(id);
  }, []);

  return (
    <div className="w-full space-y-4">
      <div className="rounded-lg border border-border bg-card p-3">
        <p className="mb-2 text-xs font-medium text-success">
          Heading — weight via the property, surviving the same fallback
        </p>
        <p
          className="text-2xl text-foreground"
          style={{ fontFamily: '"AcmeSans Variable", Georgia, serif', fontWeight: 650 }}
        >
          Quarterly revenue
        </p>
        <p className="mt-2 text-xs text-muted-foreground">
          Same broken font load, same fallback stack. <code>font-weight: 650</code> still resolves —
          the fallback&apos;s bold face renders instead of nothing.
        </p>
      </div>

      <div className="rounded-lg border border-border bg-card p-3">
        <p className="mb-2 text-xs font-medium text-success">
          Ticker — <code>tabular-nums</code> as a property, plus a stylistic set alongside it
        </p>
        <p
          className="text-2xl tabular-nums text-foreground"
          /* Legitimate escape hatch: "ss01" has no property of its own. It sits on a
             different property from the numerics, so nothing gets overwritten. */
          style={{ fontFeatureSettings: '"ss01" 1' }}
        >
          {PRICES[i]} <span className="text-base text-muted-foreground">USD</span>
        </p>
        <p className="mt-2 text-xs text-muted-foreground">
          Every digit shares one advance width, so <code>USD</code> never moves. The rule of thumb:
          property first, raw tag only when there is no property to reach for.
        </p>
      </div>
    </div>
  );
}
```

## References

- [jakubkrehel — better-typography SKILL.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/SKILL.md)
- [jakubkrehel — variable-fonts-and-opentype.md](https://raw.githubusercontent.com/jakubkrehel/skills/main/skills/better-typography/variable-fonts-and-opentype.md)
- [MDN — font-variant-numeric](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-variant-numeric)
- [MDN — font-weight](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/font-weight)
