I picked up Web Components because I was building something complicated. A custom mobile OS simulation — apps, icons, a gesture layer, a shadow DOM registry, a loader chain — all of it living inside a single HTML file on Neocities. I needed encapsulation. I needed reusability. I needed something that would stop my CSS from eating itself.
Web Components seemed like the answer. Native browser primitives. No framework overhead. Custom elements you could drop anywhere and trust to behave themselves. I started using them expecting to solve a complexity problem.
What I didn't expect was for them to hand me back a simpler question: what does this actually need to do?
The problem I was trying to solve
The project was a storytelling platform. Each "app" in the fake OS was its own narrative space — a messaging interface, a photo gallery, a voice memo player, each one a different texture of the same story. They needed to look independent. They needed to feel like different software. But they had to share a runtime, respond to the same events, and load in a predictable sequence without stepping on each other.
If you've done any serious CSS on a project with moving parts, you know what the failure mode looks like. A style that makes perfect sense in context bleeds somewhere you didn't expect. A variable gets overridden three layers deep. You add a fix, the fix breaks something else, and twenty minutes later you're reading your own code like a crime scene.
I spent forty minutes debugging a touch interaction that was being silently swallowed by a CSS property three components up the tree. The problem wasn't the gesture handler. It was touch-action inheritance. Web Components' shadow DOM would have caught it before it started.
So: encapsulation. That was the surface problem. Scoped styles. Isolated DOM. A boundary the rest of the document couldn't reach through. Web Components offered all of that natively, without a build step, without a framework, without anything between me and the browser.
I started building. And within a week, the architecture lesson that actually mattered had nothing to do with encapsulation.
What Web Components actually are
A Web Component is three things working together: a custom element (a tag name you define), a shadow DOM (an isolated subtree with its own style scope), and optionally an HTML template (a reusable chunk of markup). You define a class, extend HTMLElement, and the browser handles the rest.
The syntax is minimal enough to be almost confrontational:
class MediaPlayer extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { display: block; }
/* styles can't leak in or out */
</style>
<div class="player"></div>
`;
}
}
customElements.define('media-player', MediaPlayer);
That's it. Drop <media-player></media-player> anywhere in your HTML and the browser instantiates it. The shadow DOM inside is invisible to your main document's CSS. The component is responsible for itself.
What strikes you immediately is how little the browser gives you by default. There's no state management. No reactivity. No opinion about how your component talks to the outside world. You get a lifecycle, a scope, and a custom tag. The rest is up to you.
That blankness turns out to be the most instructive thing about them.
A framework makes decisions for you. A primitive hands the decisions back.
Studio principle · HandwiredThe question they force you to ask
With React or Vue, there are established patterns for almost everything. State goes here. Props come in this way. Side effects happen there. You might not love every choice, but the choices are made. The framework has opinions and you work inside them.
Web Components have no opinions. Which means every decision falls back on you — and the first decision, the one you can't defer, is the hardest one: what is this component actually for?
Not "what can it do." What is it for.
Complexity is what happens when you haven't decided what something is for yet. You add capabilities to cover the uncertainty. The thing grows to fill the space the question left.
On scope creep and unclear purposeIn a framework, you can defer that question longer. The structure accommodates sprawl. Components accumulate props. State management absorbs the ambiguity. The thing stays functional while it grows blurry around the edges.
In a Web Component, the blurriness costs you immediately. Because you're writing the lifecycle methods yourself. You're deciding how the component receives data. You're managing your own cleanup. Every feature you add without a clear reason is complexity you have to hold in your head from then on, in code with no scaffolding to lean against.
The question becomes unavoidable: what does this need to do? Not what could it theoretically do. Not what might be useful later. What does it need, right now, to serve this project?
The first few components I built were too big. A messaging app component that handled rendering, state, scroll behavior, keyboard shortcuts, and event routing all in one class. It worked, but it was dense and resistant to change. Every modification required reading the whole thing first.
The second version was four smaller components. Each one had one job. The complexity didn't disappear — it got distributed to where it actually belonged. And each piece became something I could read, modify, and trust.
Shadow DOM and the cost of isolation
Shadow DOM is genuinely useful. Scoped styles alone are worth it on a complex project — the ability to write .button inside a component and know with certainty that it won't collide with any other .button in the document is a meaningful reduction in cognitive overhead.
But shadow DOM comes with a cost that took me a while to price correctly: isolation cuts both ways.
/* This won't work — shadow DOM stops it */
:root { --brand-color: #d4380d; }
/* This WILL work — CSS custom properties pierce the boundary */
:host { color: var(--brand-color); }
CSS custom properties — CSS variables — cross the shadow boundary. Regular class selectors don't. That distinction matters enormously when you're trying to build a coherent visual system across components that are each living in their own scope.
querySelectorid targeting::slotted)Once I understood that model — the shadow boundary is a scope boundary, not a communication blackout — the architecture became clearer. CSS variables carry the design system across the boundary. Custom events carry data. Attributes carry configuration. The component is isolated but not unreachable.
The lesson wasn't "shadow DOM is complicated." The lesson was that the tool has a precise shape, and working with that shape rather than against it produces simpler code. Every workaround I wrote in the first week was a sign I'd misunderstood what the boundary was for.
What this looks like in practice
The OS simulation project eventually landed on a loader chain: a smau.loader.js that walked a dependency list and instantiated components in order, each one registering itself to a central registry before the next one loaded. No component assumed the others existed yet. Each one either had what it needed at connection time or waited for an event that said it did.
That sounds like overhead. In practice it was the opposite. Because each component made no assumptions, debugging was straightforward — a component either worked in isolation or it didn't, and if it didn't, the problem was in that component. The architecture had given me back the ability to reason locally.
The icon rendering that had been broken for days turned out to be a CSS variable override — inline JS was writing directly to the element's style attribute and clobbering the variable before the shadow root could read it. With proper component boundaries, that class of bug disappears. The shadow root's styles don't compete with inline styles on the host element.
That's the other thing about working close to the platform: the bugs you encounter are usually your own. There's no framework abstraction to blame. If something breaks, it's because a decision somewhere was wrong. That sounds punishing, and sometimes it is. But it's also honest. The code tells you the truth about your choices.
The real lesson
I started using Web Components to solve complexity. What they actually taught me was how complexity gets created in the first place — by deferring the question of what something is for until after you've already built it.
Frameworks make that deferral comfortable. They have enough structure to carry a fuzzy component for a long time before the fuzziness becomes a problem. Web Components don't. They ask early, they ask directly, and they make the cost of a bad answer visible in the code.
The simplicity I ended up with wasn't the browser's gift. It was the consequence of having to answer the question. Each component that has one clear job is simple. Each one that accumulated capabilities to cover an unanswered question is not.
That applies beyond Web Components. It applies to every piece of a project — every section of a site, every animation decision, every line of copy. The complexity that makes things hard to work on is almost always deferred clarity. A decision that got made implicitly, by accumulation, rather than explicitly, by thought.
What Web Components gave me, more than encapsulation, more than scoped styles, was a framework of a different kind — a set of constraints tight enough to force the question early, when it's still cheap to answer. The browser asked; I had to respond. The answer, when I took the time to find it, turned out to be simpler than anything I would have built if I'd been allowed to keep deferring.
That's the trade. Less scaffolding, more clarity. Fewer assumptions, more precision. A little harder up front, considerably lighter in the end.
Worth it every time.