Feature-Detecting Shadow DOM Support: Testing Capabilities, Not Browsers

“Does this browser support Web Components” is the wrong question, and every codebase that asks it ends up with a boolean that is true on engines missing half the API and false on engines that would have worked. There is no single Web Components feature. There is customElements, attachShadow, slotAssignment: 'manual', ElementInternals, CustomStateSet, constructable stylesheets, declarative shadow roots, form association, ::part(), :state(), container queries — each shipped on its own timeline, each detectable independently, and most of them capable of graceful absence.

Detecting them individually is more code than a user-agent check and dramatically more reliable, because it tests the thing the component actually needs. This deep dive belongs to Polyfills & Progressive Enhancement inside Distribution, Testing & Tooling.

Problem Statement: The Detection That Answers a Different Question

// A check that appears in a great many codebases.
const SUPPORTS_WEB_COMPONENTS =
  'customElements' in window && 'attachShadow' in Element.prototype;

if (SUPPORTS_WEB_COMPONENTS) {
  import('./components/index.js');       // loads EVERY component
} else {
  document.body.classList.add('legacy'); // renders a static fallback for everything
}

Two failures, in opposite directions.

The check passes on an engine from 2019 that has attachShadow and lacks ElementInternals, CustomStateSet, declarative shadow roots, and manual slot assignment. Every component loads, and the four that rely on those APIs throw during construction — which in a custom element constructor means the element is marked failed and never upgrades, silently, leaving a blank region with no error visible to the reader.

The check fails on an engine that has everything except attachShadow on Element.prototype because a polyfill installed it on HTMLElement.prototype instead. Every component is disabled and the page renders the legacy fallback for a browser that would have run the real thing.

One coarse boolean against the real feature timeline Custom elements and attachShadow shipped years before element internals, custom state sets and declarative shadow roots, so a single check cannot describe what an engine can actually do. The features a "Web Components support" boolean pretends are one thing 2016 2018 2020 2022 2024 customElements attachShadow constructable sheets ElementInternals declarative shadow DOM CustomStateSet the boolean's answer "supported" — for eight years of very different engines

Root-Cause Analysis

Every specification in this area was written to be detectable, and none of them is detectable by asking about “Web Components”. The reliable technique differs by feature, and there are exactly four shapes.

Property presence works for constructors and methods: 'customElements' in window, 'attachShadow' in Element.prototype, 'assign' in HTMLSlotElement.prototype, 'adoptedStyleSheets' in Document.prototype. It is cheap and accurate for anything that either exists or does not.

Behavioural probing is required when a member exists but does not do the thing. Declarative shadow DOM is the canonical case: shadowrootmode is an attribute the parser either honours or ignores, and no property reflects whether it did. The only reliable test is to parse a fragment and look for the resulting shadow root.

CSS.supports() covers selectors and properties: CSS.supports('selector(:state(x))'), CSS.supports('container-type: inline-size'). The selector() form is itself relatively new, so a component testing selector support should confirm CSS.supports accepts it.

Constructor probing covers APIs that throw rather than being absent. new CustomElementRegistry() throws where scoped registries are unimplemented; new CSSStyleSheet() threw in older Safari. A try/catch around one construction is the answer, evaluated once.

Debugging Pitfall — detecting inside a constructor. A custom element constructor that throws leaves the element permanently in the failed state: it never upgrades, :defined never matches, and no exception reaches the page's error handler in a way that names the component. So a constructor that calls this.attachInternals() on an engine without ElementInternals does not degrade — it produces an invisible, inert element. Detect before the class is defined, and branch on the result inside the constructor, never let the API call be the detection.

The second structural rule: detect once, at module scope. These checks are cheap individually and are not free — a declarative shadow DOM probe parses a document fragment — and running them per instance turns a microsecond into a measurable cost on a page with hundreds of components.

Four detection techniques and when each is the correct one Property presence covers members that exist or do not, behavioural probing covers parser features, CSS supports covers selectors and properties, and constructor probing covers APIs that throw. property presence 'attachShadow' in constructors, methods, accessors cheapest, most accurate fails only when a polyfill lies behavioural probe parse, then inspect declarative shadow DOM, parser behaviour the only way when no property reflects it run it exactly once CSS.supports() selector(:state(x)) selectors, properties, at-rule conditions no DOM required the selector() form is itself newish constructor probe try { new X() } scoped registries, older sheet APIs for APIs that throw rather than being absent

Production-Safe Pattern

// capabilities.js — evaluated once, at module scope, exported frozen.

const has = (object, key) => Boolean(object) && key in object;

/** Declarative shadow DOM has no property to test: parse and look. */
const detectDeclarativeShadowDom = () => {
  try {
    const probe = document.createElement('div');
    // setHTMLUnsafe is itself a later addition, so try both entry points.
    const markup = '<x-probe><template shadowrootmode="open"></template></x-probe>';
    if (typeof probe.setHTMLUnsafe === 'function') probe.setHTMLUnsafe(markup);
    else probe.innerHTML = markup;
    return Boolean(probe.firstElementChild?.shadowRoot);
  } catch {
    return false;
  }
};

/** Some APIs throw rather than being absent. */
const constructs = (factory) => {
  try {
    factory();
    return true;
  } catch {
    return false;
  }
};

/** CSS.supports('selector(...)') is itself newish — confirm before trusting it. */
const supportsSelector = (selector) => {
  if (typeof CSS === 'undefined' || typeof CSS.supports !== 'function') return false;
  if (!CSS.supports('selector(*)')) return false;      // the form itself is unsupported
  return CSS.supports(`selector(${selector})`);
};

export const CAPABILITIES = Object.freeze({
  customElements: has(globalThis, 'customElements'),
  shadowDom: has(Element.prototype, 'attachShadow'),
  declarativeShadowDom: detectDeclarativeShadowDom(),
  elementInternals: has(HTMLElement.prototype, 'attachInternals'),
  customStates: has(globalThis, 'CustomStateSet') && supportsSelector(':state(x)'),
  formAssociation: has(globalThis, 'ElementInternals') &&
    has(globalThis.ElementInternals?.prototype ?? {}, 'setFormValue'),
  constructableStyleSheets: has(Document.prototype, 'adoptedStyleSheets') &&
    constructs(() => new CSSStyleSheet()),
  manualSlotAssignment: has(HTMLSlotElement.prototype, 'assign'),
  scopedRegistries: constructs(() => new CustomElementRegistry()),
  containerQueries: typeof CSS !== 'undefined' &&
    CSS.supports?.('container-type: inline-size') === true,
  parts: supportsSelector('::part(x)')
});
// A component branches on exactly the capabilities it needs, before defining.
import { CAPABILITIES } from './capabilities.js';

class ToneField extends HTMLElement {
  static formAssociated = CAPABILITIES.formAssociation;

  #internals = null;

  constructor() {
    super();
    // Branch INSIDE the constructor on a value computed OUTSIDE it, so the
    // constructor can never throw and leave the element permanently failed.
    if (CAPABILITIES.elementInternals) {
      this.#internals = this.attachInternals();
    }

    const root = this.attachShadow({ mode: 'open' });
    root.innerHTML = `<label><slot></slot><input type="text"></label>`;

    if (CAPABILITIES.constructableStyleSheets) {
      const sheet = new CSSStyleSheet();
      sheet.replaceSync(':host { display: block; } input { inline-size: 100%; }');
      root.adoptedStyleSheets = [sheet];
    } else {
      const style = document.createElement('style');
      style.textContent = ':host { display: block; } input { inline-size: 100%; }';
      root.prepend(style);
    }
  }

  setInvalid(message) {
    if (this.#internals && CAPABILITIES.customStates) {
      this.#internals.states.add('invalid');
    } else {
      // Attribute fallback: consumers style [data-invalid] where :state() is absent.
      this.toggleAttribute('data-invalid', true);
    }
    this.#internals?.setValidity?.({ customError: true }, message);
  }
}

// Only define at all if the baseline is present. Everything above is enhancement.
if (CAPABILITIES.customElements && CAPABILITIES.shadowDom) {
  customElements.define('tone-field', ToneField);
}

Three properties make this correct rather than merely thorough. The capability object is computed once at module scope and frozen, so the probes run a single time regardless of how many instances exist. Each branch degrades to a working alternative — a <style> element, a data attribute — rather than to nothing. And the constructor never calls an API it has not already confirmed, so it cannot throw and cannot leave elements in the failed state.

The static formAssociated line is worth noticing: it reads a capability at class-definition time, because declaring form association on an engine without ElementInternals makes the definition itself unusable.

Verification

import { CAPABILITIES } from './capabilities.js';

// 1. Every capability is a boolean, never undefined — an undefined check is a bug.
for (const [name, value] of Object.entries(CAPABILITIES)) {
  console.assert(typeof value === 'boolean', `${name} must be a boolean`);
}

// 2. Detection is idempotent and cached: the object is frozen.
console.assert(Object.isFrozen(CAPABILITIES), 'capabilities must be computed once');

// 3. The declarative probe agrees with a real parse.
const host = document.createElement('div');
host.setHTMLUnsafe?.('<x-check><template shadowrootmode="open">ok</template></x-check>');
const observed = Boolean(host.firstElementChild?.shadowRoot);
console.assert(observed === CAPABILITIES.declarativeShadowDom, 'probe matches reality');

// 4. The component upgrades and does NOT enter the failed state.
document.body.innerHTML = '<tone-field>Label</tone-field>';
const field = document.querySelector('tone-field');
console.assert(field.matches(':defined'), 'element upgraded without throwing');
console.assert(field.shadowRoot !== null, 'shadow root attached');

// 5. The degraded path produces working behaviour, not silence.
field.setInvalid('Required');
const flagged = field.matches(':state(invalid)') || field.hasAttribute('data-invalid');
console.assert(flagged, 'invalid state expressed through whichever channel exists');

Assertion five is the one that makes the whole approach worth the code: it asserts the outcome rather than the mechanism, so the same test passes on an engine using custom states and on one using the attribute fallback. Tests written against the mechanism have to be skipped per engine, which is how fallback paths rot.

For CI, run the suite in at least two engines. A Chromium-only run reports every capability as true and exercises no fallback branch at all, which means the degraded paths are untested code shipping to real users.

When to Use vs When to Avoid

Feature Detection
Custom elements 'customElements' in window
Shadow DOM 'attachShadow' in Element.prototype
Declarative shadow DOM Parse a probe fragment and check for shadowRoot
ElementInternals 'attachInternals' in HTMLElement.prototype
Custom states 'CustomStateSet' in window plus a :state() selector check
Constructable stylesheets Property presence plus a new CSSStyleSheet() probe
Manual slot assignment 'assign' in HTMLSlotElement.prototype
Scoped registries try { new CustomElementRegistry() }
::part() / container queries CSS.supports()
Anything at all Never a user-agent string

The bottom row is not a style preference. User-agent strings are frozen, spoofed, and reduced by design in current browsers, so a check written against one is wrong on arrival and gets wronger. Every feature in the table above has a direct test that costs a few microseconds and stays correct as engines change — which is the whole argument for the technique described in polyfilling custom elements in legacy browsers.

Each capability paired with the fallback it degrades to Constructable stylesheets fall back to a style element, custom states to a data attribute, declarative shadow roots to an imperative attach, and form association to a hidden input. Every branch degrades to something that works, never to nothing constructable stylesheets a <style> element in the shadow root custom states a data attribute consumers can style declarative shadow DOM imperative attachShadow at connect form association a hidden input inside the light DOM Assert the outcome, not the mechanism, so one test covers both branches.

Frequently Asked Questions

Why can't I detect declarative shadow DOM with a property check?

Because it is parser behaviour with no reflected property — shadowrootmode is either honoured or ignored, and nothing on any interface says which. Parsing a small probe fragment and checking for a resulting shadowRoot is the only reliable test.

What happens if a custom element constructor throws?

The element enters the failed state permanently: it never upgrades, :defined never matches, and the region renders empty. Detect capabilities before the class is defined and branch on cached results, so the constructor can never throw.

Should I detect once or per instance?

Once, at module scope, into a frozen object. Some probes parse a fragment, and repeating that per instance is a measurable cost on a page with many components — with no possible change in the answer.

Is a user-agent check ever acceptable?

No. Modern browsers freeze and reduce the string deliberately, and it never described capabilities accurately in the first place. Every feature here has a direct test that stays correct as engines evolve.