Imagine maintaining a documentation site. Article cards need generous padding, while version notices need a tighter layout. Both should follow the same spacing unit. You write calc() twice, multiplying by three for one component and two for the other. That solves the immediate problem.

Later, cards also appear in a sidebar with a smaller spacing unit. The arithmetic remains simple. What becomes harder to track is the agreement: which components share a scale, where does the unit come from, and which other components should change when one rule changes?

CSS @function gives that calculation a name. When components share a calculation but supply different inputs, separating the rule from those inputs makes maintenance responsibilities clearer. But naming a multiplication used only once can also add another indirection before someone understands the style.

Start with what ordinary CSS already provides

Here is the HTML for our hypothetical documentation site. The examples reuse these classes:

<article class="site-card">
  <h2>Installation guide</h2>
  <p>Start with the first page of your project.</p>
</article>
<aside class="site-notice">
  <h2>Version notice</h2>
  <p>Read the changelog before upgrading.</p>
</aside>

A custom property supplies the shared unit. Each component performs its own calculation:

:root {
  --space-unit: 0.5rem;
}
 
.site-card {
  padding: calc(var(--space-unit) * 3);
}
 
.site-notice {
  padding: calc(var(--space-unit) * 2);
}

With a 16px root font size, the unit is 8px, giving the card 24px of padding and the notice 16px. Changing the unit updates both; each multiplier stays in its component’s rule.

This is already clear CSS. The custom property supplies a value and calc() performs the arithmetic. Two multiplications alone do not justify a refactor. A function becomes worth considering when the calculation expresses a rule that needs to be maintained together, rather than merely similar-looking code.

Give the calculation a name with @function

The following complete stylesheet replaces the previous one. Initially, both the multiplier and the unit are explicit arguments so that the dependencies are visible at the call site:

:root {
  --space-unit: 0.5rem;
}
 
@function --space(--steps <number>: 1, --unit <length>: 0.5rem)
  returns <length> {
  result: calc(var(--steps) * var(--unit));
}
 
.site-card {
  padding: --space(3, var(--space-unit));
}
 
.site-notice {
  padding: --space(2, var(--space-unit));
}

Function and parameter names begin with --. Define the function at the top level and invoke --space(...) directly in a property value. You do not wrap it in var(), and you do not use @apply. Inside the function, var(--steps) and var(--unit) access parameters, while result describes the output value. These parts are defined by the function rule.

This interface accepts a numeric multiplier and a length unit, and requires a length result. --space() uses both defaults and produces 0.5rem; --space(3) produces 1.5rem; --space(3, 6px) produces 18px. Percentages are not included in <length>. The type should reflect what the function actually promises to accept.

Types and defaults are optional. Here, they make the intended inputs clear. The default unit is the function’s own 0.5rem. To follow --space-unit, pass that token explicitly, as the card does. Changing the root token to 0.75rem does not rewrite the default stored in the function definition.

Where a custom property is evaluated matters

Putting a calculation in a root custom property does not preserve a function that rereads its dependencies on every descendant.

Use this standalone CSS with the same HTML and inspect the card:

:root {
  --space-unit: 0.5rem;
  --card-space: calc(var(--space-unit) * 3);
}
 
.site-card {
  --space-unit: 0.25rem;
  padding: var(--card-space);
}

At a 16px root font size, the card still has 24px of padding. The reference to --space-unit inside --card-space is resolved on the root element before the result is inherited. Setting 0.25rem on the card does not go back and resolve that root-level variable reference again. This follows the evaluation and inheritance rules for custom properties; it does not mean custom properties are unresponsive.

You can fix this without a function: put calc(var(--space-unit) * 3) on the card itself. A function helps when many callers need that same rule while its definition should stay in one place.

Here is a different interface. The multiplier is explicit, but the unit is read from the element where the function is called. It deliberately has a different name from --space() so that the two dependency models remain distinct:

:root {
  --space-unit: 0.5rem;
}
 
@function --local-space(--steps <number>: 1) returns <length> {
  result: calc(var(--space-unit, 0.5rem) * var(--steps));
}
 
.site-card {
  --space-unit: 0.25rem;
  padding: --local-space(3);
}
 
.site-notice {
  padding: --local-space(2);
}

The card now gets 12px of padding, while the notice still gets 16px. No parameter or local variable named --space-unit exists inside the function, so it reads the custom property on the calling element. The draft’s argument and variable rules describe that relationship.

ApproachWhere the card’s unit comes fromCard padding here
Inherit --card-space from the rootThe root’s 0.5rem when its variable reference is resolved24px
--space(3, var(--space-unit)), with 0.25rem on the cardExplicit argument at the call site12px
--local-space(3)The function reads the card’s token12px

Explicit arguments make dependencies easier to trace and test. Reading an external token shortens calls but adds an implicit dependency. If --local-space() belongs to a design system, its contract should document that it reads --space-unit. A parameter or local variable with the same name also shadows an external property, so avoid accidental name collisions.

result is a declaration, not an early return

Add one more hypothetical requirement: at viewport widths of 40rem or less, both components use three quarters of their usual spacing. That is a decision for this documentation site, not a universal definition of a phone.

This complete stylesheet stores the density in a local variable, overrides it when the condition matches, and uses one formula for the result:

:root {
  --space-unit: 0.5rem;
}
 
@function --responsive-space(--steps <number>: 1) returns <length> {
  --density: 1;
 
  @media (width <= 40rem) {
    --density: 0.75;
  }
 
  result: calc(var(--space-unit, 0.5rem) * var(--steps) * var(--density));
}
 
.site-card {
  padding: --responsive-space(3);
}
 
.site-notice {
  padding: --responsive-space(2);
}

With both the root and default font sizes at 16px, an 800px viewport produces 24px and 16px. At 600px, the values become 18px and 12px. --density stays local to the function; it does not become a token that other CSS can read from the element.

The model remains declarative. result is not JavaScript’s return. The following function always produces 1rem, even when the narrow-screen condition matches:

@function --always-one() returns <length> {
  @media (width <= 40rem) {
    result: 0.75rem;
  }
 
  result: 1rem;
}

The conditional declaration participates when its condition matches, but the later result still wins. Evaluation does not stop at the first result. The draft’s conditional execution model explains this distinction. Put the baseline first and conditional overrides later, or consolidate the result as in the preceding example.

If cards and notices should respond to different viewport conditions, keep those media queries in their respective component rules. Sharing a condition also creates an agreement to change it together later.

Type checking does not restore an earlier declaration

To observe failure, start with a function whose multiplier has no default:

@function --strict-space(--steps <number>) returns <length> {
  result: calc(var(--steps) * 0.5rem);
}
 
.site-card {
  padding: 24px;
  padding: --strict-space(red);
}

In a browser that supports custom functions, red does not match <number>, so the call becomes invalid at computed-value time. The resulting padding is its initial value, 0, rather than the earlier 24px. Calling --strict-space() without the required argument likewise provides no usable multiplier. Other properties may behave differently depending on inheritance; zero is specific to padding here. The rules for invalid values at computed-value time explain why the cascade is not rerun.

A parameter with a default is a different case. Our earlier --space() gives the multiplier a default of 1. Under the argument evaluation rules, an input that does not match its type falls back to that default: --space(red) produces 0.5rem. It does not necessarily invalidate the whole call. Defaults can therefore conceal mistakes, which is another reason to inspect the actual output during development.

Types constrain the kind of value, not the entire design contract. A negative number is still a <number>. A negative length returned by the function can be useful for margin but is unsuitable for padding. Consider both the permitted input range and the property that consumes the result.

Choose the abstraction around what changes together

These tools can work together. For this example, I would start with tokens and direct calculations, introducing a function once more components need the same calculation to evolve together.

What needs a shared definitionConsider firstPay attention to
Overridable values such as spacing and colorsCustom propertiesDeclaration location, inheritance, dependencies
A calculation or a bounded valuecalc(), min(), max(), clamp()A direct expression is often clearest
A shared calculation with different inputsNative @functionParameters, calling element, browser support
Build-time data processing and CSS generationSass functionsCompiler and input data
Identical groups of declarationsShared classes or grouped selectorsHTML and stylesheet organization
Parameterized blocks of stylesNative @mixin draftDraft maturity and block responsibilities

A Sass function runs in the compiler. A native CSS function participates in browser style evaluation. Sass can still output CSS containing var() or calc() for the browser to resolve, so the distinction is not that every Sass result is fixed while only native CSS can change.

@function produces a value. It does not insert a group of border, padding, and nested selector rules. For reuse at the block level, see the companion article on native CSS @mixin. Sharing a specification does not make their implementation maturity identical.

Keep a working baseline when adopting it today

At the verification date, the compatibility source data records the following. This describes feature support, not usage share or this site’s audience.

Browser@function support
Chrome / Chrome AndroidFrom 139
EdgeFrom 139
FirefoxNot supported yet
Safari / iOS SafariListed as preview; do not treat this as general stable availability

For a site that needs to remain readable across browsers, retain the working calc() version and isolate the enhancement behind @supports at-rule(@function). This complete CSS works with the opening HTML:

:root {
  --space-unit: 0.5rem;
}
 
.site-card {
  padding: calc(var(--space-unit) * 3);
}
 
.site-notice {
  padding: calc(var(--space-unit) * 2);
}
 
@supports at-rule(@function) {
  @function --space(--steps <number>: 1, --unit <length>: 0.5rem)
    returns <length> {
    result: calc(var(--steps) * var(--unit));
  }
 
  .site-card {
    padding: --space(3, var(--space-unit));
  }
 
  .site-notice {
    padding: --space(2, var(--space-unit));
  }
}

This is conservative enhancement. The at-rule() query itself also needs support. A browser that understands functions but not this query stays on the baseline. Both paths produce the same appearance here, at the cost of temporarily maintaining two calculations. That cost belongs in the adoption decision.

Do not use @supports (padding: --space(3)) to prove that the function exists and its call will succeed. A declaration being parseable is not evidence that every invocation works. In our tests, supporting engines returned true even for an undefined function name. The at-rule() query checks rule support, too—not the validity of every argument.

The examples were checked locally in Chromium 153.0.8010.12 and Playwright WebKit 26.6. Disabling CSSFunctions in Chromium also verified the baseline. This is not a test of stable Safari or Firefox: the local Firefox test process failed to start, so the table relies on the cited compatibility data.

Returning to cards and notices, the useful shared definition is the calculation your team has actually agreed to maintain together. A function’s name should make that agreement legible, while its parameters keep differences close to their use. If understanding a padding value now requires tracing more hidden tokens, keeping the original calc() may still be the better choice.