HomeJournalThis post

OpenType Numerals for Better Data Tables

Choose proportional, tabular, lining, and oldstyle figures by product role, then encode and verify the result with resilient CSS.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

OpenType numerals solve different reading problems: some align values for comparison, while others sit naturally inside prose. This guide turns those typographic choices into a product-role matrix and resilient font-variant-numeric CSS without pretending every font contains every alternate glyph.

Start with the reading task, not the feature tag

OpenType numerals solve different reading jobs. A price column asks the eye to compare vertical magnitudes; a paragraph asks numbers to join the rhythm of words; an identifier asks similar characters to stay distinguishable; and a fraction asks numerator and denominator to read as one expression. One global setting cannot optimize all four.

Begin by naming the product role before choosing a feature. Use comparison for tables, live metrics, timers, and counters; narrative for dates and quantities in prose; identifier for account codes and hashes; and fraction for authored ratios. That role becomes a component token and a QA promise rather than a one-off CSS trick.

The key distinction is that CSS requests alternates already present in a font. OpenType numerals do not manufacture missing glyphs, and successful syntax does not prove that a face or fallback supplies tnum, onum, or fraction forms. The visual check belongs beside the code review.

Interview the content before naming the token. Ask whether the number changes in place, whether neighboring values must compare, whether people copy it, and whether punctuation carries locale meaning. A scorecard built from those answers gives design critique and frontend QA the same vocabulary, which is far more durable than “make the numbers feel technical.” Record the answer beside the component example.

This role-first approach complements type scale and rhythm foundations without turning numeral choice into a universal typography rule. Measure the actual strings, locales, weights, and fallback states used by the product.

Separate figure style from figure width

Figure style and spacing are independent axes. Lining figures generally share the cap-height posture of uppercase text, while oldstyle figures vary around the x-height and may ascend or descend. Proportional figures use glyph-specific advances; tabular figures use uniform advances so columns can align.

The OpenType 1.9.1 feature registry defines pnum as proportional widths and tnum as tabular widths, including their interaction with default or alternate numeral forms. The request still depends on font data: two fonts may support the tag yet draw very different shapes.

That gives four conceptual combinations: proportional lining, tabular lining, proportional oldstyle, and tabular oldstyle. Not every family ships all four. OpenType numerals should therefore be specified as a preferred pair plus an acceptable fallback, not as evidence that the requested glyphs appeared.

The specimen makes the axes visible. It places 111.11, 808.80, and 974.26 on shared right-edge rails, then compares baseline, x-height, cap-height, and descender guides. A stable table needs equal advances; comfortable prose may benefit from varied widths and a text-like figure style.

Use the phrase tabular numerals for the spacing job, not as a synonym for every figure setting. A table can use tabular oldstyle forms, and prose can use proportional lining forms, even if those combinations are less common in a particular system. Naming both axes prevents a design token from hiding an accidental dependency on one font’s defaults.

Proportional and tabular width railsThe same three decimal values occupy uneven proportional advances and equal tabular cells while sharing a marked right edge.PROPORTIONALTABULAR111.11111.11808.80808.80974.26974.26
Right-edge rails reveal why comparison columns prefer equal advances while narrative figures can retain proportional rhythm.
Numeral width roles
ValuesRoleWidth requirement
111.11, 808.80, 974.26Comparison columnTabular advances and shared right edge
The same values in a sentenceNarrative proseProportional advances may improve rhythm

Use tabular lining figures for changing metrics

For rapidly changing product data, start with font-variant-numeric: tabular-nums lining-nums. The uniform advances stop 1,111.11 from occupying dramatically less space than 8,888.88, while lining figures hold a steady uppercase-like silhouette. That reduces horizontal jitter in counters and makes right-aligned columns easier to scan.

Alignment includes more than digits. Signs, decimal separators, currency symbols, suffixes, and localized grouping can still change width. Format the semantic value with Intl.NumberFormat, keep units in their own cells when comparison requires it, and decide whether a sign column or accounting layout better serves the task.

OpenType numerals cannot repair a table whose values are left aligned, clipped, or ambiguously rounded. The broader patterns in admin tables users trust still govern labels, precision, empty states, sorting, and consequences. Numeral features refine that structure.

Test realistic change sequences, not a still screenshot. The included fixture cycles through 1,111.11, 8,888.88, −42.50, and a locale-formatted 1.234,56; it flags a comparison role assigned proportional spacing. The test proves the role mapping and stable markup, while browser inspection proves actual width behavior.

Keep proportional oldstyle figures in reading text

Narrative text has a different priority. A year, measurement, or count should join the texture of the sentence rather than behave like a mini spreadsheet. When the chosen family includes them and the editorial voice benefits, oldstyle-nums proportional-nums can make figures sit more naturally among lowercase letters.

This is not a command to use oldstyle figures for every paragraph. Legal text, scientific notation, mixed scripts, or a brand face with weak oldstyle forms may call for lining figures. Compare a representative paragraph at the actual size and weight, then preserve the content order when styling disappears.

OpenType numerals also interact with fallback. During font loading, the browser may briefly render a system face whose defaults, advances, or supported alternates differ. Check both the loading and loaded state, and choose a fallback stack whose numeral metrics do not turn reflow into a product-level distraction.

Treat the choice like variable font grade versus weight: CSS exposes an instruction, while the font supplies the actual behavior. Document the selected family, verified weights, specimen strings, and acceptable fallback rather than claiming that a computed style guarantees a glyph.

Lining and oldstyle numeral anatomyTwo separated specimen rows compare lining figures between cap height and baseline with oldstyle figures around x-height and descender guides.CAPBASELINEX-HEIGHTBASELINEDESCENDER12371237LININGOLDSTYLE
Separate rows make the vertical posture comparison visible without placing one specimen on top of the other.
  • Lining: the illustrative 1, 2, 3, and 7 share an uppercase-like vertical field.
  • Oldstyle: the illustrative forms vary around the x-height.
  • Descenders: 3 and 7 descend in this diagram; actual shapes depend on the selected font.
  • Support: CSS requests alternates but does not prove the font contains them.

Treat identifiers and fractions as separate jobs

Identifiers are read for recognition, not magnitude. An order code such as O0I1-5070 benefits from stable grouping, generous tracking, and—when the font has it—a slashed zero. Avoid applying oldstyle figures merely because the identifier appears near prose; its job is disambiguation.

Fractions require narrow scope. diagonal-fractions or stacked-fractions can transform a supported numerator, slash, and denominator, but a broad rule may accidentally style dates, version numbers, or slash-separated IDs. Wrap the authored fraction in an explicit span and keep its accessible text as ordinary characters.

The MDN implementation reference for font-variant-numeric maps semantic values to OpenType tags and documents combinable syntax. It is implementation guidance, not proof that a selected face contains every alternate. Visual inspection remains required.

OpenType numerals should preserve meaning when unsupported. An identifier must remain copyable, a fraction must retain its source text, and assistive technology should encounter the same characters in logical order. Styling can improve recognition, but it must never become the only carrier of the value.

Prefer semantic CSS over low-level tags

Use font-variant-numeric for product roles because its values say what the interface needs. The W3C CSS Fonts Level 4 numerical formatting draft defines the semantic combinations and their OpenType mappings. A token such as --numeral-comparison: tabular-nums lining-nums communicates intent more clearly than a list of four-letter feature switches, and it lets the cascade combine settings without replacing unrelated low-level features accidentally.

Keep the mapping close to components: [data-numerals="comparison"] for table cells and counters, narrative for editorial spans, identifier for codes, and fraction only for explicit fraction markup. Design tokens can encode the role while each component owns alignment and formatting.

Reserve font-feature-settings for a verified feature that has no higher-level CSS equivalent or for a carefully isolated compatibility case. Low-level settings are not a stronger proof of support. Record the font file/version and include a visual regression specimen when exact output is contractually important.

The artifact accepts an anchored whole-string grammar for named families and an explicit generic-family allowlist, then quotes named families with a CSS-string serializer before producing HTML. It rejects arbitrary declarations, delimiters, unknown roles, and mutually exclusive pairs. OpenType numerals remain progressive enhancement: safe source text first, role class second, supported alternate third.

Runnable artifact — Proves role mapping, safe generation, and stable markup; browser and font inspection prove glyph availability and visual metrics.

JavaScript18 lines
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";

const MAX=65536,groups=[["lining-nums","oldstyle-nums"],["proportional-nums","tabular-nums"],["diagonal-fractions","stacked-fractions"]],fractionFeatures=new Set(groups[2]),known=new Set(groups.flat().concat(["slashed-zero","ordinal","normal"])),roleNames=["comparison","narrative","identifier","fraction"],genericFamilies=new Set(["serif","sans-serif","monospace","cursive","fantasy","system-ui","ui-serif","ui-sans-serif","ui-monospace","emoji","math","fangsong"]),cssWideKeywords=new Set(["inherit","initial","revert","revert-layer","unset"]),familyNameGrammar=/^(?=.{1,64}$)[A-Za-z0-9](?:[A-Za-z0-9 ._-]*[A-Za-z0-9])?$/;
const builtIn={fontFamily:"Inter, system-ui, sans-serif",locales:["en-US","de-DE"],roles:{comparison:{features:["lining-nums","tabular-nums"],values:["1111.11","8888.88","-42.50"]},narrative:{features:["oldstyle-nums","proportional-nums"],values:["In 2026, 18 studies were reviewed."]},identifier:{features:["lining-nums","tabular-nums","slashed-zero"],values:["O0I1-5070"]},fraction:{features:["lining-nums","proportional-nums","diagonal-fractions"],values:["3/8"]}},desiredComparisonBehavior:"stable-right-edge"};
const esc=value=>String(value).replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;").replaceAll('"',"&quot;").replaceAll("'","&#39;"),hash=value=>createHash("sha256").update(value).digest("hex");
const wholeFontFamilySafe=value=>familyNameGrammar.test(value)&&!cssWideKeywords.has(value.toLowerCase());
function parseFontFamily(value){if(typeof value!=="string"||value.length<1||value.length>160||value.trim()!==value||/[\u0000-\u001f\u007f-\u009f]/.test(value))throw new Error("unsafe-font-family");const parts=value.split(",").map(part=>part.trim());if(parts.length<1||parts.length>6||parts.some(part=>!wholeFontFamilySafe(part)))throw new Error("unsafe-font-family");return parts.map(name=>({name,generic:genericFamilies.has(name.toLowerCase())}));}
function cssString(value){return '"'+Array.from(value,char=>{const code=char.codePointAt(0);if(char==='"'||char==="\\"||char==="<"||char===">"||char==="&"||code<32||code===127)return"\\"+code.toString(16)+" ";return char;}).join("")+'"';}
const serializeFontFamily=parts=>parts.map(part=>part.generic?part.name.toLowerCase():cssString(part.name)).join(", ");
function parse(){const args=process.argv.slice(2);if(args.length===0)return structuredClone(builtIn);if(args.length!==2||args[0]!=="--fixture")throw new Error("invalid-arguments");const input=readFileSync(args[1]);if(input.length>MAX)throw new Error("fixture-too-large");try{return JSON.parse(input.toString("utf8"));}catch{throw new Error("malformed-fixture");}}
function validate(fixture){if(!fixture||typeof fixture!=="object"||Array.isArray(fixture))throw new Error("invalid-fixture");const fixtureKeys=new Set(["fontFamily","locales","roles","desiredComparisonBehavior"]);if(Object.keys(fixture).some(key=>!fixtureKeys.has(key)))throw new Error("unknown-fixture-member");const fontFamilies=parseFontFamily(fixture.fontFamily);if(fixture.desiredComparisonBehavior!=="stable-right-edge")throw new Error("unsupported-comparison-behavior");if(!Array.isArray(fixture.locales)||fixture.locales.length<1||fixture.locales.length>8||new Set(fixture.locales).size!==fixture.locales.length||fixture.locales.some(value=>typeof value!=="string"||value.length>35))throw new Error("invalid-locales");try{if(Intl.NumberFormat.supportedLocalesOf(fixture.locales).length!==fixture.locales.length)throw new Error("unsupported");}catch{throw new Error("unsupported-locale");}if(!fixture.roles||typeof fixture.roles!=="object"||Array.isArray(fixture.roles)||Object.keys(fixture.roles).length!==roleNames.length||Object.keys(fixture.roles).some(role=>!roleNames.includes(role)))throw new Error("unknown-role");
for(const role of roleNames){const item=fixture.roles[role];if(!item||typeof item!=="object"||Array.isArray(item)||Object.keys(item).some(key=>!["features","values"].includes(key))||!Array.isArray(item.features)||!Array.isArray(item.values)||item.features.length<1||item.features.length>8||new Set(item.features).size!==item.features.length||item.values.length<1||item.values.length>20)throw new Error("invalid-role:"+role);if(item.features.some(feature=>typeof feature!=="string"||!known.has(feature)))throw new Error("unknown-feature:"+role);if(item.features.includes("normal")&&item.features.length!==1)throw new Error("normal-is-exclusive:"+role);for(const pair of groups)if(item.features.filter(feature=>pair.includes(feature)).length>1)throw new Error("mutually-exclusive:"+role);if(role!=="fraction"&&item.features.some(feature=>fractionFeatures.has(feature)))throw new Error("fraction-feature-outside-fraction-role:"+role);if(item.values.some(value=>typeof value!=="string"||Buffer.byteLength(value)>512))throw new Error("invalid-value:"+role);}
if(!fixture.roles.comparison.features.includes("tabular-nums")||fixture.roles.comparison.features.includes("proportional-nums"))throw new Error("comparison-width-unstable");if(!fixture.roles.fraction.features.some(feature=>fractionFeatures.has(feature)))throw new Error("fraction-feature-required");if(fixture.roles.fraction.values.some(value=>!/(?:^|\D)\d+\/\d+(?:\D|$)/.test(value)))throw new Error("fraction-not-explicit");return{fixture:structuredClone(fixture),fontFamilies};}
const validated=validate(parse()),fixture=validated.fixture,fontFamilies=validated.fontFamilies,cssFontFamily=serializeFontFamily(fontFamilies),classes=Object.entries(fixture.roles).map(([role,item])=>".numerals-"+role+"{font-variant-numeric:"+item.features.join(" ")+";}").join("\n"),rows=Object.entries(fixture.roles).map(([role,item])=>"<section aria-labelledby=\"role-"+role+"\"><h2 id=\"role-"+role+"\">"+esc(role)+"</h2><p class=\"numerals-"+role+"\">"+item.values.map(esc).join(" · ")+"</p><code>"+esc(item.features.join(" "))+"</code></section>").join("\n"),localeSamples=fixture.locales.map(locale=>({locale,formatted:new Intl.NumberFormat(locale,{minimumFractionDigits:2,maximumFractionDigits:2}).format(1234567.89)})),localeRows=localeSamples.map(item=>"<li><code>"+esc(item.locale)+"</code>: "+esc(item.formatted)+"</li>").join(""),html="<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width\"><title>OpenType numeral role specimen</title><style>body{font-family:"+cssFontFamily+";max-width:70rem;margin:auto;padding:2rem}section{border-block-start:1px solid;padding:1rem}p{font-size:2rem}.numerals-comparison{text-align:right}"+classes+"</style><main><h1>OpenType numeral role specimen</h1>"+rows+"<section aria-labelledby=\"locale-exercise\"><h2 id=\"locale-exercise\">Locale punctuation exercise</h2><ul>"+localeRows+"</ul></section><p><strong>Verification:</strong> role grammar, escaping, and locale formatting passed. Actual glyph support and metrics require visual browser inspection of this font and every fallback.</p></main></html>";
const normalized={fontFamily:fixture.fontFamily,cssFontFamily,locales:[...fixture.locales],desiredComparisonBehavior:fixture.desiredComparisonBehavior,roles:Object.fromEntries(Object.entries(fixture.roles).map(([role,item])=>[role,{features:[...item.features],values:[...item.values]}]))},checks={mutualExclusion:Object.values(fixture.roles).every(item=>groups.every(pair=>item.features.filter(feature=>pair.includes(feature)).length<=1)),normalGrammarValid:Object.values(fixture.roles).every(item=>!item.features.includes("normal")||item.features.length===1),comparisonUsesTabular:fixture.roles.comparison.features.includes("tabular-nums")&&!fixture.roles.comparison.features.includes("proportional-nums"),fractionScopedToExplicitRole:roleNames.filter(role=>role!=="fraction").every(role=>fixture.roles[role].features.every(feature=>!fractionFeatures.has(feature)))&&fixture.roles.fraction.features.some(feature=>fractionFeatures.has(feature)),localesExercised:localeSamples.length===fixture.locales.length&&localeSamples.every(item=>item.formatted.length>0),fontFamilyGrammarValid:fontFamilies.length>0&&fontFamilies.every(part=>wholeFontFamilySafe(part.name)),cssFontFamilySerialized:cssFontFamily===serializeFontFamily(fontFamilies)&&!/[<>\r\n]/.test(cssFontFamily)&&!/@|\/\*/.test(cssFontFamily),escapedMarkup:Object.values(fixture.roles).flatMap(item=>item.values).every(value=>html.includes(esc(value)))&&!/<script(?:\s|>)/i.test(html),standaloneHtmlSafe:(html.match(/<\/style\s*>/gi)||[]).length===1&&!/javascript\s*:/i.test(html)};
for(const name of ["fontFamilyGrammarValid","cssFontFamilySerialized","escapedMarkup","standaloneHtmlSafe"])if(checks[name]!==true)throw new Error("safety-invariant-failed:"+name);
const receipt={schema:"opentype-numeral-specimen-v3",fixtureDigest:hash(JSON.stringify(normalized)),fontFamily:{input:fixture.fontFamily,serialized:cssFontFamily,families:fontFamilies},roles:normalized.roles,localeSamples,checks,glyphSupport:"visual-browser-inspection-required",html,claimBoundary:"Proves whole-string font-family validation, CSS string serialization, role grammar, escaping, locale exercise, and stable markup; not actual glyph availability or visual metrics."};console.log(JSON.stringify({...receipt,receiptHash:hash(JSON.stringify(receipt))},null,2));

Verify the actual font and fallback stack

A complete check uses content that can expose failure. Include narrow and wide digits, zero beside capital O, one beside capital I, positive and negative decimals, grouping separators for at least two locales, rapidly changing counters, and a scoped fraction. Render every supported weight because feature coverage can vary by font file.

Inspect computed styles, but do not stop there. Compare advances with guides, watch for layout shift during font loading, zoom to 200%, use forced colors, and verify that copied text is unchanged. If a webfont fails, repeat the specimen against every real fallback rather than a convenient local substitute.

Capture the specimen at the narrowest table width and at the largest supported text zoom. Long currency labels and localized separators can reveal collisions that a clean desktop row conceals. The evidence should identify the browser, font file, weight, locale, and loading state so a future typography update can reproduce the decision instead of relying on memory.

OpenType numerals are especially easy to overclaim because unsupported alternates often fail quietly. The receipt therefore says glyphSupport: visual-inspection-required even when the role contract passes. A screenshot and font metadata can establish the chosen build's appearance; CSS parsing alone cannot.

Check adjacent geometry too. Baseline alignment, row height, and optical box edges can make correct figures look wrong, which is where CSS text-box-trim optical alignment becomes relevant. Keep those layout findings separate from the numeral-feature verdict.

Ship an OpenType numerals role contract

A small role contract is easier to maintain than scattered feature tags. For each role, record preferred style, preferred spacing, optional extras, alignment, representative values, fallback expectation, and failure signs. The product-role scorecard in this article makes those decisions reviewable across design and engineering.

Comparison prefers lining plus tabular figures and flags unstable right edges. Narrative may prefer oldstyle plus proportional figures and flags an awkward mismatch with lowercase text. Identifier keeps readable lining forms with optional slashed zero; fraction scopes one supported fraction style to authored spans and flags accidental transformations elsewhere.

Run the downloadable specimen with two real surfaces: a production table and a paragraph containing dates and quantities. Confirm that its escaped standalone HTML contains no remote script, then inspect the selected font in target browsers. Promote the passing roles into component-level CSS tokens, not a universal body rule.

That is the durable value of OpenType numerals: a documented relationship between reading task, semantic CSS, actual font evidence, and fallback behavior. The contract remains honest when the font changes because it states what to recheck instead of pretending one property settled typography forever.

Product-role numeral scorecardFour quadrants connect comparison, narrative, identifier, and fraction jobs to preferred CSS, fallback checks, and visible failure signs.ROLECONTRACTTABLEPROSEIDENTIFIERFRACTIONtabular + liningproportional + styledistinguish 0/Oscope explicit span
The scorecard makes one role own each numeral choice, its fallback inspection, and its failure sign.
Comparison
Tabular lining figures; check right-edge stability and locale punctuation.
Narrative
Proportional figures with a verified style; check lowercase rhythm and fallback reflow.
Identifier
Readable lining forms with optional slashed zero; check 0/O and 1/I recognition.
Fraction
One supported fraction feature on an explicit span; check that dates and IDs remain untouched.