In 2026 I finally did the thing I'd been putting off since 2022 — I went all-in on the platform. No React, no Vue, no Svelte. Just customElements.define, Shadow DOM, and the HTML parser.
Two projects in parallel pushed me into the deep end. Ekko is a headless, multi-brand design system I ship as a library. This site — jamesmacharia.dev — is a vanilla Vite MPA where every visible thing is a custom element. They teach overlapping but distinct lessons.
Here are ten lessons learnt from these 2 projects. They're ranked roughly by how much time I'd have saved if I'd known them from the start.
The Mental Model
01 Shadow DOM is a selector wall, not a style shield
When I first moved components into shadow DOM, I assumed my global reset was gone — that the boundary was a hard seal around the component. So I started copy-pasting * { box-sizing: border-box } and h1 { font-family: Agrandir } into every component's stylesheet. Then I re-read the actual spec.
The shadow boundary blocks selectors from reaching in, but it does not block inheritance. color, font-family, line-height, CSS custom properties — anything that's inheritable propagates through unchanged. What doesn't inherit is everything that requires a selector match: * { box-sizing }, a { text-decoration }, img { max-width }, the whole class of reset rules.
Once that clicked, my base shadow stylesheet shrunk to three blocks. Nothing more:
h1, h2, h3, h4, h5, h6 { font-family: "Agrandir", sans-serif; }
a { color: inherit; text-decoration: none; }
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
Nothing about colors, nothing about theme variables, nothing about typography tokens — all of that flows in through inheritance from :root for free. The base sheet declares only what selectors can't reach in from outside.
The mental model to steal: the shadow boundary is a trespass wall, not a light shield. Selectors trespass; light passes through.
02 adoptedStyleSheets: one sheet, many shadows
The obvious way to style a custom element is to drop a <style> tag into its shadow root. That works, but every instance parses and owns its own stylesheet. For a page with fifteen project cards and ten highlighted-text elements, that's twenty-five identical parsed stylesheets in memory.
adoptedStyleSheets fixes this. Construct one CSSStyleSheet at module scope, populate it with replaceSync, then adopt it on every instance:
// A shared base class — one reset sheet, adopted on every instance
import baseCss from './base.css?inline';
const baseStyles = new CSSStyleSheet();
baseStyles.replaceSync(baseCss);
export class BaseElement extends HTMLElement {
protected shadow: ShadowRoot;
constructor(componentStyles?: CSSStyleSheet) {
super();
this.shadow = this.attachShadow({ mode: 'open' });
const sheets: CSSStyleSheet[] = [baseStyles];
if (componentStyles) sheets.push(componentStyles);
this.shadow.adoptedStyleSheets = sheets;
}
}
Every subclass builds its own component sheet the same way and hands it up to super():
// A concrete component — constructs its own sheet, hands it up
import navCss from './navigation.css?inline';
const styles = new CSSStyleSheet();
styles.replaceSync(navCss);
export class AppNavigation extends BaseElement {
constructor() { super(styles); }
// …
}
Both sheets are constructed once per module load — adopted many, parsed never-again. The ?inline suffix is Vite's magic: it imports the CSS file contents as a string instead of injecting a <link>, which is what makes replaceSync possible. It's one of two reasons modern Vite is fantastic for vanilla web components — the other is ?url.
The Upgrade Timeline
03 :not(:defined) is the lightest FOUC guard
Before a custom element is upgraded, the browser treats it as an unknown inline element. Any children inside render with no styling, and when the upgrade finally lands, layout shifts as the shadow DOM takes effect. It's the vanilla-custom-element version of the flash of unstyled content problem.
The one-line fix is a single rule in a light-DOM stylesheet — your global CSS, a <style> tag in the document head, anywhere the selector can match the host element before it's upgraded:
:not(:defined) {
visibility: hidden;
}
Two things to notice. First, visibility: hidden, not display: none. Visibility preserves the box, so no reflow happens when the element upgrades — just a paint. display: none would collapse the element, then expand it back on upgrade, which is exactly the layout shift you were trying to hide.
Second, the rule lives in the light-DOM stylesheet, not in the component's own shadow sheet. It has to — :host rules don't apply until the element is already defined, which is too late. You need a selector that matches at parse time, before upgrade.
The selector is unscoped on purpose. :not(:defined) technically matches every undefined element, built-in or custom. Built-ins are always defined by the time CSS parses, so in practice only not-yet-upgraded custom elements match. Terse, global, does the right thing.
04 pagereveal + whenDefined — the fix for FOUC during cross-document View Transitions
Cross-document View Transitions are a 2026 treat. Two lines of CSS —
@view-transition {
navigation: auto;
}
— and same-origin navigations crossfade between pages. The browser snapshots the outgoing page, loads the new one, takes the new snapshot, composites them. No router, no framework.
With custom elements, there's a race. The browser snapshots the incoming page at its first rendering opportunity, which can happen before custom elements have upgraded. The transition then fades from a fully-rendered old page into an unstyled pre-upgrade new page, and the real content pops in after the crossfade ends. It looks broken in a way that's hard to point at.
The fix is pagereveal — a 2026 browser event that fires on the incoming document right before it's revealed. If you attach an async listener, the browser awaits the returned promise before taking the new snapshot. Pair it with customElements.whenDefined, which resolves the moment a name is registered:
window.addEventListener('pagereveal', async (e) => {
if (!e.viewTransition) return;
try {
await Promise.all([
customElements.whenDefined('app-layout'),
customElements.whenDefined('app-navigation'),
]);
} catch {
e.viewTransition.skipTransition();
}
});
Two subtleties. Only wait on the top-level custom elements — every other component is defined in the same module chain, so once the outer elements resolve, all their descendants are already defined. And wrap the await in try/catch with skipTransition() as the fallback, so if whenDefined ever refuses to resolve, you get an instant page swap instead of a hung page.
The effect is transformative. The crossfade now runs between two fully-upgraded, fully-styled snapshots. Click around this site and watch the nav sit rock-solid while only the main content fades — SPA-smoothness on a genuine MPA, no router in sight.
05 The pagereveal script must be parser-blocking, not a module
I had the pagereveal listener inside my module-script entry — a regular <script type="module"> — and the transition still showed FOUC.
Here's the trap. <script type="module"> is deferred by default. You don't have to write defer; it's implicit. Execution is postponed until after HTML parsing completes. That means the listener registers after the moment the browser wants to fire pagereveal. No listener, no await, no guard.
The fix is a classic script — no type, no defer, no async — placed before the module entry:
<link rel="stylesheet" href="/global.css" />
<script src="/view-transitions.js"></script> <!-- classic, parser-blocking -->
<script type="module" src="/main.js"></script> <!-- deferred -->
A classic script with no attributes halts the parser while it fetches and executes, then parsing continues. By the time pagereveal is anywhere near firing, the listener is already attached.
The timing table worth memorising:
| Script tag | Fetch | Execute |
|---|---|---|
<script src> (classic) |
blocks parser | blocks parser |
<script src defer> |
parallel | after parsing |
<script src async> |
parallel | whenever it arrives |
<script type="module"> |
parallel | after parsing (implicit defer) |
For listeners that have to be live before the page's first rendering opportunity — pagereveal, pageswap, anything paint-adjacent — you want row one.
06 upgradeProperty — the ceremony React consumers make you do
This one is library-side only. Framework consumers — React 19+ especially — assign custom-element properties rather than setting attributes:
<x-button variant="primary" disabled>Click</x-button>
// compiles to: el.variant = 'primary'; el.disabled = true;
Custom-element scripts load asynchronously. If the consumer's render happens before your class is defined, the element is still the generic HTMLElement fallback. JS happily sets el.variant = 'primary' as an instance own-property. Your class then defines, upgrades the element, and here's the issue — the own-property shadows the prototype setter. Your set variant(v) never runs. The value sits there, ignored.
The workaround lives in every custom-element library's base class. For each property, on first upgrade, snapshot the own-value, delete the own-property, then re-assign — routing the value through the setter this time:
// A shared base class (simplified)
protected upgradeProperty(prop: string): void {
if (Object.hasOwn(this, prop)) {
const value = (this as Record<string, unknown>)[prop];
delete (this as Record<string, unknown>)[prop];
(this as Record<string, unknown>)[prop] = value;
}
}
// A concrete component — e.g. a button
connectedCallback() {
this.upgradeProperty('variant');
this.upgradeProperty('disabled');
if (!this.hasAttribute('variant')) this.variant = 'primary';
}
Two details that save future pain. Guard every default with hasAttribute — you don't want connectedCallback to clobber a value the consumer already set. And do it for every declared property; the three-line cost per property is the difference between "works in React" and "silently ignores half the API."
The pattern to steal: if you're writing a library, every public property needs upgradeProperty + a hasAttribute-guarded default. If you're writing an app, skip it — you control the upgrade order.
Shadow Meets the World
07 view-transition-name inside a shadow stylesheet → persistent nav chrome
In 2026, view-transition-name declared inside a shadow stylesheet is captured by the document-level view transition. That used to require lifting the nav out of the shadow DOM or naming elements only in light DOM, which was a real architectural constraint for anyone doing component-heavy work. No longer.
A common layout pattern: a top-level wrapper component holds the navigation, a main content slot, and the footer, all inside its shadow DOM. To make the nav feel persistent across page navigations, give it a transition name from inside its own shadow stylesheet:
/* inside the navigation component's shadow stylesheet */
:host {
display: block;
position: sticky;
top: 0;
view-transition-name: site-nav;
}
/* inside the layout component's shadow stylesheet */
footer {
view-transition-name: site-footer;
}
Then, in a document-level (light-DOM) stylesheet, zero out the animation for those groups so the snapshot is swapped in a single frame rather than cross-faded:
/* light-DOM stylesheet — not inside any shadow root */
::view-transition-group(site-nav),
::view-transition-group(site-footer) {
animation-duration: 0s;
}
The result reads as "the nav never moved." The old snapshot is replaced by the new in a single frame, while everything else — the unnamed root group that everything without its own transition name falls into — crossfades at the browser's default view-transition pace. The active-link indicator swap feels intentional rather than a half-fade through an in-between state.
One caveat. ::view-transition-* pseudo-elements live on the root document's view-transition pseudo-tree. They have to be declared in a document-level stylesheet, not inside a shadow root. view-transition-name itself can be set anywhere; the ::view-transition-group / ::view-transition-old / ::view-transition-new pseudos are light-DOM only.
08 Shadow-DOM <button type="submit"> doesn't submit — make the element form-associated
Imagine a button component. Internally it renders <button part="base" type="submit"> inside its shadow root. Drop the host inside a native <form>, click it: nothing happens.
The reason is shadow-DOM membership. Form-control association walks the tree to find the enclosing form, but it walks through the regular tree — it doesn't pierce shadow boundaries. The real submit button lives in the host's shadow root, not in the form's light-DOM tree, so from the form's perspective it isn't a submitter.
The fix is to make the element form-associated. Two lines of boilerplate — a static flag plus a call to attachInternals() — opt you in:
export class XButton extends HTMLElement {
static formAssociated = true;
#internals: ElementInternals;
constructor() {
super();
this.#internals = this.attachInternals();
}
}
static formAssociated = true flips the switch; attachInternals() — only callable on form-associated elements — returns an ElementInternals object whose most useful property is this.#internals.form. That's the owning form, resolved across the shadow boundary for free.
For submission, call form.requestSubmit(), not form.submit(). The former runs constraint validation and fires the submit event (which your consumer is almost certainly listening for); the latter skips both. The slightly subtle part is matching native <button> submitter semantics: a native submitter contributes its name/value pair to the submitted FormData only when it is the active submitter. To replicate that, set the form value transiently, submit, then clear it:
#handleClick(event: Event): void {
if (this.type !== 'submit') return;
const form = this.#internals.form;
if (!form) return;
const hasEntry = Boolean(this.name);
if (hasEntry) this.#internals.setFormValue(this.value);
form.requestSubmit();
if (hasEntry) this.#internals.setFormValue(null);
}
For free you also pick up the rest of the form lifecycle. formDisabledCallback(disabled) fires when the element lands inside a <fieldset disabled>, so the button's internal ARIA and styling can sync without manual listeners. formResetCallback() fires on form.reset(). formAssociatedCallback(form) fires when the element is first inserted into a form. Constraint validation hooks cleanly into setValidity, checkValidity, reportValidity. That's a lot of behaviour for a class-level flag and one call in the constructor.
The pattern to steal: if your element behaves like a native form control — button, input, checkbox, radio, select — make it form-associated from the start. The spec does the heavy lifting; you inherit the whole form lifecycle for the cost of two lines of boilerplate.
09 Outside-click detection needs composedPath(), not event.target
Popovers, tooltips, dropdowns, command palettes — anything that closes on outside click — need to answer "was this click inside me?" The idiomatic reach is !this.contains(event.target). For a light-DOM component it's exactly right. For a shadow-DOM component it's brittle — it happens to give the right answer for simple cases, but by accident rather than by design.
Here's the accident. When a click originates inside a shadow root, the event retargets at the boundary as it bubbles up. By the time a document-level listener sees it, event.target has been rewritten to the shadow host — not the actual element that was clicked. So this.contains(event.target) reduces to this.contains(this), which is true (Node.contains is inclusive of self). The inside click is correctly ignored, but you're leaning on retargeting's side-effect to answer a question the spec gives you a proper API for. The idiom falls over the moment you need to know which internal element was clicked, or you're dealing with nested custom elements, or any part of the tree uses a closed shadow root.
The spec's answer is composedPath(). It returns the full original event path, including nodes on the other side of shadow boundaries, before retargeting happened:
#handleDocumentClick(event: Event): void {
if (!this.open) return;
const path = event.composedPath();
if (path.includes(this)) return; // click was somewhere inside us
this.hide();
}
path.includes(this) tells you the truth. Retargeting doesn't affect composedPath. One gotcha worth knowing: composedPath() returns an empty array once the event has finished dispatching. Call it synchronously in the handler, never from a deferred callback.
10 Cross-shadow radio groups — walk the scope chain, don't reach for document
Native <input type="radio" name="x"> groups via the name attribute, scoped automatically to the enclosing form (or the document if there's no form). For custom-element radios, each instance is its own element with its own shadow root, and "uncheck my siblings when I check" has no built-in resolution. You have to walk the DOM yourself.
The wrong answer is document.querySelectorAll('x-radio[name="x"]'). It works right up until two radio groups with the same name exist on the same page — in separate forms, or nested inside different component hosts. Then checking one radio unchecks all of them, which is the opposite of what radios are supposed to do.
The right answer is to walk the scope graph in descending specificity. ElementInternals.form first, then the radio's getRootNode() (which returns either the document or the enclosing shadow root), then document as a last resort:
#getGroupMembers(): XRadio[] {
const scope: ParentNode =
this.#internals.form
?? (this.getRootNode() as Document | ShadowRoot | null)
?? document;
const selector = `x-radio[name="${CSS.escape(this.name)}"]`;
return Array.from(scope.querySelectorAll<XRadio>(selector));
}
Two implementation details that save future pain. CSS.escape(this.name) is non-optional — user-provided names can contain brackets, quotes, dots, any character that would break the selector. And queue the sibling-tabindex pass with queueMicrotask in connectedCallback. Siblings in the same group may not have upgraded yet when the first radio connects; a synchronous query would miss them.
The pattern generalises. Anything that needs to coordinate across sibling custom elements — tabs, accordions, segmented controls, radio groups — should walk the same form → rootNode → document chain before reaching for document directly.
Wrapping up
In 2022 I'd have reached for Lit for any custom-element work. With adoptedStyleSheets, ElementInternals, cross-document View Transitions, async pagereveal, and shadow DOM finally playing nicely with document-level pseudo-elements — vanilla is legitimately enough for a whole design system, a whole portfolio, and probably a whole product.
Ekko is on GitHub at Mellow254/Ekko. This site is at MasherJames/jamesmacharia.dev. Both are vanilla and public.
If you're deciding right now whether to reach for a framework: write three vanilla custom elements first. If the platform holds up — and in 2026 it will — you've just saved yourself a build-dependency and a framework migration.