Defining Your Own Environment Variables

JS
Jesse Stewart
Apr 13, 20263 min read

Defining Your Own Environment Variables

TL;DR

The first post on env() was about what the browser gives you. This one is about a build-step pattern that lets you author your own design tokens in TypeScript and reference them in CSS, including inside @media rules where custom properties don't work. PostCSS does the substitution before your CSS ever reaches the browser, so what ships is just inlined values. The pattern extends to z-index scales, animation durations, layout dimensions, and any token you'd otherwise duplicate across files. It's a build-time tool, not a runtime one. Pair it with custom properties, don't replace them.

The Problem You Didn't Know You Had

Here's a thing you've definitely written, possibly today:

Loading...
Loading...

There's nothing connecting them. The day someone bumps BP_MD to 800 and forgets to update the CSS, you ship a layout where useMediaQuery('(min-width: 800px)') returns true but the matching rule never kicks in. That kind of bug walks right through code review.

The natural reaction is to reach for CSS custom properties:

Loading...

It doesn't work. Custom properties aren't allowed inside @media rules. They're resolved per-element, and media queries are evaluated before any element exists. This is a hard limitation of the spec, not a missing feature waiting to land.

env() doesn't have that problem natively, but only for browser-defined variables like safe-area-inset-top. You can't actually author your own env() variables today and have a browser resolve them. What you can do is hijack the syntax at build time.

The Fix

A PostCSS plugin reads your tokens module at build time, finds the env() references in your CSS, and replaces them with literal values before the stylesheet ever ships. By the time the browser parses anything, the env() calls are gone, swapped out for static numbers. That's why this works inside @media: the browser isn't actually evaluating a variable there, just reading inlined values.

With a PostCSS plugin, you can author this:

Loading...

And feed it values from a JS/TS module:

Loading...

The plugin reads the module at build time, replaces every env(--bp-md) with 768px, and your CSS ships with the values inlined. Meanwhile, your TS code imports the same module and uses the same values for matchMedia, scroll calculations, or anywhere else. One file is the source of truth, and the "I updated it in one place but forgot the other" bug class disappears.

There are a handful of plugins in this space. @csstools/postcss-design-tokens is the one I've reached for, but the broader pattern works with anything that does env()-style substitution at build time. Pick whatever fits your build.

If "PostCSS" sounds like setup overhead, it usually isn't. Autoprefixer is a PostCSS plugin. Tailwind, Vite, Next.js, and Parcel all have PostCSS in the pipeline by default. If you're shipping modern CSS, you're almost certainly already running it. Adding a plugin is a config change, not a new tool.

env() vs. Custom Properties: When Each Wins

These tools are complementary, not competitive. Most projects need both.

Custom properties (var(--name)) are runtime values. They cascade, they scope to elements, you can change them with JavaScript, and they're how every modern theming system works. If a user can flip a switch and change the value, it's a custom property.

env() in this pattern is a build-time value. PostCSS swaps it out before the browser ever sees it, so by the time anything renders, it's just a static number in your stylesheet. That makes it global by default, unchangeable at runtime, and frozen the moment your bundle ships. Native env() is technically a runtime mechanism in browsers (the safe-area insets update when device orientation changes, for instance), but you're not using it that way here. You're using the syntax as a hook for build-time substitution.

Quick mental model:

var(--name)env(--name) (this pattern)
ResolvedRuntime, by browserBuild time, by PostCSS
ScopeCascadesGlobal
Changes at runtimeYesNo
Works in @mediaNoYes
Use caseThemes, user settingsStatic design tokens

The rule of thumb: if a designer would call it a token, it's probably env(). If a user could change it, it's a custom property. Theme colors are custom properties because users toggle dark mode. Breakpoint values are env() because they don't change after the build.

Beyond Breakpoints

Once the pipeline is in place, the same pattern picks off a bunch of other duplicated-token problems.

Z-index scale. Define --z-modal, --z-toast, --z-tooltip once, use them in CSS and in any JS that needs to compute stacking dynamically. No more z-index: 9999 arms races.

Animation durations. Framer Motion reads tokens['--motion-short'] from the same module that CSS reads env(--motion-short). When you tune the timing, every transition, JS-driven and CSS-driven, moves together. This is the one I miss most when working without it.

Layout dimensions. Header height, sidebar width, footer height. These show up in CSS layout AND in JS scroll math (anchor offsets, sticky positioning, intersection observer thresholds). The "I changed the header to 64px and now my scroll-to-section is off by 8px" bug stops happening.

Anything you'd find on a designer's token page is a candidate: brand colors, spacing scale, container widths. The bigger your project, the more this earns its keep.

All of this rides on PostCSS doing the work at build time. That's a fine tradeoff today. The tooling is stable, the DX is good, and you mostly forget the build step is there. But it's the kind of pattern that feels like it should be a language feature.

Where This Is Going

There are early discussions and draft specs exploring author-defined env() variables, but nothing usable in browsers yet. Don't bet a project on it landing soon.

There's also @custom-media, which would address the breakpoint case natively:

Loading...

It's been in the spec for years, but the rollout has stalled. Firefox is the only browser that's shipped anything, and it's behind a flag (layout.css.custom-media.enabled as of Firefox 148). Chrome and Safari haven't picked it up, and there's open skepticism about whether they will. There's a postcss-custom-media plugin that polyfills the syntax, but at that point you're back in build-tool territory anyway.

The honest read: the platform isn't catching up here as fast as you'd want. The build-step pattern in this post isn't a stopgap. It's likely to be the practical answer for years.

Today it's a build-tool pattern. Tomorrow it's probably still a build-tool pattern, just with better syntax options.