Variable Font Grade vs Weight for UI
Choose variable-font grade or weight by interface job, using pinned-font measurements for width, wrapping, CSS settings, and fallback behavior.
Variable font grade vs weight separates optical compensation from semantic emphasis in interface typography. This guide measures both axes on a pinned Roboto Flex asset, then designs an honest missing-axis fallback.
Variable font grade vs weight separates two jobs
Variable font grade vs weight is a choice between optical color and semantic hierarchy. Weight commonly communicates emphasis and participates in the registered wght axis through font-weight. Grade, when a font provides a GRAD axis, can adjust stroke color with the font designer's intended behavior. It is useful for interface compensation only after the exact font is measured.
Start by naming the job. A heading that must outrank body text calls for weight. Small text that looks faint on a dark surface may be a candidate for grade. Hovering a label should not trigger an accidental line wrap, so grade may be useful if the chosen font keeps the relevant metrics stable. These are separate product intents even when both make letters appear darker.
Do not turn that distinction into a universal law. GRAD is font-specific, is not one of OpenType's registered axes, and may be absent. The exact outlines and metrics are designed by the font maker. The pinned Roboto Flex lab measures identical strings instead of promising every variable font behaves alike. Use a responsive variable-font axis contract as the broader foundation, then add grade only where the interface job and evidence support it.
Address wght and GRAD through CSS
The registered wght axis maps through the font-weight property when the font face declares a matching range. CSS also provides font-variation-settings for lower-level axis control. In a variable font grade vs weight specimen, use font-weight for semantic weight and reserve the four-letter “GRAD” setting for the font-specific grade coordinate.
The CSS Fonts Level 4 draft defines variation selection and the relationship between high-level properties and font-variation-settings. High-level properties can take precedence for registered axes, so setting “wght” in two places can produce confusing tests. Keep one owner for weight, record the computed declarations, and avoid assuming the serialized computed style proves which glyph instance was selected.
For the lab, every row records requested font-weight, requested GRAD, computed font family, computed font weight, computed variation settings, measured width, and line count. That receipt makes the CSS controls inspectable. It still cannot infer perceived contrast from a string. Variable font grade vs weight combines browser evidence with a design review; it does not replace that review with an axis number.
- Grade and weight coordinate plane
- Optical compensation and semantic emphasis occupy separate axes, with a combined quadrant reserved for interfaces that need both.
| Job | Preferred control | Reason |
|---|---|---|
| Semantic emphasis | wght / font-weight | Hierarchy changes |
| Optical compensation | GRAD when verified | Hierarchy stays fixed |
| Both jobs | Separate wght and GRAD tokens | Rationales remain inspectable |
Confirm the exact font axes and ranges
Never enable grade because a family name sounds variable. Pin the font asset, hash it, record its license and source, and verify its metadata. The article bundles the Latin Roboto Flex WOFF2 served by Google Fonts, alongside its OFL 1.1 license text and a SHA-256 digest. The lab waits for document.fonts and fails visibly when the face cannot load.
For this deterministic fixture, Roboto Flex metadata declares GRAD from -200 to 150 and wght from 100 to 1000. The Google Fonts Roboto Flex metadata is the source for those font-specific coordinates. The Google Fonts grade registry record describes intended grade behavior in that ecosystem, including width and line-break invariance, but the lab still measures rather than assumes it.
If the asset digest or range changes, invalidate the old variable font grade vs weight receipt. If a fallback font loads, label the run unsupported; similar-looking text is not evidence that GRAD executed. Font identity is part of the experiment, not decorative metadata.
Measure advance width and line breaks
Use one frozen string at multiple coordinates and one constrained paragraph that can wrap. Measure advance width with Canvas and element width with the DOM after fonts are ready. Record the line count from the element's rendered height and computed line height. Variable font grade vs weight is then a table of observed deltas, not a slogan about stability.
Compare a baseline, positive grade, heavier weight, and a combined setting. Grade passes the layout-stability check only when its measured difference stays inside the declared tolerance for this asset, browser, size, and string. Weight is allowed to change metrics because its job is hierarchy, but the receipt still records what happened. A zero delta is evidence for the specimen, not proof for every glyph sequence.
Text-box alignment is a separate concern; separate glyph color from text-box alignment before attributing vertical rhythm changes to an axis. Keep the type scale and rhythm protected by testing real component widths, translations, and dynamic values. The short lab string is a calibration fixture, not the entire product corpus. For layout-stable bolding, add the component's longest translation, highest dynamic value, and narrowest supported container to that corpus. A coordinate that holds one English label steady can still reflow a longer script or a substituted fallback face.
Review typographic color without pretending universality
Typographic color describes the perceived darkness and texture of a text block. Grade can influence it, but the effect depends on typeface, size, rendering, surface, and reader. A variable font grade vs weight decision therefore needs a visual review with the exact interface, not a numeric threshold that claims to measure taste.
Create side-by-side captures at the same size, line height, text, antialiasing environment, and container. Ask whether the compensated state restores balance without changing hierarchy. Test dark and light themes, ordinary and high-density displays, and the accessibility settings the product supports. Keep the review note qualitative unless a validated measurement instrument is actually used.
Weight remains the semantic signal. If a button label becomes important on focus, changing weight may be the truthful hierarchy change even if it shifts width; reserving space or using another emphasis cue can handle layout. Grade is best when the job is optical compensation while the semantic level stays fixed. The coordinate plane in the first figure prevents “darker” from becoming one undifferentiated instruction.
- Pinned specimen measurement ladder
- The same string is measured at baseline, positive grade, heavier weight, and combined coordinates inside a fixed container.
| State | wght | GRAD | Evidence |
|---|---|---|---|
| Baseline | 400 | 0 | Width and line count |
| Grade | 400 | +100 | Delta from baseline |
| Weight | 600 | 0 | Hierarchy state |
| Combined | 600 | +100 | Both controls named |
Design missing-axis and fallback states
A grade declaration is ignored or has no intended effect when the active font lacks the axis. The fallback must therefore preserve hierarchy without depending on GRAD. Use the baseline weight, color, spacing, or another established token; do not silently increase weight and call that equivalent optical compensation.
The lab exposes a missing-font query state and reports document.fonts failure, active family, and unavailable evidence. Export remains disabled until the pinned specimen completes. In product CSS, group the grade enhancement with the verified font face and keep ordinary font-weight outside that enhancement. Variable font grade vs weight should fail toward a readable, semantically correct state.
Color fonts add another independent dimension; distinguish variation axes from color-font palettes when a family supports both. Also test forced colors, where author color choices can be replaced while glyph outlines remain. A fallback plan names what is preserved—content, hierarchy, and readable spacing—and what is lost: the font-specific optical adjustment. That statement is more honest than claiming graceful equivalence.
Run the pinned Roboto Flex specimen
Open the artifact and run the default specimen after the bundled face loads. The variable font grade vs weight table measures baseline, GRAD +100, wght 600, and the combined state. A second run must reproduce the same rounded measurements within the fixture tolerance. Changing the authored string creates a new receipt rather than mutating old evidence.
The downloadable JSON includes font source, license, SHA-256, declared axes, browser user agent, device pixel ratio, CSS requests, computed settings, widths, line counts, and pass states. The hash belongs to the bytes in the repository. It lets a reviewer detect asset drift before comparing measurements.
The artifact does not rate readability and does not claim GRAD is universally metric-preserving. Its deterministic claim is narrower: the fixed asset and strings in one executing browser produce a complete, reparsable receipt; same inputs repeat within tolerance; missing font or axis states are explicit. That claim can be independently tested without accepting a prewritten PASS label.
- Font-axis choice matrix
- Six UI situations route to weight, grade, both, or a semantic fallback through font identity and interface intent.
- Heading hierarchy routes to weight with layout reserved.
- Dark-theme optical compensation routes to grade only after measurement.
- Focus plus compensation can use both controls with separate tokens.
- A missing GRAD axis retains the semantic weight baseline without an equivalence claim.
- An unknown font requires a new specimen before grade evidence is reused.
Runnable artifact — Evidence applies only to the pinned Roboto Flex asset in the executing browser. It does not prove universal grade availability, metric stability, readability, or typographic preference.
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Variable font grade and weight lab</title><style>
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:auto;max-width:1000px;padding:24px;background:#171421;color:#fff8ef;font:16px/1.5 system-ui}button,a,input{min-height:44px;font:inherit}button{padding:10px 16px;background:#ffcf66;color:#241700;border:0;border-radius:8px;font-weight:800}.panel{padding:16px;margin:16px 0;border:1px solid #aa9ab8;border-radius:14px}.controls{display:grid;grid-template-columns:1fr auto;gap:12px;align-items:end}.controls label{display:grid;gap:4px}.specimen{font-size:32px;line-height:1.2;display:inline-block;white-space:nowrap}.wrap{font-size:22px;line-height:28px;width:310px}.hidden{position:absolute;left:-9999px;top:0}table{border-collapse:collapse;width:100%;min-width:920px}th,td{padding:8px;border-bottom:1px solid #74697e;text-align:left}.scroll{overflow:auto}textarea{width:100%;min-height:320px;background:#0c0911;color:#fff}@media(max-width:600px){body{padding:14px}.controls{grid-template-columns:1fr}.wrap{width:260px}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><main><h1>Variable font grade vs weight lab</h1><p>This browser parses the bundled WOFF2 metadata before measuring. Both DOM geometry and Canvas advance width must reconcile; a serialized GRAD declaration alone is never accepted as axis evidence.</p><div class="panel controls"><label>Authored specimen string<input id="text" value="Audit calm interfaces before they wrap."></label><button id="run" type="button">Parse font and measure</button></div><p id="status" class="panel" aria-live="polite">Waiting for font</p><div class="scroll" tabindex="0" aria-label="Axis measurement table"><table><caption>Parsed-axis DOM and Canvas measurements</caption><thead><tr><th>State</th><th>wght</th><th>GRAD</th><th>DOM px</th><th>Canvas px</th><th>Delta</th><th>Lines</th></tr></thead><tbody id="rows"></tbody></table></div><div id="stage" class="hidden" aria-hidden="true"></div><canvas id="measure" class="hidden" width="1000" height="80" aria-hidden="true"></canvas><textarea id="receipt" readonly aria-label="Measurement receipt"></textarea><p><a id="download" download="variable-font-grade-weight-receipt.json" aria-disabled="true">Download JSON receipt</a></p></main><script type="module">
import {create as parseFont} from './fontkitten-browser-20260905.mjs';
const asset={family:'JP Roboto Flex',url:'/assets/journal/fonts/roboto-flex-latin-20260905.woff2',source:'Google Fonts v30 Latin subset',sourceUrl:'https://fonts.gstatic.com/s/robotoflex/v30/NaNeepOXO_NexZs0b5QrzlOHb8wXqDGRY6mZsaJ8__OGfttPZktqc2VdZ80KvCLZaPcSBZtOx2MifRuWR28sPJtUMbsFEK6cRrleUx9Xgbm3WLHa_F4Ep4Fm0PN19Ik5Dntczx0wZGzhPlL1YNMYKbv9_1IQXOw7AiUJVXRrV8cWW4O8LJCoXjCnwSRSaLshNP1d9-EmFw.woff2',license:'OFL-1.1',licenseFile:'/assets/journal/fonts/roboto-flex-OFL-20260905.txt',sha256:'87f81d61ac29a41e3fa78f04904b42a3553d9a1615e3ce61a929ae3b04bb2aa6'};
const requiredAxes={wght:{min:100,default:400,max:1000},GRAD:{min:-200,default:0,max:150}},states=[{name:'baseline',wght:400,grad:0},{name:'grade-plus-100',wght:400,grad:100},{name:'weight-600',wght:600,grad:0},{name:'combined',wght:600,grad:100}],q=selector=>document.querySelector(selector),ctx=q('#measure').getContext('2d');let url='',prepared=[];
const hex=bytes=>[...new Uint8Array(bytes)].map(value=>value.toString(16).padStart(2,'0')).join(''),sha256=async bytes=>hex(await crypto.subtle.digest('SHA-256',bytes));
function reset(){if(url)URL.revokeObjectURL(url);url='';q('#download').removeAttribute('href');q('#download').setAttribute('aria-disabled','true');q('#receipt').value='';q('#rows').innerHTML='';q('#stage').replaceChildren()}
const unavailable=['domWidth','canvasAdvanceWidth','lineCount','gradeWidthDelta','reconciliation'];
async function unsupported(reason,details){const receipt={schema:'variable-font-axis-receipt-v2',asset,loaded:false,axisReady:false,status:'unsupported',reason,...details,measurements:null,unavailableFields:unavailable,exportEnabled:false,claimBoundary:'No grade or weight measurement is published without parsed required axes and the pinned loaded face.'};q('#receipt').value=JSON.stringify(receipt,null,2);q('#status').textContent='UNSUPPORTED: '+reason;return receipt}
function normalizedAxes(parsed){return Object.fromEntries(Object.entries(parsed).map(([tag,row])=>[tag,{name:row.name,min:row.min,default:row.default,max:row.max}]))}
async function prepareFaces(bytes){prepared=[];for(const state of states){const family='JP Axis '+state.name+' '+Date.now(),face=new FontFace(family,bytes.slice(0),{weight:String(state.wght),variationSettings:'"GRAD" '+state.grad});await face.load();document.fonts.add(face);prepared.push({...state,family,loaded:face.status==='loaded',faceWeight:face.weight,faceVariationSettings:face.variationSettings})}}
function measure(state,text){const one=document.createElement('span');one.className='specimen';one.textContent=text;one.style.fontFamily='"'+state.family+'"';one.style.fontWeight=String(state.wght);const wrap=document.createElement('div');wrap.className='wrap';wrap.textContent=text+' '+text;wrap.style.fontFamily='"'+state.family+'"';wrap.style.fontWeight=String(state.wght);q('#stage').append(one,wrap);const style=getComputedStyle(one),domWidth=Number(one.getBoundingClientRect().width.toFixed(3)),lineHeight=parseFloat(getComputedStyle(wrap).lineHeight),lineCount=Math.round(wrap.getBoundingClientRect().height/lineHeight);ctx.font=state.wght+' 32px "'+state.family+'"';const canvasAdvanceWidth=Number(ctx.measureText(text).width.toFixed(3)),domCanvasDelta=Number(Math.abs(domWidth-canvasAdvanceWidth).toFixed(3));const row={name:state.name,wght:state.wght,grad:state.grad,faceWeight:state.faceWeight,faceVariationSettings:state.faceVariationSettings,domWidth,canvasAdvanceWidth,domCanvasDelta,lineCount,computedFamily:style.fontFamily,computedWeight:style.fontWeight,computedSettings:style.fontVariationSettings,reconciled:domCanvasDelta<=.75};one.remove();wrap.remove();return row}
async function missingFontControl(){const family='JP Missing Control',probe=document.createElement('span');probe.textContent='fallback';probe.style.fontFamily='system-ui';document.body.append(probe);let loadError=null,check=false;try{const face=new FontFace(family,'url(/assets/journal/fonts/does-not-exist-20260905.woff2)');document.fonts.add(face);await face.load();check=document.fonts.check('32px "'+family+'"')}catch(error){loadError=error.name+': '+error.message}const computedActiveFamily=getComputedStyle(probe).fontFamily;probe.remove();return unsupported('pinned-font-load-failed',{forcedMissingFont:true,computedActiveFamily,loadCheck:{check,loadError},axisInspection:null})}
async function run(){reset();const query=new URLSearchParams(location.search);if(query.get('font')==='missing'){await missingFontControl();return}try{const response=await fetch(asset.url,{cache:'no-store'});if(!response.ok)throw new Error('font-fetch-http-'+response.status);const bytes=await response.arrayBuffer(),digest=await sha256(bytes);if(digest!==asset.sha256)throw new Error('font-digest-mismatch');const parsed=parseFont(new Uint8Array(bytes)),axes=normalizedAxes(parsed.variationAxes||{}),faultMissingAxis=query.get('axis')==='missing';if(faultMissingAxis)delete axes.GRAD;const axisErrors=Object.entries(requiredAxes).flatMap(([tag,expected])=>{const actual=axes[tag];if(!actual)return['missing-axis:'+tag];return['min','default','max'].filter(key=>actual[key]!==expected[key]).map(key=>'axis-range:'+tag+':'+key+':'+actual[key]+'!='+expected[key])});const axisInspection={method:'fontkitten WOFF2 parser reading the fetched bundled bytes',format:parsed.type,familyName:parsed.familyName,postscriptName:parsed.postscriptName,axes,requiredAxes,errors:axisErrors,faultMissingAxis};if(axisErrors.length){await unsupported(axisErrors.join(','),{forcedMissingAxis:faultMissingAxis,computedActiveFamily:null,loadCheck:{attempted:false,reason:'axis inspection failed first'},axisInspection});return}await prepareFaces(bytes);if(!prepared.every(face=>face.loaded))throw new Error('font-face-load-incomplete');const text=q('#text').value;if(!text.trim())throw new Error('authored-string-blank');const rows=prepared.map(state=>measure(state,text)),again=prepared.map(state=>measure(state,text)),repeatable=rows.every((row,index)=>Math.abs(row.domWidth-again[index].domWidth)<=.05&&Math.abs(row.canvasAdvanceWidth-again[index].canvasAdvanceWidth)<=.05&&row.lineCount===again[index].lineCount),reconciled=rows.every(row=>row.reconciled),gradeDomDelta=Number(Math.abs(rows[1].domWidth-rows[0].domWidth).toFixed(3)),gradeCanvasDelta=Number(Math.abs(rows[1].canvasAdvanceWidth-rows[0].canvasAdvanceWidth).toFixed(3));const receipt={schema:'variable-font-axis-receipt-v2',asset:{...asset,observedSha256:digest},loaded:true,axisReady:true,status:'measured',axisInspection,loadCheck:{faces:prepared.map(face=>({name:face.name,family:face.family,loaded:face.loaded})),documentFontsReady:document.fonts.status},provenance:'Bundled licensed WOFF2 parsed at runtime, editable authored string, fixed axis coordinates, and two independent browser measurement APIs.',claimBoundary:'Evidence applies to this asset, string, and executing browser; no universal readability or metric claim.',browser:navigator.userAgent,devicePixelRatio,string:text,rows,repeatable,reconciled,reconciliationTolerancePx:.75,gradeDomDelta,gradeCanvasDelta,gradeLayoutStableForFixture:gradeDomDelta<=.25&&gradeCanvasDelta<=.25&&rows[1].lineCount===rows[0].lineCount,exportEnabled:repeatable&&reconciled};q('#rows').innerHTML=rows.map(row=>'<tr><th>'+row.name+'</th><td>'+row.wght+'</td><td>'+row.grad+'</td><td>'+row.domWidth+'</td><td>'+row.canvasAdvanceWidth+'</td><td>'+row.domCanvasDelta+'</td><td>'+row.lineCount+'</td></tr>').join('');q('#receipt').value=JSON.stringify(receipt,null,2);q('#status').textContent=(receipt.exportEnabled?'PASS':'FAIL')+': parsed '+Object.keys(axes).join(', ')+'; DOM/Canvas reconciled '+reconciled+'; replay '+repeatable;if(receipt.exportEnabled){const serialized=JSON.stringify(receipt,null,2);JSON.parse(serialized);url=URL.createObjectURL(new Blob([serialized],{type:'application/json'}));q('#download').href=url;q('#download').removeAttribute('aria-disabled')}}catch(error){await unsupported(error.message,{computedActiveFamily:null,loadCheck:{attempted:true,error:error.name+': '+error.message},axisInspection:null})}}
q('#run').addEventListener('click',run);run();
</script></html>
Choose grade, weight, or both by interface job
Use weight when the content moves to a stronger semantic level. Use grade when the same level needs font-specific optical compensation and the measured layout behavior matters. Use both when hierarchy and compensation change together, but keep their tokens and rationale separate. Variable font grade vs weight becomes manageable when each axis answers a different design question.
Document the chosen coordinates per component and theme rather than exposing arbitrary sliders in production. Preserve room for translations and user font settings. If the product cannot guarantee the verified font asset, treat grade as an enhancement and let the semantic weight system stand alone.
Revisit the decision when CSS variation behavior, Roboto Flex bytes, or the Google Fonts GRAD registry changes. A new font is a new experiment even if its axis shares the same tag. The durable practice is to pair a typographic judgment with a precise font receipt and a layout measurement. Preserve the specimen text, computed declarations, font digest, viewport, and comparison note together; without that bundle, a later reviewer cannot tell an intentional optical change from asset drift. Keep the unsupported-font control beside the successful specimen so the evidence cannot silently fall back during review. Measure one real UI string on both axes before assigning grade to the system.