Using CSS :defined to Style Unregistered Custom Elements

This article explains how the CSS :defined pseudo-class targets custom elements based on their registration status in the browser. You will learn how to use the :not(:defined) selector to style custom elements before their JavaScript definitions load, effectively preventing Flash of Unstyled Content (FOUC) and Cumulative Layout Shift (CLS) through placeholders, skeleton screens, and smooth transitions.


What Is the :defined Pseudo-Class?

The :defined pseudo-class targets any element that has been officially registered with the browser. Standard HTML elements (like <div>, <p>, or <button>) are defined by default.

For Custom Elements (Web Components), an element remains undefined until its underlying class is registered using the customElements.define() JavaScript API. Once registered, the browser automatically applies the :defined state to all matching tags in the DOM.

The Problem: Flash of Unstyled Content (FOUC)

Modern web applications often defer, asynchronously load, or code-split the JavaScript bundles that contain Custom Element definitions. While the browser waits to download and execute this JavaScript, it parses the HTML tags as generic inline elements with no styling.

This delay causes two major user experience issues:

  1. Flash of Unstyled Content (FOUC): Raw text or unstyled child nodes render momentarily before snapping into their intended component layout.
  2. Cumulative Layout Shift (CLS): When the component finally renders, it suddenly expands or shifts, disrupting the page layout.

Targeting Elements Before Registration with :not(:defined)

To target an element before its JavaScript executes, negate the pseudo-class using :not(:defined).

/* Target the unregistered custom element */
user-card:not(:defined) {
  display: none;
}

This selector applies styling exclusively during the loading window. As soon as customElements.define('user-card', UserCard) runs, the :not(:defined) selector no longer matches, and the browser switches to the component's normal styles.

Common Implementation Patterns

1. Reserving Layout Space (Preventing CLS)

Instead of hiding the component completely, you can reserve layout dimensions to stop the page from jumping when the element initializes:

media-player:not(:defined) {
  display: block;
  min-height: 300px;
  background-color: #f0f0f0;
}

2. Displaying Skeleton Loaders

You can present a placeholder animation to signal to users that the component is loading:

user-profile:not(:defined) {
  display: block;
  height: 120px;
  border-radius: 8px;
  background: linear-gradient(90deg, #e0e0e0 25%, #f5f5f5 50%, #e0e0e0 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s infinite;
}

@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

3. Smooth Fade-In Transitions

To avoid an abrupt visual pop when the component activates, combine opacity with a transition:

/* Before registration: transparent */
custom-modal:not(:defined) {
  opacity: 0;
}

/* After registration: fade in smoothly */
custom-modal:defined {
  opacity: 1;
  transition: opacity 0.3s ease-in;
}

Best Practices

  • Always provide fallback dimensions: Setting a min-height or aspect-ratio on :not(:defined) elements preserves layout stability while scripts load.
  • Avoid permanently hiding content: If you use display: none on :not(:defined), ensure that a script failure will not leave critical page content permanently invisible.
  • Ensure proper display types: Custom elements default to display: inline. If your component acts as a block-level container, set display: block on both :not(:defined) and :defined states to prevent formatting discrepancies.