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:
--color <color>- first parameter, must be a colour--amount <percentage>: 50%- second parameter, must be a percentage, defaults to 50% if omittedreturns <color>- the return type (optional, but good practice)result:- the computed value that's returned
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
@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
@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);
}
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)
- No loops or iteration - you can't generate a list of values
- No string manipulation - can't build selector names or property names
- No side effects - functions can only return a value, not set properties on elements
- No recursion - a function can't call itself
- Can't be used in selectors or property names - only in values
Retrieval check
Question 1
What must a custom function name start with?
Question 2
What descriptor do you use to specify what a @function returns?
Question 3
A function has two result descriptors - one inside @media and one outside. Which value is returned when the media query matches?
Question 4
You define: @function --space(--n <number>: 1). How do you call it to get the default?
Question 5
How do you pass a comma-separated value as a single argument?