This article was translated from Japanese by AI and may contain inaccuracies. For the most accurate content, please refer to the original Japanese version.
たこ焼きのイラスト

Defining Media Query Presets with @custom-media

Repeating a media query across CSS files means editing every copy when a breakpoint changes. @custom-media names a media query like --md so input and breakpoint presets live in one place. This article covers its syntax and specified behavior.

Responsive sites usually share a common set of breakpoints. For example, a project might decide that phones are below 48rem, tablets are 48rem and up, and desktops are 64rem and up. If you write a breakpoint like (width >= 48rem) directly in each component's CSS, changing that rule means hunting down and rewriting every occurrence. Miss just one, and you end up with a hard-to-find bug where the layout breaks only at certain widths.

Preprocessors such as Sass have traditionally solved this problem. The following example stores the breakpoint value in a Sass variable and wraps the media query in a mixin.

$bp-md: 48rem;
 
@mixin md {
  @media (width >= $bp-md) {
    @content;
  }
}
 
.cards {
  display: grid;
 
  @include md {
    grid-template-columns: repeat(2, 1fr);
  }
}

Every @include md is expanded to @media (width >= 48rem) at build time. You can change the breakpoint in one place, but the downside is that you have to use Sass-specific syntax.

@custom-media is an at-rule that brings this to CSS itself, letting you give a media query a name like --md. If you collect your named media queries in a single file, you can treat them as presets shared across the whole project.

This article explains the syntax of @custom-media and how it behaves according to the spec.

Note

As of November 4, 2026, Firefox is the only browser that implements @custom-media, and it works only when layout.css.custom-media.enabled is set to true in about:config. Chrome and Safari do not support it. The spec, Media Queries Level 5, is a Working Draft, and some points, such as how definition order is handled, are still undecided.

Custom properties don't work in media queries

When you want to manage breakpoints in one place, custom properties are the first thing that comes to mind. However, you can't use var() inside a media query. CSS Custom Properties for Cascading Variables Module Level 1 specifies that var() can only be used in the values of properties on elements, so it can't appear anywhere other than a property value, such as in property names or selectors.

In fact, the following media query, which tries to share the breakpoint through a custom property, does not match even when the viewport is 48rem or wider.

:root {
  --bp-md: 48rem;
}
 
/* var() is not resolved inside media queries */
@media (width >= var(--bp-md)) {
  p {
    color: green;
  }
}

This is why breakpoints can't be shared with CSS alone, and why build-time expansion like the Sass approach shown in the introduction has been the go-to solution.

Basic syntax of @custom-media

The syntax of @custom-media is as follows. After the name, you write either a media query list, true, or false.

@custom-media <extension-name> [ <media-query-list> | true | false ];

Like a custom property, <extension-name> is a name that starts with --. The following example gives (width >= 48rem) the name --md.

@custom-media --md (width >= 48rem);
 
@media (--md) {
  .cards {
    grid-template-columns: repeat(2, 1fr);
  }
}

When you wrap a defined name in parentheses and put it where a media feature would go, as in @media (--md), the rule applies whenever the media query associated with --md matches. Names can only be used in this boolean form, like (--md). Comparing them against a value, as in (--md: 1) or (--md > 1), is a syntax error.

Just like regular media features, you can combine them with and, or, and not.

@media (--md) and (--mouse) {
  /* When the width is 48rem or more and the user is using a mouse */
}
 
@media not (--md) {
  /* When the width is less than 48rem */
}

A @custom-media definition can reference other @custom-media names. However, cycles where a name refers to itself directly or indirectly are forbidden, and every name involved in a cycle is treated as undefined.

Behavior defined by the spec

This section covers behaviors worth knowing when you use @custom-media.

Evaluated logically, not by textual substitution

A @custom-media name is not replaced with the text of its media query. Instead, it is evaluated as a single condition. The spec gives the following example.

/* --modern targets devices that support color or hover */
@custom-media --modern (color), (hover);
 
@media (--modern) and (width > 1024px) {
  .a {
    color: green;
  }
}

A comma-separated media query list means "any of these matches." Therefore, (--modern) and (width > 1024px) is equivalent to ((color) or (hover)) and (width > 1024px).

Toggling definitions with true and false

If you write true or false instead of a media query, the name becomes a condition that always matches or never matches.

@custom-media --md true;

By temporarily switching a preset's definition to true or false, you can check the styles that apply when that condition matches without changing the viewport width or device.

An undefined name is not the same as false

When you reference a name that hasn't been defined, the condition is treated as "unknown." A note in Media Queries Level 5 explains that, for error handling purposes, an undefined media feature is different from a media feature that evaluates to false. The details are defined in Media Queries Level 4, 3.2. Error Handling. The difference shows up when you combine the name with not.

@custom-media --off false;
 
/* Matches: not false is true */
@media not (--off) {
}
 
/* Does not match: not unknown is still unknown */
@media not (--undefined) {
}

Redefining a name uses the last definition

When there are multiple @custom-media rules with the same name, the spec says only the last one is used.

@custom-media --md (width >= 48rem);
@custom-media --md (width >= 50rem); /* This one is used */

Definitions written after a reference are still undecided

One point still under discussion in the spec is how to handle a case where a name is referenced, as in @media (--late), before @custom-media --late is defined.

@media (--late) {
  .a {
    color: green;
  }
}
 
@custom-media --late true;

In August 2025, the CSS Working Group (CSSWG) resolved in Issue #12536 that "@custom-media needs to be declared before use." Because @custom-media can also be written inside @media, cycles can occur, such as defining --a inside @media (--a). If the stylesheet is read from the top and each reference uses whatever definitions have been found so far, these cycles can be resolved without multiple passes.

However, in CSS it is normal for definitions written after a reference to take effect, as with @font-face. Rules that depend on declaration order would force authors to learn a model that differs from the rest of CSS, so this resolution was reverted in Issue #13041 in January 2026. Instead, the group decided to collect all definitions first and then resolve name collisions, bringing the behavior closer to "the last definition wins." In July 2026, however, examples were reported where this approach still produces different results depending on evaluation order, so the details have yet to be settled.

Expanding with PostCSS

postcss-custom-media is a PostCSS plugin that expands @custom-media names into the media queries they define. Since @custom-media isn't widely supported yet, this is a good way to start using it today. Install the required packages.

npm install -D postcss postcss-cli postcss-custom-media @csstools/postcss-global-data

By passing presets.css to @csstools/postcss-global-data, you can provide the definitions needed for the transformation without including them in the output.

postcss.config.mjs
import postcssGlobalData from "@csstools/postcss-global-data";
import postcssCustomMedia from "postcss-custom-media";
 
export default {
  plugins: [
    // Place this before postcss-custom-media
    postcssGlobalData({ files: ["./presets.css"] }),
    postcssCustomMedia(),
  ],
};

presets.css defines presets for input methods, breakpoints, and user preferences in one place, as shown below.

presets.css
/* Input methods */
@custom-media --mouse (hover: hover) and (pointer: fine);
@custom-media --touch (hover: none) and (pointer: coarse);
 
/* Breakpoints */
@custom-media --md (width >= 48rem);
@custom-media --lg (width >= 64rem);
 
/* User preferences */
@custom-media --motion-ok (prefers-reduced-motion: no-preference);
@custom-media --high-contrast (prefers-contrast: more), (forced-colors: active);

In app.css, reference them like @media (--md).

app.css
.cards {
  display: grid;
  gap: 1rem;
}
 
@media (--md) {
  .cards {
    grid-template-columns: repeat(2, 1fr);
  }
}
 
@media (--lg) {
  .cards {
    grid-template-columns: repeat(3, 1fr);
  }
}
 
.button {
  padding: 0.5rem 1rem;
  border: 1px solid transparent;
  background-color: rebeccapurple;
  color: white;
}
 
@media (--touch) {
  .button {
    min-block-size: 48px;
  }
}
 
@media (--mouse) {
  .button:hover {
    background-color: indigo;
  }
}
 
@media (--motion-ok) {
  .button {
    transition: background-color 0.2s;
  }
}
 
@media (--high-contrast) {
  .button {
    border-color: currentColor;
  }
}

Transform app.css with postcss-cli. The postcss command automatically loads postcss.config.mjs from the current directory.

npx postcss app.css -o dist/app.css

In the generated dist/app.css, references like @media (--md) are replaced with the media queries they define. The following is an excerpt of the output.

dist/app.css
@media (width >= 48rem) {
  .cards {
    grid-template-columns: repeat(2, 1fr);
  }
}
 
@media (hover: none) and (pointer: coarse) {
  .button {
    min-block-size: 48px;
  }
}
 
@media (prefers-contrast: more),(forced-colors: active) {
  .button {
    border-color: currentColor;
  }
}

If you use postcss-preset-env, postcss-custom-media is enabled in its default configuration.

Summary

  • @custom-media is an at-rule that gives a media query a name like --md, and the name can only be referenced in boolean form, as in @media (--md)
  • Because var() can only be used in property values, you can't share media query breakpoints through custom properties
  • A @custom-media name is evaluated as a single condition rather than by textual substitution, so combining a name defined as a list with and doesn't change its meaning
  • You can toggle a definition with true and false, and an undefined name is treated as unknown rather than false, so adding not still doesn't make it match
  • When a name is redefined, the last definition is used. As for definitions written after a reference, the August 2025 "declare before use" resolution was reverted in January 2026, and the CSSWG is still discussing it
  • Collecting presets for input methods, breakpoints, and user preferences in a single file means there is only one place to edit when a condition changes
  • As of November 2026, the only implementation of @custom-media is Firefox behind a flag, so if you want to use it today, expand it into regular @media rules with postcss-custom-media and @csstools/postcss-global-data

References

Comprehension check

Answer the following questions to deepen your understanding of the article.

Given @custom-media --modern (color), (hover);, which condition is equivalent to @media (--modern) and (width > 1024px)?

  • (color), (hover) and (width > 1024px)

    Try again

    This interprets the definition as a textual substitution. Under that reading, (color) alone would be enough to match. The spec evaluates the name logically as a single condition.

  • (color) and (hover) and (width > 1024px)

    Try again

    A comma-separated list means "any of these matches," so it does not become an and that requires both (color) and (hover).

  • ((color) or (hover)) and (width > 1024px)

    Correct!

    @custom-media names are evaluated logically, and the list is treated as a single or group. The spec includes this same example.

  • (color) or (hover) or (width > 1024px)

    Try again

    Because the conditions are combined with and, the width condition must always be satisfied. Matching just one of them is not enough.

How is @media not (--undefined) evaluated when --undefined has never been defined?

  • An undefined name is treated as false, so not makes it match

    Try again

    That is the behavior of not (--off) after defining @custom-media --off false;. An undefined name is treated as unknown, not false.

  • An undefined name is treated as unknown, and not leaves it unknown, so it does not match

    Correct!

    As the note in the spec says, an undefined media feature is distinct from one that evaluates to false. not unknown remains unknown, so the rule is not applied.

  • An undefined name is treated as true, so not makes it not match

    Try again

    An undefined name is never treated as true. It is treated as unknown, so adding not still does not make it match.

  • The entire stylesheet becomes a syntax error and every rule is dropped

    Try again

    Referencing an undefined name only makes that condition unknown. It has no effect on other rules.