Custom elements and the shadow boundary
Defining an element and attaching a shadow root buys real style encapsulation in both directions — selectors match across it neither way — but inherited properties and custom properties still flow inward, which is the leak that makes theming possible and consistency impossible.
You have a constraint sheet. It says the host has a bare button rule, a reset you may not open, and a set of :root custom properties you wrote down by name. Now you write the element that has to survive all of it.
A custom element with a shadow root is about twenty lines of code and it is the only delivery mechanism in this course that asks the host for nothing: no build change, no framework, no orchestration layer, one script tag and a tag name. What it costs is a boundary, and the price of that boundary is paid across the rest of this module. This lesson is about the boundary itself, and about saying precisely what crosses it, because the sentence most engineers carry in their heads is half of the rule.
The element, in full
Two rules from the WHATWG HTML Standard shape everything that follows. A custom element name must contain a hyphen, which is what keeps it from ever colliding with a future built-in. And, per MDN’s attachShadow() reference, only an autonomous custom element and a fixed list of built-in elements may host a shadow root at all.
class ReviewGate extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' })
}
}
customElements.define('agent-review-gate', ReviewGate)That is the entire installation story in a plain HTML host. The tag works in markup the host already knows how to write, the upgrade happens whenever the definition arrives, and nothing about their build had to change. The delivery-constraints lesson called that the competitive advantage of this mechanism; this is what it looks like.
The one option in there worth understanding is mode. 'open' means element.shadowRoot returns the root, so the host can reach in from JavaScript; 'closed' means it returns null. Closed is not a security boundary. The host loaded your script, so the host can patch attachShadow before you call it, and your own code has to hold the reference anyway. Choose 'open' and treat the boundary as a styling and DOM-scoping mechanism, which is what it is.
Say what crosses, precisely
Here is the sentence to stop using: “shadow DOM stops their styles getting in.” It is true and it is half the rule, and the missing half is the one that gets a graft accepted. MDN states both directions in the same breath: “The page CSS does not affect nodes inside the shadow DOM” and “none of the code inside a shadow DOM can affect anything outside it.”
Selector matching is blocked in both directions. Their button rule cannot match your button. Your button rule cannot match theirs. That symmetry is the argument you make to a platform team, because the direction they care about is the second one: a component that cannot render their application wrong is a component whose blast radius they can reason about.
Inheritance crosses inward, and only inward. Inheritance is computed over the flattened tree rather than the original one — CSS Scoping Module Level 1 grounds this by specifying that slotted content inherits “from the slot they’re assigned to,” not from its light-DOM parent. Your shadow tree hangs below the host element, so every inherited property computed on the host arrives inside: color, font-family, font-size, line-height, letter-spacing, visibility, and every custom property. Nothing runs the other way, because your shadow tree has no descendants outside itself to inherit into.
Those two facts are different mechanisms, and collapsing them into “encapsulation is one-directional” produces a claim that is wrong about selectors and vague about inheritance. Say it as two sentences. Selectors do not cross either way. Inherited properties cross inward.
The doors you open on purpose
A sealed component is not the goal. Three channels let a host reach in, each with a different amount of surface area, and the difference between them is a contract decision rather than a styling one.
- Custom properties. They inherit, so they cross with no plumbing at all. This is the narrowest and most durable channel: you publish the names you read, the host sets them or does not, and your internal structure stays private. The lesson on extracting the host brand is built entirely on this.
::part(). You mark an internal element with apartattribute and the host may style it from outside. MDN records it as Baseline since July 2020 (checked 2026-09-03), notes it is visible only to the parent DOM — a part inside a nested shadow root needsexportpartsto be reachable — and that it cannot be chained with a descendant selector. Every part name you publish is an API you have to keep, so publish few.::slotted(). For content the host passes in, which stays in the light DOM and is styled by the host’s own rules as well as yours. MDN records it as Baseline since January 2020 (checked 2026-09-03) and constrains it to a compound selector: it cannot match descendants of a slotted element, and it cannot match text nodes.
Note what the slot case means for the boundary. Slotted nodes are the host’s nodes. They render in your tree, they inherit from the slot they land in, and the host’s selectors still match them, because they never left the host’s document tree. If you accept slotted content, you have accepted a region of your component that the host’s stylesheet governs.
Two things that are true today and were not recently
Both of these change a delivery answer, and both are the kind of claim that goes stale, so each carries the date it was checked.
Declarative shadow DOM is broadly available. A <template shadowrootmode="open"> in the served HTML gets a shadow root attached during parsing, with no JavaScript needed to produce the first frame. On caniuse it records 93.74% full support plus 1.2% partial, totalling 94.94% globally — Chrome and Edge 111, Firefox 123, Safari 16.4 (checked 2026-09-05). For the server-rendered host on your list, that is the difference between markup that arrives styled and markup that flashes unstyled while a bundle downloads. Treat it as an available technique, not an experiment.
:host-context() is not an option. It is the selector that would let you respond to an ancestor outside your boundary, which is exactly what a graft wants, and it is deprecated. MDN records it as removed from the specification “due to opposition from vendors, performance considerations, and interest in alternatives,” with Firefox and Safari unsupported (checked 2026-09-03). If you need to know that the host is in dark mode, read a custom property they set, not an ancestor selector.
Where people get burned
Custom properties cross the boundary and no reset removes them, which makes a name collision the one failure this mechanism cannot defend against. If the host defines --surface and you consume --surface, you are silently on their theme in some hosts and not others, and the bug reproduces in exactly one product. Use the prefix you committed to on the constraint sheet, on every property you read and every one you write.
Check your recall
Answer from memory — no scrolling back.
Retrieval check
A platform engineer asks: “if I let this in, can it break the rest of my page?” Answer in two sentences, and be precise about what you are and are not promising.
Check your answer
Your selectors cannot match anything outside your shadow root, so no rule you ship can restyle their application — MDN states that nothing inside a shadow DOM can affect anything outside it. What you cannot promise is the reverse direction: their inherited properties and custom properties still reach into your tree, so a change on their side can alter how your component renders.
That is the honest shape of the answer, and it is the one that gets a yes. The risk they are asking about — a foreign stylesheet breaking their navigation — is structurally impossible here, and the risk that remains lands on you, not on them. Anything beyond styling, including whether your JavaScript can misbehave in their page, is not a claim the shadow boundary supports: it is a code-review and bundle question, and pretending encapsulation covers it is the overclaim that costs you the next conversation.
Hands on
Define the gate and prove the boundary in both directions
Done when: ARTIFACT.md records `<agent-review-gate>` as defined with an open shadow root, plus four measurements from one real page: a host selector that failed to match inside your tree, a selector of yours that failed to match outside it, an inherited value that arrived anyway, and the name of one host custom property that would have collided with yours.
- Write the element. A class extending
HTMLElement, anattachShadow({ mode: 'open' })in the constructor, enough markup for the approve button and one confidence badge, and acustomElements.define()with a hyphenated name. Keep the styles in a single<style>in the shadow root for now. - Drop it into a page that carries the host stylesheet you captured for the constraint sheet. Not a blank page — a blank page proves nothing, and the whole point of the sheet was that their reset is hostile.
- Prove the inward selector block. Pick a bare-element rule from their sheet that should hit your button, confirm in devtools that it does not appear in the computed styles for your internal button, and write the selector down.
- Prove the outward block. Add a deliberately antisocial rule inside your shadow root —
button { outline: 4px solid red }— and confirm no button in the host page changed. Then delete it. - Now measure the leak. Read the computed
font-family,font-size,line-heightandcoloron your internal button, and compare them against what you thought you had set. Every difference is inheritance, and every one of them is a value you did not choose. - List the custom properties your component reads. Cross-check them against the host
:rootlist on your constraint sheet and record any name that appears in both. Then apply your prefix. - Bring the four measurements into the chat. I will ask you to state the boundary rule in two sentences without using the word “encapsulated,” and I will push back if the inheritance half goes missing.
What this does not cover
This lesson stopped at styling and DOM scoping, which is the half of the boundary that behaves the way the documentation implies. The lesson on what the boundary actually costs takes the other half: what happens to focus when a shadow root is between the host and your input, why an event you fire is invisible outside your tree unless you set two separate flags, and what it takes for the gate to submit inside a form it does not own.
Accessibility across the boundary is worse than either of those and gets its own lesson in this module, because ARIA works on ID references and IDs are scoped per tree. Theming through the custom properties named here — reading a host’s brand rather than shipping your own — is the subject of the lesson on extracting the host brand, in the module on inheriting the host.
Read this next — primary source
Using shadow DOMMDN Web Docs — free
This lesson takes two sentences from it and spends the whole page on what they mean together: that page CSS does not affect nodes inside the shadow DOM, and that nothing inside a shadow DOM can affect anything outside it. Read the full page because the parts beyond those two sentences are the parts a host review will ask about — how a shadow root is attached and what the mode option really governs, how slots relocate light-DOM children into the tree, and how styles get into a shadow root in the first place. It is also the page that will not tell you about inheritance, which is the half this lesson has to bring from the CSS scoping specification.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.