---
title: "Writing Accessibility into CSS with if() and @function: Reduced Motion, More Contrast, Minimum Target Size"
description: "CSS if() puts conditions inside a property value, and @function turns repeated calculations into custom functions. This post uses a live demo to write reduced motion, a higher-contrast focus outline and a 24px minimum target size as conditions and functions, and sums up the pitfalls I hit while testing: an if() without else can make the focus outline disappear entirely."
canonical_url: "https://neillin.com/en/blog/css-if-function/"
---

> Index of every page on this site: [llms.txt](https://neillin.com/llms.txt)

> 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](https://neillin.com/en/blog/css-techniques-checklist), 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:

<feature-support features="css-if,css-function" notice="Your browser doesn't support if() or @function, so the button in the demo below falls back to fixed styles.">



</feature-support>

## 1. if(): conditions inside a property

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

```css
.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:

```css
.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:

<table>
<thead>
  <tr>
    <th>
      Condition
    </th>
    
    <th>
      What it checks
    </th>
    
    <th>
      Example
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        media()
      </code>
    </td>
    
    <td>
      A media query
    </td>
    
    <td>
      <code>
        media(prefers-contrast: more)
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        supports()
      </code>
    </td>
    
    <td>
      Whether the browser supports a feature
    </td>
    
    <td>
      <code>
        supports(width: stretch)
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        style()
      </code>
    </td>
    
    <td>
      The element's own custom properties
    </td>
    
    <td>
      <code>
        style(--size: small)
      </code>
    </td>
  </tr>
</tbody>
</table>

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

```css
.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:

```css
/* 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:

```css
/* 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:

<if-function-demo>



</if-function-demo>

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:

```html
<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**

```css
/* 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**

```css
.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**

```css
/* 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:

```css
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:

```css
.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

```css
.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

```css
.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:

```css
@supports (color: if(else: red)) {
  /* if() styles */
}
```

```js
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.

## Related reading

- [One Line to Change Opacity with alpha(), One Line to Roll Back with revert-rule: Testing Two Small CSS Tools](https://neillin.com/en/blog/css-alpha-revert-rule/)
- [Zoomed In and the Top of Your Centered Content Is Gone? Make It Reachable with safe center](https://neillin.com/en/blog/css-safe-alignment/)
- [Modern CSS & HTML Cheat Sheet: 115 Features, Support & What to Try Next](https://neillin.com/en/blog/css-techniques-checklist/)

## Related links

- [CSS Values and Units Level 5 (if())](https://drafts.csswg.org/css-values-5/#if-notation)
- [CSS Functions and Mixins (@function)](https://drafts.csswg.org/css-mixins-1/)
- [MDN — if()](https://developer.mozilla.org/en-US/docs/Web/CSS/if)
- [WCAG 2.2 — Understanding 2.3.3 Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions)
- [WCAG 2.2 — Understanding 2.4.7 Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible)
- [WCAG 2.2 — Understanding 2.5.8 Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum)
- [Can I use](https://caniuse.com)

## Sitemap

See the full [sitemap](https://neillin.com/sitemap.md) for all pages.
