HomeJournalThis post

CSS @function for Typed Design Logic

Adopt CSS @function as progressive design logic with typed inputs, computed-value checks, explicit cascade ownership, and a maintained fallback.

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

CSS @function can express typed, parameterized design logic inside the cascade, but the feature is still draft work. This tutorial builds one spacing utility with a frozen semantic contract, real behavior detection, and an independently tested custom-property fallback.

CSS @function starts with one semantic output

CSS @function is useful when a design system needs parameterized logic that participates in CSS value processing. The adoption target should be one semantic result, such as a spacing value derived from a step, rather than a promise to replace every preprocessor helper. Freeze the output contract first: accepted inputs, returned dimension, clamping behavior, invalid-input behavior, and fallback value.

This tutorial uses a spacing utility whose contract is “an integer-like step produces a length based on the local unit.” The native draft branch expresses that contract with a custom function. The baseline branch uses an authored custom property and calc(). Both branches are inspected through computed values. They share intent, but the fallback is not presented as an implementation of draft parsing or typed parameter semantics.

Treat the feature as progressive design logic. The CSS Custom Functions and Mixins draft is an Editor's Draft, so grammar and behavior can change. A production policy must name the browser set it supports and retain a legible baseline. This teaching fixture treats CSS custom functions as an optional enhancement, not a compatibility promise. CSS @function should improve local expressiveness only after the system can prove that unsupported parsers still render the semantic output.

Typed token call graphA spacing token and numeric step pass through type validation, bounded branches, and one length result while independent bounds protect an invalid native call.--unit: 8pxstep: 3typenumberclamp + multiplyone semantic branch24pxcalc fallback
Typed token call graph
A spacing token and numeric step pass through type validation, bounded branches, and one length result while independent bounds protect an invalid native call.
  1. Input step must satisfy the declared numeric syntax.
  2. The local spacing token remains a length.
  3. Named branches clamp the accepted range.
  4. The function returns one semantic length.
  5. The authored calc baseline is tested separately, and an independent minimum protects invalid computed values.
Figure 1: Parameterized logic is narrow, typed, and paired with a maintained baseline.

Declare typed CSS parameters and results

Typed CSS parameters turn an implicit helper convention into a declaration the CSS engine can validate. A parameter can require a number, length, color, or another supported syntax, and the result statement returns the function's value. For CSS @function, the important design-system question is which invalid values are rejected and which defaults are intentional.

Keep units at the boundary. A step can remain a number while the local spacing token remains a length; multiplying them produces the returned length. Do not accept arbitrary tokens and attempt to repair them deep inside the function. When a component passes a color where a step is expected, the function value can become invalid at computed-value time; do not assume an earlier width declaration will win. Preserve usable bounds through an independent property or an explicitly activated enhancement branch.

The CSSWG custom functions proposal history explains the design pressure behind reusable CSS logic, but a proposal discussion is not a global-support table. Review the current grammar when shipping, and use registered token syntax before adding parameterized logic where a custom property also needs validation or animation. CSS @function adds a callable boundary; it does not make token ownership or input design automatic.

Keep local variables and branches inspectable

A custom function can contain local declarations and conditional logic. That power is easiest to review when each branch corresponds to a named semantic state. In the spacing specimen, negative steps select a compact lower bound, ordinary steps scale the unit, and an upper guard prevents a component from producing an absurd gap. The branch names belong in tests as cases, not only in comments.

Avoid hiding a full component theme inside CSS @function. Deeply nested conditions recreate application logic where browser tooling and team conventions may still be immature. Prefer small functions that return one value and compose through ordinary declarations. If two branches change unrelated properties, the boundary is probably too large.

Local variables should also retain units and purpose in their names. A value called “factor” is a number; “base-space” is a length; “resolved-space” is the returned length. This vocabulary makes computed-value receipts readable even when native CSS rules are unavailable in the current browser. A design review can then compare the function source, the fallback formula, and the observed result without treating the implementation text as proof that a branch executed.

Preserve cascade and token ownership

Custom functions resolve in the cascade context where their values are used. That makes token ownership more important, not less. Put the design-system definition in an explicit layer, let product or component layers supply documented inputs, and keep emergency overrides visible. CSS @function should not become a secret tunnel around the cascade.

The specimen exposes its base unit as a custom property and records the computed width of both native and fallback elements. Changing the unit changes both semantic branches when the native feature exists. If the native declaration is unsupported, the fallback remains the rendered source of truth. The receipt names which branch supplied the observed width.

Read explicit cascade ownership for design systems before centralizing custom functions. Also preserve semantic token identity across migration so a callable helper does not collapse aliases into anonymous numbers. CSS @function can calculate a value, but the organization still owns naming, override policy, deprecation, and the path from a design decision to a rendered component.

Computed-value truth tableNative, fallback, invalid-argument, and unsupported-parser outcomes remain distinct and are judged by rendered values.caseexpectedobserveddecisionfallback step 3native step 3invalid argumentunsupported parser
Computed-value truth table
Native, fallback, invalid-argument, and unsupported-parser outcomes remain distinct and are judged by rendered values.
Behavior cases
CaseOracleNative statusBaseline status
Step 324px computed widthAssert only when executedMust pass
Invalid typeIndependent 7px minimum remainsBounded or unsupportedTested separately
Unsupported parserNative is not assertedExplicit unknownMust pass
Figure 2: Baseline truth never depends on the experimental parser reporting support.

Feature-detect parse and computed behavior

A parser accepting some new syntax is weaker evidence than a declaration producing the intended computed value. The lab inserts a real stylesheet containing CSS @function and a call site, then inspects the stylesheet and the rendered specimen. It reports parse visibility, a nonempty computed value, and equality with the frozen semantic expectation as separate observations.

Do not infer support from the presence of Web Platform Tests. The WPT css-mixins suite is primary conformance material, but repository presence does not say which browser version passes which case. Execute a small behavior test in the browsers covered by the product policy and keep the result date.

Unknown is a valid outcome. If the executing browser discards the at-rule or leaves the call unresolved, the lab marks native support unavailable and continues to test the baseline. It does not print a fake native PASS. This distinction lets a CSS @function experiment run safely in today's browser while remaining useful when an implementation arrives. CSS feature detection belongs beside a fallback, not as a reason to ship an empty component. That evidence boundary also keeps design token functions reviewable while the syntax matures.

Author a standards-safe custom-property fallback

The fallback should use widely implemented CSS with its own truth table. This specimen calculates spacing with calc(var(--space-unit) * var(--space-step)) and supplies bounded authored values for every production call site. It does not attempt to parse the draft function source in JavaScript or claim identical invalid-argument behavior.

Put the baseline declaration before the experimental declaration in the same rule when ordinary cascade replacement is safe. An unsupported value is discarded, leaving the earlier value in force. If at-rule scoping or parse recovery makes that arrangement ambiguous, separate the experiment behind an explicit enhancement class whose activation follows a behavior test. CSS @function adoption is successful when removing the enhancement still leaves the component correct.

Fallback tests cover step zero, an ordinary step, the chosen maximum, and a malformed input that resolves to the documented baseline. Keep component boundaries from hiding fallback behavior when scoping the utility. The baseline is a maintained product path with its own source, computed values, and review ownership—not a comment that promises someone will add compatibility later.

Compare native and fallback truth tables

A truth table makes progressive enhancement concrete. Each row contains the step, base unit, expected semantic length, fallback computed length, native computed length when available, parse status, and verdict. CSS @function earns adoption only when every supported native row matches the contract and every baseline row passes independently.

Include hostile rows. A missing argument, a value with the wrong type, and a browser that lacks the draft must never leave stale output or enable a misleading download. The lab rebuilds the receipt after each run and disables export until all baseline invariants pass. Native failure is labeled unsupported or mismatch rather than silently replaced in the native column.

Computed values are the useful oracle because they reveal what rendered after parsing, substitution, cascade, and value processing. Source-string equality is not enough. Repeat the test after changing the local unit to prove the specimen is reading live style rather than comparing constants. The downloadable receipt contains the authored sources and observed values so another reviewer can distinguish CSS execution from the JavaScript policy around it.

Design-token decision pipelineCascade layer, token owner, condition, function call, fallback, and rendered component are visible as separate control stations.layertoken ownerconditionfunctionrenderbaselineunsupportedsame semantic output
Design-token decision pipeline
Cascade layer, token owner, condition, function call, fallback, and rendered component are visible as separate control stations.
Ownership pipeline
StationOwnerReceipt field
Cascade layerDesign systemRule source
TokenTheme or component contractBase unit
FunctionProgressive enhancementParse and compute status
FallbackSupported baselineComputed value
ComponentProduct UIRendered semantic result
Figure 3: The custom function calculates a value but never erases cascade or fallback ownership.

Runnable artifact — The lab reports parse/compute behavior in the executing browser. The fallback is authored CSS math, not an emulation of the draft, and the result is not a universal support claim.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CSS @function design logic lab</title><style>
:root{color-scheme:dark;--unit:8px}*{box-sizing:border-box}body{margin:auto;max-width:980px;padding:24px;background:#071b20;color:#f5fbf8;font:16px/1.5 system-ui}button,a,input{min-height:44px;font:inherit}button{padding:10px 16px;border:0;border-radius:8px;background:#70f0c0;color:#062019;font-weight:800}.panel{margin:16px 0;padding:16px;border:1px solid #7aa9a2;border-radius:14px;background:#102d31}.controls{display:grid;grid-template-columns:1fr 1fr auto;gap:12px;align-items:end}.controls label{display:grid;gap:4px}.samples{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.sample{min-height:72px;border:2px solid #ffca70;padding:8px}.fallback-probe{width:calc(clamp(0,var(--case-step),8)*var(--case-unit))}.native-probe{width:--jp-space(var(--case-step),var(--case-unit))}.native-invalid{width:--jp-space(red,8px);min-width:7px}.parse-unsupported{width:--jp-not-defined(3,8px);min-width:5px}@function --jp-space(--step <number>,--unit <length>) returns <length>{--lower:0;--upper:8;--bounded-step:clamp(var(--lower),var(--step),var(--upper));--resolved-space:calc(var(--bounded-step)*var(--unit));result:var(--resolved-space)}table{border-collapse:collapse;width:100%;min-width:820px}th,td{padding:8px;text-align:left;border-bottom:1px solid #52777b}.scroll{overflow:auto}textarea{width:100%;min-height:300px;background:#041114;color:#fff}@media(max-width:600px){body{padding:14px}.controls,.samples{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
</style><main><h1>CSS @function design logic lab</h1><p>Typed parameters feed named local declarations. Negative and lower-bound steps resolve to zero, ordinary steps scale the live unit, and oversize steps stop at eight units. The calc() baseline remains an independent product path.</p><div class="panel controls"><label>Unit in pixels<input id="unit" type="number" value="8" min="1" max="64" step="1"></label><label>Ordinary step<input id="step" type="number" value="3" min="0" max="8" step="1"></label><button id="run" type="button">Run truth table</button></div><div class="panel samples"><div id="live-fallback" class="sample fallback-probe">fallback probe</div><div id="live-native" class="sample native-probe">native probe</div><div id="invalid" class="sample native-invalid">invalid typed call</div><div id="unsupported" class="sample parse-unsupported">undefined function call</div></div><p id="status" class="panel" aria-live="polite">Not run</p><div class="scroll" tabindex="0" aria-label="Computed-value truth table"><table><caption>Live native, fallback, and expected values</caption><thead><tr><th>Branch</th><th>Step</th><th>Unit</th><th>Expected</th><th>Fallback</th><th>Native</th><th>Verdict</th></tr></thead><tbody id="rows"></tbody></table></div><textarea id="receipt" readonly aria-label="Execution receipt"></textarea><p><a id="download" download="css-function-receipt.json" aria-disabled="true">Download JSON receipt</a></p></main><script>
const q=selector=>document.querySelector(selector),download=q('#download');let url='';
const px=value=>Number.parseFloat(value),format=value=>Number(value.toFixed(6))+'px';
function strictNumber(raw,name,{integer=false,min=-Infinity,max=Infinity}={}){if(String(raw).trim()==='')throw new Error(name+'-blank');const value=Number(raw);if(!Number.isFinite(value)||integer&&!Number.isInteger(value)||value<min||value>max)throw new Error(name+'-invalid');return value}
function measure(className,step,unit){const node=document.createElement('div');node.className=className;node.style.setProperty('--case-step',String(step));node.style.setProperty('--case-unit',unit+'px');node.style.cssText+=';position:fixed;visibility:hidden;height:1px;font-size:0;contain:layout';document.body.append(node);const value=getComputedStyle(node).width;node.remove();return value}
function expected(step,unit){return format(Math.min(8,Math.max(0,step))*unit)}
function reset(){if(url)URL.revokeObjectURL(url);url='';download.removeAttribute('href');download.setAttribute('aria-disabled','true');q('#receipt').value=''}
function run(){reset();try{const unit=strictNumber(q('#unit').value,'unit',{min:1,max:64}),ordinary=strictNumber(q('#step').value,'step',{min:0,max:8});const cases=[{branch:'negative/lower',step:-2},{branch:'lower boundary',step:0},{branch:'ordinary/live',step:ordinary},{branch:'upper boundary',step:8},{branch:'oversize/upper',step:12}];const rows=cases.map(item=>{const wanted=expected(item.step,unit),fallback=measure('fallback-probe',item.step,unit),native=measure('native-probe',item.step,unit);return{...item,unit,expected:wanted,fallback,native,fallbackMatches:px(fallback)===px(wanted),nativeMatches:px(native)===px(wanted)}});const fallbackPass=rows.every(row=>row.fallbackMatches),nativeMatches=rows.filter(row=>row.nativeMatches).length,nativeStatus=nativeMatches===rows.length?'supported-and-matched':nativeMatches===0?'parse-unsupported':'supported-but-mismatched';const invalidStyle=getComputedStyle(q('#invalid')),unsupportedStyle=getComputedStyle(q('#unsupported'));const invalidBehavior={width:invalidStyle.width,minWidth:invalidStyle.minWidth,bounded:invalidStyle.minWidth==='7px'&&px(invalidStyle.width)>=7,policy:'Typed wrong-kind argument becomes invalid at computed-value time; independent min-width keeps the box usable.'};const unsupportedBehavior={width:unsupportedStyle.width,minWidth:unsupportedStyle.minWidth,bounded:unsupportedStyle.minWidth==='5px'&&px(unsupportedStyle.width)>=5,policy:'An undefined dashed function is not counted as native support; independent min-width remains.'};const rules=[...document.styleSheets].flatMap(sheet=>{try{return[...sheet.cssRules].map(rule=>rule.cssText)}catch{return[]}}),functionSource=rules.find(rule=>rule.includes('@function --jp-space'))||null,parseVisible=Boolean(functionSource);const pass=fallbackPass&&invalidBehavior.bounded&&unsupportedBehavior.bounded&&nativeStatus!=='supported-but-mismatched';q('#live-fallback').style.setProperty('--case-step',String(ordinary));q('#live-fallback').style.setProperty('--case-unit',unit+'px');q('#live-native').style.setProperty('--case-step',String(ordinary));q('#live-native').style.setProperty('--case-unit',unit+'px');const receipt={schema:'css-function-truth-table-v2',grammar:'CSS Custom Functions and Mixins Editor Draft checked 2026-09-05',authoredFunction:'--jp-space',typedParameters:{step:'<number>',unit:'<length>',returns:'<length>'},locals:['--lower','--upper','--bounded-step','--resolved-space'],branches:{lower:'step < 0 clamps to 0',ordinary:'0 through 8 multiplies live unit',upper:'step > 8 clamps to 8'},inputs:{unit,ordinaryStep:ordinary},provenance:'Authored CSS fixtures and independently calculated expected rows; no compatibility telemetry.',claimBoundary:'Executing-browser parse and compute behavior only; calc() is a maintained baseline, not draft emulation.',parseVisible,functionSource,nativeStatus,rows,fallbackPass,invalidBehavior,unsupportedBehavior,pass};q('#rows').innerHTML=rows.map(row=>'<tr><th>'+row.branch+'</th><td>'+row.step+'</td><td>'+row.unit+'px</td><td>'+row.expected+'</td><td>'+row.fallback+'</td><td>'+(nativeStatus==='parse-unsupported'?'unsupported':row.native)+'</td><td>'+((row.fallbackMatches&&(nativeStatus==='parse-unsupported'||row.nativeMatches))?'PASS':'FAIL')+'</td></tr>').join('');q('#receipt').value=JSON.stringify(receipt,null,2);q('#status').textContent=(pass?'PASS':'FAIL')+': baseline '+(fallbackPass?'matches':'mismatches')+'; native '+nativeStatus+'; invalid and unsupported calls '+(invalidBehavior.bounded&&unsupportedBehavior.bounded?'contained':'uncontained');if(pass){const serialized=JSON.stringify(receipt,null,2);JSON.parse(serialized);url=URL.createObjectURL(new Blob([serialized],{type:'application/json'}));download.href=url;download.removeAttribute('aria-disabled')}}catch(error){q('#status').textContent='FAIL: '+error.message;q('#rows').innerHTML=''}}
q('#run').addEventListener('click',run);run();
</script></html>

Ship the branch your policy can defend

Adopt the experimental branch only for browsers where the behavior test passes and only for components whose baseline remains correct. Document the exact CSS @function grammar revision, test cases, and support date. If the draft changes, treat that as a migration event rather than assuming the old function continues to mean the same thing.

The right first use is narrow, visible, and reversible: a token utility with clear types and an easily inspected result. Avoid critical layout where an unsupported value would erase content. Avoid duplicating business rules that belong in application state. Keep design decisions in tokens and use the function to express a small CSS-native transformation.

This lab reports only the executing browser. It does not claim a global compatibility percentage, and its calc() path is an authored fallback rather than a polyfill. Revisit after a CSSWG grammar change, new implementation, or meaningful WPT interoperability shift. Until then, the operational question is not whether CSS @function looks elegant; it is whether the team can prove the semantic output in both branches. Run the native-versus-fallback truth table on one real token utility.