← Back to Index

Lesson 4

@function - Custom CSS Functions

In Lessons 1 - 3 you learned to store values (custom properties), type them (@property), and compute with them (calc, min, max, clamp, color-mix). Now: what if you could wrap all of that into a reusable function with parameters - like a Sass function, but native to CSS?

That's @function. It's brand new - defined in the CSS Functions and Mixins Module (First Public Working Draft, May 2025).

Browser support: Chrome/Edge 139+ (June 2025). Not yet in Firefox or Safari. This is experimental but actively shipping. The demos in this lesson will only work in Chrome/Edge.

The basic shape

A custom function has a name (starting with --), parameters, and a result descriptor that defines what it returns:

@function --double(--value) {
  result: calc(var(--value) * 2);
}

Call it anywhere you'd put a value:

.box {
  padding: --double(8px);   /* → 16px */
  margin: --double(1rem);   /* → 2rem */
}

That's it. The function name starts with --, parameters are custom property names, and you use var() to read them inside the body.

Key mental model: A custom function is a parametrised custom property. Instead of returning one fixed value, it computes a value from its inputs. The result descriptor is what gets returned to the caller.

Adding types

Just like @property, you can type your parameters and return value. This gives you validation and makes the function self-documenting:

@function --tint(--color <color>, --amount <percentage>: 50%) returns <color> {
  result: color-mix(in oklch, var(--color), white var(--amount));
}

Breaking that down:

Call it:

.card {
  background: --tint(#3a86ff, 30%);       /* light blue */
  border-color: --tint(#3a86ff);          /* uses default 50% */
}

Default values

Parameters can have defaults, specified after a colon. If the caller omits that argument, the default kicks in:

@function --fluid(--min <length>, --max <length>, --vw <length>: 2vw) returns <length> {
  result: clamp(var(--min), calc(var(--vw) + var(--min)), var(--max));
}

/* Use with default --vw */
font-size: --fluid(1rem, 2rem);

/* Override --vw */
padding: --fluid(0.75rem, 2rem, 3vw);

Live demos

These demos require Chrome/Edge 139+. In other browsers you'll see fallback styling.

--double(12px) applied to padding

12px pad
24px pad
@function --double(--value) {
  result: calc(var(--value) * 2);
}

<!-- HTML -->
<div class="double-demo">
  <div class="box box-base">12px pad</div>
  <div class="box box-doubled">24px pad</div>
</div>

/* CSS */
.double-demo { --base: 12px; display: flex; gap: 1rem; }
.box-base    { padding: var(--base); }
.box-doubled { padding: --double(var(--base)); }

--fluid(1rem, 2rem) on font-size - resize the window

This text uses a custom --fluid() function that wraps clamp().
@function --fluid(--min <length>, --max <length>, --vw <length>: 2vw) returns <length> {
  result: clamp(var(--min), calc(var(--vw) + var(--min)), var(--max));
}

<!-- HTML -->
<div class="fluid-text">This text uses a custom --fluid() function.</div>

/* CSS */
.fluid-text {
  font-size: --fluid(1rem, 2rem);
  padding: --fluid(0.75rem, 2rem, 3vw);
}
Ad

Local variables inside functions

You can declare custom properties inside the function body as local constants:

@function --elevation(--level <integer>) {
  --base-shadow: 0px;
  --blur: calc(var(--level) * 4px);
  --spread: calc(var(--level) * 1px);
  --opacity: calc(0.1 + var(--level) * 0.03);
  result: var(--base-shadow) var(--blur) var(--spread) rgba(0, 0, 0, var(--opacity));
}

.card-1 { box-shadow: --elevation(1); }
.card-3 { box-shadow: --elevation(3); }
.card-5 { box-shadow: --elevation(5); }

Local variables are scoped to the function - they don't leak out.

Calling functions from functions

Functions can call other functions. Inner functions can even access the outer function's parameters and locals:

@function --shade(--color <color>, --amount <percentage>: 30%) returns <color> {
  result: color-mix(in oklch, var(--color), black var(--amount));
}

@function --tint(--color <color>, --amount <percentage>: 30%) returns <color> {
  result: color-mix(in oklch, var(--color), white var(--amount));
}

@function --contrast-pair(--base <color>) {
  /* Returns a lighter version  -  could call --shade for dark variant */
  result: --tint(var(--base), 80%);
}

.hero {
  background: --contrast-pair(navy);  /* very light navy */
}

Conditional logic with @media and if()

You can include @media rules inside a function to return different values based on context:

@function --responsive-padding(--sm <length>, --lg <length>) {
  result: var(--lg);

  @media (width < 768px) {
    result: var(--sm);
  }
}

.container {
  padding: --responsive-padding(1rem, 3rem);
}

No early returns. Unlike JavaScript, CSS functions don't "return" at the first result they hit. They follow normal cascade rules: last matching result wins. So the @media version above works because when the media query matches, its result overrides the earlier one.

There's also a new if() function for inline conditionals:

The if() function - inline conditional logic

if() lets you choose between values based on a condition, directly inside a property value - no @media block needed. It's CSS's first true inline conditional:

/* Syntax */
if( <condition> : <value-if-true> ; else: <value-if-false> )

/* The condition can be: */
if( media(width < 768px) : ... ; else: ... )     /* media query */
if( supports(display: grid) : ... ; else: ... )  /* feature query */
if( style(--theme: dark) : ... ; else: ... )     /* style query (custom property) */

Used inside a @function:

@function --responsive-padding(--sm <length>, --lg <length>) returns <length> {
  result: if(media(width < 768px): var(--sm); else: var(--lg));
}

.container {
  padding: --responsive-padding(1rem, 3rem);
}

Used directly in a property value (outside @function):

/* Inline conditional without @function */
.card {
  padding: if(media(width < 768px): 1rem; else: 2rem);
  background: if(style(--theme: dark): #1a1a2e; else: white);
  display: if(supports(display: grid): grid; else: flex);
}

/* Chaining multiple conditions */
.text {
  font-size: if(
    media(width < 400px): 0.875rem;
    else: if(
      media(width < 768px): 1rem;
      else: 1.125rem
    )
  );
}

if() vs @media blocks: They produce the same result, but if() keeps the logic inline with the value - no separate block needed. This is especially powerful inside @function where you want one-liner conditional returns, or when you want to set a single property conditionally without the overhead of a media query block.

Browser support: if() is very new - Chrome 137+ (behind a flag), not yet stable in any browser as of mid-2025. The @media-inside-function approach works today.

Passing comma-separated values

If an argument itself contains commas (like a list of colours), wrap it in curly braces to avoid it being parsed as multiple arguments:

@function --longest(--list <length>#, --extra <length>) {
  result: calc(max(var(--list)) + var(--extra));
}

.box {
  width: --longest({10px, 40px, 25px}, 5px);  /* 45px */
}

How this compares to what you know

Feature Sass @function CSS @function
When it runs Compile time Runtime (browser)
Can use viewport/container units No Yes
Can respond to media queries No Yes
Loops / iteration Yes (@for, @each) No
String manipulation Yes No
Type checking Manual Built-in (syntax types)
Reactive to DOM state No Yes (inherits custom properties)

The big difference: CSS @function runs in the browser with full access to runtime context - viewport size, inherited properties, media queries, container queries. Sass functions are powerful but frozen at build time. CSS functions are alive.

Practical patterns

Design token utilities

@function --space(--multiplier <number>: 1) returns <length> {
  --base: 0.5rem;
  result: calc(var(--base) * var(--multiplier));
}

.card {
  padding: --space(3);    /* 1.5rem */
  gap: --space(2);        /* 1rem */
  margin-bottom: --space(4);  /* 2rem */
}

Colour palette generation

@function --tint(--color <color>, --amount <percentage>: 50%) returns <color> {
  result: color-mix(in oklch, var(--color), white var(--amount));
}

@function --shade(--color <color>, --amount <percentage>: 50%) returns <color> {
  result: color-mix(in oklch, var(--color), black var(--amount));
}

:root {
  --brand: #3a86ff;
}

.button {
  background: var(--brand);
  border-color: --shade(var(--brand), 20%);
}

.button:hover {
  background: --tint(var(--brand), 15%);
}

Responsive typography

@function --fluid-type(--min <length>, --max <length>) returns <length> {
  /* Grows from min at 320px viewport to max at 1200px viewport */
  --slope: calc((var(--max) - var(--min)) / (1200 - 320));
  result: clamp(var(--min), calc(var(--min) + (100vw - 320px) * var(--slope)), var(--max));
}

h1 { font-size: --fluid-type(1.5rem, 3rem); }
h2 { font-size: --fluid-type(1.25rem, 2rem); }
p  { font-size: --fluid-type(1rem, 1.25rem); }

What @function can't do (yet)

Retrieval check

Question 1

What must a custom function name start with?

@ - like other at-rules
-- - like custom properties
fn- - a dedicated prefix
Any valid identifier works

Question 2

What descriptor do you use to specify what a @function returns?

return: value;
output: value;
result: value;
value: value;

Question 3

A function has two result descriptors - one inside @media and one outside. Which value is returned when the media query matches?

The first result - early return wins
The @media result - last matching result wins
Both are combined into a list
It's invalid - only one result allowed

Question 4

You define: @function --space(--n <number>: 1). How do you call it to get the default?

--space(default)
--space()
--space
var(--space)

Question 5

How do you pass a comma-separated value as a single argument?

Quote it: --fn("1px, 2px, 3px")
Wrap in braces: --fn({1px, 2px, 3px})
Escape commas: --fn(1px\, 2px\, 3px)
Use semicolons: --fn(1px; 2px; 3px)