The Problem
One of the best things about web components is that they are wicked fast, but one of the common complaints I hear is that, because they are a client-side technology, there can be a delay between when the page renders and the components are defined and rendered. This can result in a flash of unstyled content (FOUC) and page content shifts - that jarring moment when your carefully crafted layout suddenly jumps around before your users' eyes. Not exactly the first impression we're going for.
The Old Solution
Here's the solution that's been making the rounds for ages:
:not(:defined) {
visibility: hidden;
}
What's happening?
This CSS selector targets any custom element that hasn't been defined yet and visually hides it from view. Once the JavaScript defines the custom element, the browser marks it as :defined, the selector no longer matches, and the component becomes visible.
Pros
- Performant and simple: Just drop it in a CSS reset or theme file, and you're done.
- Zero setup required: Developers using your components don't need to do anything special - it just works out of the box.
Cons
-
Layout shifts: Because this only hides the component visually (the element still takes up space in the layout). The element initially renders as an unstyled
HTMLUnknownElement, which has no intrinsic size. As components load and apply their styles, it can result in content jumps as the page reflows. - Silent failures: If a component fails to define (network issues, JavaScript errors, etc.), it will never be shown. This might be acceptable in some cases, but it's problematic if you're using custom elements for progressive enhancement or mixing defined and undefined custom elements in your application.
-
Accessibility issues: Using
visibility: hidden;can remove the content from the accessibility tree, which means screen readers and other assistive technologies can't access it. Your content is invisible to both sighted users and accessibility tools, which can affect initial focus.
Solving it with JavaScript
In a previous article I wrote, I show how to use native JavaScript APIs to improve upon the original solution:
<style>
/* Visually block initial render as components get defined */
body:not(.wc-loaded) {
opacity: 0;
}
</style>
<script type="module">
(() => {
// Select all undefined custom elements on the page and wait for them to be loaded.
// Once they are all loaded, add the `wc-loaded` class to the body to make it visible again.
Promise.allSettled(
[...document.querySelectorAll(":not(:defined)")]
.map((component) => {
return customElements.whenDefined(component.localName);
})
).then(() => document.body.classList.add("wc-loaded"));
// Add fallback to add the `wc-loaded` class to the body to make it visible after 200ms.
// This prevents the user from being blocked from using your application in case a component fails to load
// or other undefined elements are being used on the page.
setTimeout(() => document.body.classList.add("wc-loaded"), 200);
})();
</script>
What's happening?
This approach waits for all custom elements to be defined before revealing the page. Here's how it works:
Hide the body: We set
opacity: 0on the body until it gets thewc-loadedclass. Unlikevisibility: hidden, this keeps content in the accessibility tree - screen readers can still access it.Query for undefined elements:
document.querySelectorAll(":not(:defined)")finds all custom elements that haven't been defined yet.Wait for definition: For each undefined element, we use
customElements.whenDefined(), which returns a Promise that resolves when that custom element gets defined.Wait for all components:
Promise.allSettled()waits for all those Promises to complete (whether they succeed or fail), then adds thewc-loadedclass to make everything visible.Fallback timeout: The
setTimeoutensures that even if something goes wrong, users aren't staring at a blank page forever. After 200ms, we show the page regardless.
Here is a demo you can play with.
Pros
-
Accessibility-friendly: By using
opacityinstead ofvisibility: hidden, the page content remains available to assistive technologies. Users with screen readers aren't left in the dark. - Graceful degradation: Provides a fallback in case components fail to load or if undefined custom elements are also being used on the page. Your users will see something rather than nothing.
- Very performant: Modern browsers handle opacity changes efficiently, and the JavaScript overhead is minimal.
- Future-proof: By adding a class instead of directly manipulating styles, you avoid issues where new components added after page load might cause the opacity to flash in and out.
Cons
- Setup required: Depending on your architecture, this might need to be included in your base template or injected into every page. It's not quite as "set it and forget it" as a CSS-only solution.
- JavaScript dependency: Requires JavaScript to execute on the page before content is visible. If JS is disabled or fails to load, users see nothing (though the timeout helps mitigate this).
- Module timing: The script needs to run as a module, which means it's deferred and runs after the DOM is loaded. This can introduce a small delay compared to inline scripts or CSS-only solutions.
- Potential failure point: If JavaScript is disabled or fails to load the script, it could prevent the page from loading properly.
Solving it with CSS
What if we could have the best of both worlds - a solution that's easy to set up, lightweight, doesn't require additional JavaScript to run, and provides a fallback if components fail to load? Here's a CSS-only approach:
body:has(:not(:defined)) {
opacity: var(--wc-loaded, 0);
animation: showBody 0s linear 100ms forwards;
}
@keyframes showBody {
to {
--wc-loaded: 1;
}
}
What's happening?
This solution uses modern CSS features to handle component loading. Here's the breakdown:
The
:has()selector: This checks if the body contains any elements matching:not(:defined). If it finds undefined custom elements, the styles apply.Initial hiding:
opacity: 0makes the body invisible while undefined components are present.Animation-based timeout: We set up an animation with 0 seconds duration but a 100ms delay. This animation's purpose is to set
opacity: 1.Automatic reveal: As soon as all custom elements become defined, the
:has(:not(:defined))selector no longer matches, the animation is removed, and the browser resets to the defaultopacity: 1.Built-in fallback: If components fail to load or take too long, the animation completes after 100ms anyway, forcing
opacity: 1.Prevent flashes: The
--wc-loadedsets the state moving forward, which will prevent the opacity from flashing when new undefined components are added to the page.
Here is a demo you can play with.
Pros
- Pure CSS solution: No JavaScript required! This means it works even in environments where JS is disabled or fails to load, and it executes immediately without waiting for the DOM or module loading.
-
Automatic fallback: The animation delay provides a built-in timeout. If components fail to define, the page still becomes visible after 100ms. No need for manual
setTimeoutcalls. - Lightweight: Just a few lines of CSS - no extra scripts, no DOM queries, no Promise wrangling.
-
Reactive: The
:has()selector automatically updates when elements become defined. - Easy to customize: Want a longer timeout? Change the animation delay. Want to target specific containers instead of the whole body? Adjust the selector. Timeouts and animation timing can be made configurable using CSS variables. It's flexible without being complicated.
-
Accessibility-friendly: Like the JS solution, this uses
opacityrather thanvisibility, keeping content available to assistive technologies.
Cons
-
Browser support: The
:has()selector is relatively new. It's supported in all modern browsers, but may not be supported in older versions. The good news is that if something is not supported, it will gracefully fail rather than crash your app.
Wrapping Up
These solutions will continue to evolve, but FOUC doesn't have to be a necessary evil of using web components. With these techniques in your toolkit, you can provide smooth, accessible experiences for your users and boost those Core Web Vitals scores without sacrificing the benefits of web components.
Top comments (0)