Apply

Browser support in LSCSS projects

Policy belongs in your team’s written strategy. This page covers the engineering side: how to express targets with browserslist, how tools consume them, and how to layer progressive CSS without turning every page into a compatibility puzzle.

For product-level decisions and common mistakes, read Browser Support Strategy first—then wire the targets below into your build.

Browserslist

Browserslist is a shared query language for “which browsers does this project run on?” PostCSS (Autoprefixer), Lightning CSS, SWC/Babel, and other tools read the same configuration so prefixes and transpilation stay aligned with what you actually support.

Put queries in a .browserslistrc at the repository root, or inpackage.json under a browserslist key. Avoid duplicating different targets per package in a monorepo unless you intend different CSS output per app—shared design tokens and CSS bundles should usually share one resolved list.

BASH code example
# https://github.com/browserslist/browserslist
# One shared query list for Autoprefixer, Lightning CSS, @babel/preset-env, etc.

last 2 versions
> 0.5%
not dead

Queries are data, not superstition: combine coverage stats (> 0.5%), recency (last 2 versions), and explicit exclusions (not dead) with what your analytics and contracts require—or use feature queries such assupports css-grid when your support matrix is capability-first rather than version-first. Run the command below whenever you change the file.

Print resolved browsers
pnpm exec browserslist

How the build uses the same targets

Once browserslist is set, keep these behaviours consistent:

  • Vendor prefixes. Autoprefixer (or Lightning CSS with the right options) uses the list to decide which prefixes to emit. If the list is stale, you either ship unnecessary prefixes or miss real devices.
  • Syntax lowering. Some pipelines transpile nesting, custom media, or other syntax. Those steps should assume the same browser surface as your prefix step.
  • CI. Run production builds in CI with the same environment variables and config paths as local machines so browserslist resolution does not drift.

More detail on PostCSS plugins, Stylelint, and monorepo caveats lives under Tooling.

Custom media queries and the build step

Custom media (@custom-media) lets you name breakpoints and preference queries once—usually in the settings layer—and reference them in component CSS as @media (--desktop) instead of repeating magic numbers. Directional names such as --from-tablet and accessibility queries such as --reduced-motion can sit beside layout breakpoints in the same file.

No major browser implements @custom-media yet. What ships in production is ordinary @media after your build expands the aliases (PostCSS, Lightning CSS, or equivalent). You cannot paste custom-media syntax into a stylesheet that reaches the browser without that transform—unlike cascade layers or :is(), there is no progressive runtime fallback.

CSS code example
/* settings layer — define once */
@custom-media --desktop (width >= 1280px);
@custom-media --reduced-motion (prefers-reduced-motion: reduce);

/* component CSS — readable at authoring time */
.product-card {
    @media (--desktop) {
        grid-template-columns: repeat(3, 1fr);
    }

    @media (--reduced-motion) {
        transition: none;
    }
}

/* build output — what the browser actually parses */
/* @media (width >= 1280px) { … } */
/* @media (prefers-reduced-motion: reduce) { … } */

The tradeoff is explicit: every path that produces CSS must run the expand step—local dev, CI, preview, and any CMS or micro-frontend that might inject styles. If one pipeline skips the build while the rest of the repo uses @media (--tablet), that CSS will not parse.

When the build step is worth it

For a small site with one author and few breakpoints, a tool only for custom media may not pay for itself. A single shared constants file, comments, or careful discipline can be enough.

For larger codebases—many contributors, long-lived products, or a shared design system—the step is usually worth it:

  • One source of truth. Change a breakpoint once; every component that references--desktop stays aligned.
  • Readable reviews. @media (--until-desktop) states intent; scattered1279px does not.
  • Fewer silent drifts. Without named media, “the tablet breakpoint” slowly becomes three different numbers in three folders.
  • Preference queries as first-class. Treat prefers-reduced-motion like a layout breakpoint instead of bolting one-off media blocks into random files.

Wire custom media into the same browserslist-backed pipeline as prefixes and other syntax lowering so expanded queries match the browsers you actually support. Examples and anti-patterns for naming breakpoints live under Tooling → Custom media queries.

@supports and progressive enhancement

Browserslist answers “what do we transform or prefix?”. In authored CSS,@supports answers “does this runtime understand this capability?” Use it for meaningful fallbacks—layout modes, selectors, units—not for sprinkling forks around every new property.

CSS code example
@supports (display: grid) {
    .product-grid {
        display: grid;
        gap: var(--space-m);
    }
}

@supports not (display: grid) {
    .product-grid {
        display: flex;
        flex-wrap: wrap;
        gap: var(--space-m);
    }
}

Prefer simpler baselines first: solid layout and readable typography without advanced features, then enhance where support exists. That matches how LSCSS uses layers: keep the stable story in lower layers and isolate experiments.

Platform-forced CSS ordering

Some progressive CSS has to be written in an order that looks unusual by normal LSCSS standards. Native popovers, customisable selects, and animated disclosure content all have browser parsing or specificity rules that matter more than tidiness. Treat those cases as documented platform exceptions, not hacks.

  • Write the fallback first so older browsers keep a complete control.
  • Gate enhanced selectors and newer properties with @supports where practical.
  • Keep the exception in the owning component layer, not in hacks.
  • When a standalone @starting-style block repeats the open selector, place it after the open rule because the selectors have the same specificity.
  • Add a short “do not reorder” comment when source order is part of the behaviour.

Popover entry and exit transitions

Popovers need an ordinary closed style first. The enhanced block can then transition continuous properties such as opacity and transform, and discrete properties such as display and overlay.

CSS code example
/* Fallback styles for old browsers first. */
.menu-popover {
    opacity: 0;
    transform: translateY(var(--space-2xs));
}

/* Progressive enhancement for browsers with popover transition support. */
@supports (transition-behavior: allow-discrete) {
    .menu-popover {
        transition:
            opacity var(--transition-s),
            transform var(--transition-s),
            overlay var(--transition-s) allow-discrete,
            display var(--transition-s) allow-discrete;
    }

    /* Open styles first, then exit/initial styles,
     * then @starting-style last because of specificity.
     * Do not change the order of these rules. */
    .menu-popover:popover-open {
        opacity: 1;
        transform: translateY(0);
    }

    .menu-popover {
        opacity: 0;
        transform: translateY(var(--space-2xs));
    }

    @starting-style {
        .menu-popover:popover-open {
            opacity: 0;
            transform: translateY(var(--space-2xs));
        }
    }
}

Customisable selects

Customisable select support is still a progressive enhancement. The native select must remain useful before any enhanced picker styles run. Use a small default gate for the core feature, then add stricter selector checks when the component also depends on ::picker-icon, :open, ::checkmark, or <selectedcontent>.

CSS code example
/* Fallback styles for old browsers first. */
.form-select > select {
    inline-size: 100%;
    min-block-size: 2.75rem;
    padding: var(--space-xs) var(--space-s);
}

/* Progressive enhancement for browsers with full customisable select support. */
@supports (appearance: base-select) and selector(::picker(select)) {
    .form-select > select,
    .form-select > select::picker(select) {
        appearance: base-select;
    }

    .form-select > select {
        padding-inline-end: var(--space-xl);
    }

    .form-select > select::picker(select) {
        border: var(--border-s) solid var(--c-border);
        border-radius: var(--br-m);
        background: var(--c-surface);
    }
}

Disclosure content animation

Disclosure animation has the same ordering problem: open styles first, exit or initial styles second, and @starting-style last. The fallback remains native <details> behaviour.

CSS code example
/* Fallback styles for old browsers first.
 * The disclosure opens and closes with native details behaviour. */
.disclosure > details {
    border: var(--border-s) solid var(--c-border);
}

@supports selector(::details-content) {
    /* Do not change the order of these rules. */
    :root {
        interpolate-size: allow-keywords;
    }

    details[open]::details-content {
        block-size: auto;
        block-size: calc-size(auto, size);
        opacity: 1;
        content-visibility: visible;
    }

    details::details-content {
        transition:
            var(--transition-m) block-size,
            var(--transition-m) opacity,
            var(--transition-m) content-visibility allow-discrete;
        opacity: 0;
        overflow: hidden;
        block-size: 0;
        content-visibility: hidden;
    }

    @starting-style {
        details::details-content {
            block-size: 0;
            opacity: 0;
            content-visibility: visible;
        }
    }
}

Current platform references worth checking before copying these into a production component: MDN on @starting-style, transition-behavior, customisable select elements, ::details-content, and @supports.

Modern features this methodology leans on

LSCSS examples and site CSS assume a reasonably current evergreen baseline. Your exact floor is a business decision; these are the capabilities you will see referenced most often in Apply and Learn:

  • Cascade layers (@layer) for predictable override order
  • Custom properties for tokens and theme boundaries
  • Registered custom properties (@property) for typed tokens, inheritance control, and animatable custom values where the project target allows it
  • Logical properties and values for direction-aware layout
  • Custom media (@custom-media) for named breakpoints— authored in CSS, delivered as plain @media via the build; see Custom media queries and the build step
  • Container queries and nesting for responsive, readable component CSS
  • Selector features:is(), :where(), :not(), and:has() where examples stay concise.:has() has a newer support curve than:is(), :where(), or :not()—confirm it against your resolved browserslist before critical UI depends on it.
  • @scope where discussed explicitly—know your target support before relying on it in production
  • Platform interaction features such as @starting-style, transition-behavior, ::details-content, and customisable selects where the fallback and rule order are documented.

If you must support older engines, document which features are authored behind build transforms, which are gated with @supports (for example@supports selector(:has(*)) for relational selectors), and which are simply off the table until the baseline moves.Native CSS features turns that policy into feature-by-feature LSCSS recommendations.

Support matrix template

Browserslist tells the build what to transform; a written matrix tells people what you promise users. Keep it next to the code (for exampledocs/browser-support.md or your team handbook), link it from onboarding, and update it when the baseline changes—not only at project kickoff.

Use three tiers so “supported” is not argued from memory in every review:

  • Fully supported — critical journeys meet your documented baseline; defects are release blockers.
  • Best effort — core tasks work; minor visual differences are acceptable.
  • Not supported — no test commitment; degradation or blocking is acceptable.

Two ways to document the baseline

You do not have to anchor policy on version numbers. Two approaches are common; pick one as primary and add the other when it helps.

ApproachWhat you documentBest when
Feature baselineCapabilities the product requires—CSS, JavaScript, and platform APIs—and how you deliver or fall back (native, build, polyfill, transpile)Progressive enhancement, polyfill/transpile strategies, or when “Chrome 120” is less useful than “fetch required” or “container queries required”
Version / environment listNamed browsers, minimum versions, webviews, and tiersContracts, QA device labs, analytics-driven floors, or embedded shells you must name explicitly

The same idea applies outside CSS. A feature baseline forJavaScript might list fetch, ES modules,dynamic import(), or observers your app relies on—with delivery marked as native, transpiled, or polyfilled—instead of debating “which Safari version” in every ticket. You stop guessing what “we support last two versions” means for a given API and document what must work, what degrades, and what is off the table.

Many teams treat feature tables as source of truth for engineering (CSS plus JS where relevant) and keep a version list as an optional appendix for QA and stakeholders. For CSS tooling, browserslist can follow coverage queries such as last 2 versions,feature queries such as supports css-grid, or a mix—see thebrowserslist docs. For JS, the same browserslist config often feeds Babel or SWC; align those transforms with the capabilities in your matrix. Runtime checks in CSS use @supports (see @supports above); in JS use feature detection or conditional loading, not version sniffing.

Feature baseline (primary template)

Start with the CSS rows below. Extend the same columns for JavaScript and platform APIs in your copied template (see the Markdown block)—policy, delivery, and fallback—so one document covers the stack.

For each capability, record policy (required everywhere you claim fully supported, progressive with a fallback, or not used),delivery (native, build transform, polyfill, or@supports in CSS), and what happens when the feature is missing.

CapabilityPolicyDeliveryFallback when missingNotes
Cascade layers (@layer)
Design tokens
Registered custom properties (@property)Plain custom propertiesUseful for typed tokens, inheritance control, and animatable custom values
Custom mediaBuild must expand in every CSS pipeline
Container queries
Nesting
:is() / :where() / :not()
:has()Often progressive; gate with @supports selector(:has(*))
@scope
@starting-styleKeep standalone blocks after the original rule
transition-behaviorNeeded for display, overlay, and content-visibility transitions
::details-contentProgressive disclosure animation only
interpolate-size / calc-size()Verify syntax and support before production use
Customisable selectGate with appearance and ::picker(select)
Logical properties

Policy examples: Required,Progressive, Not used.Delivery examples: Native, Build,@supports.

Browser versions (optional appendix)

Use this table when you need to name engines for legal text, device labs, or analytics—not as a substitute for describing what CSS must do. Add rows for webviews, kiosk shells, or partner embeds that matter to your product.

Browser / environmentMinimum versionTierNotes
Chrome (desktop)
Firefox
Safari (macOS)
Microsoft Edge
Safari (iOS)
Chrome (Android)
Samsung Internet
Embedded webviewName the product (in-app browser, partner shell, etc.)

Copy the Markdown below into your repository and replace the placeholders. It includes an optional JavaScript / platform API section using the same pattern. State whether your primary baseline is feature-, version-, or both-style in the policy header. Attach resolved browserslist output to release notes when the baseline changes.

Diagram
# Browser support matrix

<!-- Copy into your repo (e.g. docs/browser-support.md). Update when policy or browserslist changes. -->

## Policy

| Field | Value |
| --- | --- |
| Last updated | YYYY-MM-DD |
| Owners | |
| Primary baseline | Feature / Version / Both |
| Browserslist | See .browserslistrc (queries and/or feature queries) |
| Next review | |

## Support tiers

- Fully supported — critical journeys meet the baseline; defects are release blockers.
- Best effort — core tasks work; minor visual differences acceptable.
- Not supported — no test commitment.

## Two ways to document the baseline

Use a feature baseline when capability matters more than a version number (CSS, JS, and platform APIs).
Use a version list when contracts, QA devices, or analytics name specific engines.
Many teams treat the feature tables as source of truth and keep versions as an optional appendix.

## Feature baseline (primary)

Policy: Required = must work everywhere you claim fully supported.
Progressive = enhanced path + documented fallback. Not used = off the table.

| Capability | Policy | Delivery | Fallback when missing | Notes |
| --- | --- | --- | --- | --- |
| Cascade layers (@layer) | | Native / Build / Not used | | |
| Design tokens (custom properties) | | Native | | |
| Registered custom properties (@property) | | Native / Not used | Plain custom properties | Useful for typed tokens, inheritance control, and animatable custom values |
| Custom media (@custom-media) | | Build | Plain @media or simpler layout | Every pipeline must expand |
| Container queries | | Native / Not used | | |
| Nesting | | Native / Build | Flat selectors | |
| :is(), :where(), :not() | | Native | Longer selectors | |
| :has() | | Native / @supports | Simpler DOM or JS hook | |
| @scope | | Native / Not used | Layers + naming discipline | |
| @starting-style | | Native / @supports / Not used | No entry transition | Keep standalone blocks after the original rule |
| transition-behavior | | Native / Not used | Discrete values change immediately | Needed for display, overlay, and content-visibility transitions |
| ::details-content | | Native / @supports / Not used | Native details open/close | Progressive disclosure animation only |
| interpolate-size / calc-size() | | Native / @supports / Not used | Instant intrinsic size change | Verify syntax and support before production use |
| Customisable select | | Native / @supports / Not used | Classic select | Gate with appearance + ::picker(select) |
| Logical properties | | Native | Physical properties | |

## JavaScript and platform APIs (optional, same pattern)

| Capability | Policy | Delivery | Fallback when missing | Notes |
| --- | --- | --- | --- | --- |
| ES modules | | Native / Transpile | | |
| fetch | | Native / Polyfill | | |
| dynamic import() | | Native / Transpile / Not used | | |
| IntersectionObserver | | Native / Polyfill | | |
| (add rows your app needs) | | | | |

## Browser versions (optional appendix)

| Browser / environment | Minimum version | Tier | Notes |
| --- | --- | --- | --- |
| Chrome (desktop) | | | |
| Firefox | | | |
| Safari (macOS) | | | |
| Microsoft Edge | | | |
| Safari (iOS) | | | |
| Chrome (Android) | | | |
| Samsung Internet | | | |
| Embedded webview (name product) | | | |

Practical checklist

  • Publish a written support matrix using thetemplate above—feature baseline, version list, or both—and keep it aligned with browserslist and@supports fallbacks.
  • Commit browserslist queries next to the code that consumes them.
  • Run the browserslist command above after changes and store the output in release notes when support shifts.
  • If you use @custom-media, confirm every CSS delivery path (dev, CI, preview, embedded bundles) runs the expand step—no raw@media (--name) in shipped CSS.
  • Test critical journeys in at least one device class per major engine you claim.
  • Revisit targets when analytics, regulation, or embedded webviews change.

Next useful pages