Up front: I tested the behavior in this post in Chromium 141 and 153. Both support if() and @function, and I've marked where they differ. For other browsers, check caniuse and MDN. If I've gotten something wrong, corrections welcome.

Intro

Accessibility-related CSS tends to be scattered across @media blocks: one for reduced motion, one for more contrast, another for forced colors. A single component's styles end up split across several places, and it's easy to miss one when you change something.

if() and @function, from my CSS & HTML cheat sheet, bring those conditions back into the property itself:

  • if(): write a condition inside a property's value.
  • @function: turn repeated calculations into custom functions, like Sass's @function but running in the browser.

Every example this time is about accessibility. First, check your browser's support:

Your browser's support

  • if()Detecting (requires JavaScript)
  • @functionDetecting (requires JavaScript)

1. if(): conditions inside a property

To respect the user's reduced-motion setting, you used to need a separate @media block:

.btn {
  transition-duration: 300ms; /* the usual transition duration */
}
/* When the user turns on "reduce motion" in the system, repeat the same selector */
@media (prefers-reduced-motion: reduce) {
  .btn {
    transition-duration: 0s;
  }
}

With if(), it's one declaration:

.btn {
  transition-duration: if(
    /* condition: value when it's true; */
    media(prefers-reduced-motion: reduce): 0s;
    /* value when none of the conditions above are true */
    else: 300ms
  );
}

if() is written as a series of condition: value; pairs, ending with else: value. Note that the separator is a semicolon ;, not a comma.

There are three kinds of condition:

ConditionWhat it checksExample
media()A media querymedia(prefers-contrast: more)
supports()Whether the browser supports a featuresupports(width: stretch)
style()The element's own custom propertiesstyle(--size: small)

Conditions can be combined with and, or and not, and you can write several branches at once; the first one that matches wins:

.btn {
  --size: small; /* a custom property as a switch, on the element itself or an ancestor */
  padding-inline: if(
    style(--size: small): 0.5rem; /* when --size is small */
    style(--size: large): 1.5rem; /* when --size is large */
    else: 1rem                    /* anything else, including --size not being set */
  );
}

2. @function: turning repeated calculations into functions

WCAG 2.5.8 requires targets of at least 24×24px. With lots of components, each one has to remember max(24px, …), and it's easy to miss one. As a function, you write it once:

/* Define a function named --target that takes one parameter, --h */
@function --target(--h) {
  /* result is the return value; read parameters with var() */
  result: max(24px, var(--h));
}

.icon-btn {
  /* Call it like any built-in CSS function, right in the value */
  min-block-size: --target(16px); /* tested: 24px */
}
.btn {
  min-block-size: --target(40px); /* tested: 40px, already above 24px */
}

Function names and parameters start with --, and result is the return value. Some things I tested:

  • Parameters can have defaults: with @function --target(--h, --min: 24px), calling --target(16px, 44px) gives at least 44px.
  • Functions can contain @media, and a matching result overrides the earlier one:
/* Focus outline width: 2px normally, 4px when the user asks for more contrast */
@function --ring() {
  result: 2px;
  @media (prefers-contrast: more) {
    result: 4px; /* overrides the 2px above when the condition matches */
  }
}

.btn:focus-visible {
  outline: --ring() solid; /* parentheses are required even with no arguments */
}
  • Functions can use if(), and can read custom properties set on the element.
  • Parameters can be typed: @function --target(--h <length>) returns <length>. Pass the wrong type and the whole property becomes invalid.

3. Try it

This button's size, transition and focus outline are all written with if() and @function. A page can't switch your system's "reduce motion" or "more contrast" settings, so the demo simulates them with custom properties; if your system actually has them turned on, they apply too:

Live demo: every style on this button is written with if() and @function. Change the settings to see the computed values

Button size
Simulated user preferences
How the focus outline is written

Tab to the sample button to see its real focus outline; hover over it to see the transition.

Minimum height (--if-demo-target() guarantees at least 24px)
—
Inline padding
—
Transition duration
—
Focus outline
—

Detecting (requires JavaScript)

This is an interactive demo. Try it on the web page.

Things to try:

  • Choose small: the button is written as 16px tall, and --if-demo-target() raises it to 24px.
  • Check "Reduce motion": the transition becomes 0s, and the button no longer grows on hover.
  • Check "More contrast" and Tab to the button: the focus outline gets thicker and switches to the text color.
  • Switch to "if() without else", uncheck "More contrast", then Tab to the button: the focus outline is gone. The next section explains why.

The demo's full code

Here's the demo, step by step.

Step 1: HTML, with custom properties as switches

The outer div sets three custom properties as inline styles; the demo's options just change these values. data-ring switches between the "with else" and "without else" outline rules:

<div
  class="if-fn-demo__stage"
  data-ring="else"
  style="--size: small; --sim-motion: none; --sim-contrast: none;"
>
  <button type="button" class="if-fn-demo__btn">Sample button</button>
</div>
  • --size: the button size, one of small, medium or large.
  • --sim-motion: simulates "reduce motion"; reduce when checked.
  • --sim-contrast: simulates "more contrast"; more when checked.

Custom properties inherit, so they're set on the wrapper and the button inside can still check them with style().

Step 2: define two functions

/* Guarantee a target of at least 24px (WCAG 2.5.8).
   --h is the height you want; anything below 24px returns 24px */
@function --if-demo-target(--h) {
  result: max(24px, var(--h));
}

/* 0s when the user asks for reduced motion; otherwise the --t passed in */
@function --if-demo-motion(--t) {
  result: if(
    /* the demo's simulated switch, or the real system setting: either one is enough */
    style(--sim-motion: reduce) or media(prefers-reduced-motion: reduce): 0s;
    else: var(--t)
  );
}

@function is global: the whole site shares one namespace, so I prefixed the names with --if-demo- to avoid clashing with functions elsewhere.

Step 3: write the unsupported styles first

.if-fn-demo__btn {
  min-block-size: 2.5rem;
  padding-inline: 1rem;
  /* other visual styles omitted */
  transition: transform 300ms ease;

  &:hover {
    transform: scale(1.08); /* grow on hover, to show the transition */
  }

  &:focus-visible {
    /* --outline-color is my site's focus color, stored as three oklch channels */
    outline: 3px solid oklch(var(--outline-color));
    outline-offset: 2px;
    /* My site draws button focus rings with box-shadow globally. Turn it off here,
       or you can't see the outline disappear (more in section 4) */
    box-shadow: none;
  }
}

Browsers without if() only use this part: the button's size and focus outline are fixed, but the outline is always visible.

Step 4: override with if() and the functions

/* Only browsers that support if() get in here */
@supports (color: if(else: red)) {
  .if-fn-demo__btn {
    /* Pick a height by size with if(), then let the function guarantee
       at least 24px: small is written as 16px but ends up 24px */
    min-block-size: --if-demo-target(
      if(style(--size: small): 16px; style(--size: large): 56px; else: 40px)
    );
    /* Inline padding and font size switch by size too; no match means medium */
    padding-inline: if(
      style(--size: small): 0.5rem;
      style(--size: large): 1.5rem;
      else: 1rem
    );
    font-size: if(
      style(--size: small): 0.875rem;
      style(--size: large): 1.25rem;
      else: 1rem
    );
    /* Let the function decide: 0s with reduced motion, otherwise 300ms */
    transition-duration: --if-demo-motion(300ms);
  }

  /* The right way: with more contrast, a 4px outline in the text color;
     otherwise the usual 3px focus color */
  [data-ring="else"] .if-fn-demo__btn:focus-visible {
    outline: if(
      style(--sim-contrast: more) or media(prefers-contrast: more):
        4px solid oklch(var(--title-color));
      else: 3px solid oklch(var(--outline-color))
    );
  }

  /* The deliberately wrong way: no else.
     When the condition is false, outline resets to its initial value and disappears */
  [data-ring="none"] .if-fn-demo__btn:focus-visible {
    outline: if(
      style(--sim-contrast: more) or media(prefers-contrast: more):
        4px solid oklch(var(--title-color))
    );
  }
}

Why wrap it in @supports? Because the two outline rules contain var(), browsers without support can't drop them at parse time, so they'd override the focus-outline fallback from step 3 and the outline would disappear. The other rules have no var(), so browsers without support drop them at parse time and the fallback still applies, but wrapping everything together makes it easier to tell the new code from the fallback. Section 5 explains this further.

The values shown in the demo come from reading the button's computed min-height, padding-inline-start and transition-duration with JavaScript's getComputedStyle(). The focus outline only exists during :focus-visible, so the demo also has a hidden element using the same outline rule, so the value can be read when the button isn't focused.

Your site's own preference toggle

The way the demo simulates preferences is useful in its own right:

transition-duration: if(
  style(--sim-motion: reduce) or media(prefers-reduced-motion: reduce): 0s;
  else: 300ms
);

If your site has its own "reduce motion" toggle, store it as a custom property (for example, set --motion: reduce on <html>) and put it in the same condition as the system setting: it works whether the user turns it on in the system or on your site.

4. Pitfalls

1. An if() without else resets the property to its initial value

This is the one I most want to warn about:

.btn:focus-visible {
  outline: 2px solid blue;
  outline: if(style(--hc: 1): 4px solid black);
}

I expected the previous 2px solid blue to apply when the condition is false. In my test the result was outline-style: none; the focus outline was gone entirely.

When no branch in if() matches and there's no else, the whole value becomes invalid. That only happens at computed-value time, when the later declaration has already beaten the earlier one in the cascade, so the browser doesn't go back to it and falls back to the property's initial value instead. The initial value of outline-style is none.

It's less obvious with just outline-width: its initial value is medium (3px), so the outline is still there, just not the width you wrote.

Without a focus outline, keyboard users can't tell where they are (WCAG 2.4.7). So for any accessibility-related property, always give if() an else.

There's one more thing to watch for when testing: a missing outline can be hidden by other focus styles. That's what happened with my first version of the demo: my site draws button focus rings with box-shadow globally, so even though the demo's outline was already none, a yellow ring still showed up. When you check, confirm the computed outline-style in DevTools instead of just looking at the page.

2. A misspelled function name does the same

.box {
  min-height: 30px;
  min-height: --nope(10px); /* no such function */
}

In my test min-height was 0px, not 30px. Browsers that support @function accept any ---prefixed function name at parse time and only find out it doesn't exist at computed-value time. CSS.supports("width: --whatever(1)") returns true in both 141 and 153.

3. style() compares text, not values

.a { --size: Small; }  /* not equal to small */
.b { --gap: 8.0px; }   /* not equal to 8px */

For unregistered custom properties, style() compares the text: a different case, or 8.0px versus 8px, doesn't match. Leading and trailing whitespace is ignored.

Once you register the type with @property, it compares computed values, so 8.0px and 0.5rem (with a 16px root font size) both equal 8px.

4. style() checks the element itself, but custom properties inherit

style() inside if() checks the element's own custom properties. But custom properties inherit by default, so if a parent sets --size: small, style(--size: small) is true for every descendant. The demo sets it on the wrapper and the button inherits it.

5. Range comparisons need a newer version

Range comparisons like style(--n > 2) work in 153 in my tests; 141 doesn't support them and goes straight to else.

5. When it isn't supported

Feature detection:

@supports (color: if(else: red)) {
  /* if() styles */
}
CSS.supports("color: if(else: red)"); // if()
"CSSFunctionRule" in window;           // @function

CSS's @supports at-rule(@function) can also detect @function, but in my tests 141 supports @function without supporting the at-rule() syntax, so it returns false; only 153 returns true.

Be careful with var() in the fallback:

  • No var() in the if(): browsers without support drop the declaration at parse time, so the previous fallback line applies.
  • var() inside the if() or the function call: any value containing var() is accepted at parse time and only fails at computed-value time, so you get the same result as the first pitfall: the initial value.

I don't have a browser without support to test with, so the second point is inferred from how var() works. To be safe, the demo wraps every if() rule in @supports and writes fixed styles outside it as the fallback, so the focus outline is always there.

Conclusion

In short:

  • if() puts conditions inside properties, so reduced motion and more contrast no longer need their own @media blocks.
  • @function turns repeated calculations like max(24px, …) into functions, so the minimum target size is written once.
  • style(--custom-property) or media(…) lets your site's own preference toggle and the system setting share one condition.
  • An if() without else, or a misspelled function name, resets the property to its initial value, which can make the focus outline disappear.
  • style() compares unregistered custom properties as text; to compare values, register them with @property first.
  • When the value contains var(), wrap it in @supports instead of relying on the previous line.

if() and @function let accessibility styles live in one place. Just remember that the more conditions you write, the more important it is that every path has a result.