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:
- Flash of Unstyled Content (FOUC): Raw text or unstyled child nodes render momentarily before snapping into their intended component layout.
- 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-heightoraspect-ratioon:not(:defined)elements preserves layout stability while scripts load. - Avoid permanently hiding content: If you use
display: noneon: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, setdisplay: blockon both:not(:defined)and:definedstates to prevent formatting discrepancies.