Skip to content

Chapter 21: The DOM Is a Context Tree

The DOM is usually described as a tree of nodes.

That’s true, and incomplete. For application architecture, the more important fact is that the DOM is a tree of context.

Containment means something. A <button> inside a <form> is not just spatially nested; it participates in the form. The button’s type defaults to submit. Clicking it triggers the form’s submission. The button’s disabled state can be inherited from a parent <fieldset disabled>. A link inside a <nav> belongs to the navigation landmark in the accessibility tree. A field inside a disabled fieldset is itself disabled, without any JavaScript needing to set the attribute on the field directly. A paragraph inside an article inherits typographic context, semantic context, and screen-reader landmark context. A custom CSS property defined on <html> can shape every component below it. An event fired on a deeply nested element travels through every ancestor on its way to the document root.

The DOM isn’t only where the UI appears. It’s how the browser understands relationships.

Modern frontend often treats the DOM as a render artifact — the thing React, Vue, Svelte, Lit, or a server template produces and the framework then mostly hides. This chapter argues that the DOM is something more architecturally useful than that. It’s a context graph the application can use directly. And the architectural patterns Kitsune builds in Parts III–VI lean heavily on this fact.

The plainest way to see the DOM-as-context idea is to look at what happens automatically when an element is placed inside another.

Put an <input> inside a <form>, and the input’s value is included in the form’s FormData when the form submits. The input also participates in the form’s constraint validation — if the input is required and empty, the form blocks submission. No JavaScript wired this up. The container relationship in the DOM is itself the configuration.

Put a <button type="submit"> inside that same <form> (or omit the type attribute — submit is the default), and the button’s click triggers the form’s submission. Again, no JavaScript. The form-button relationship is structural, established by where the elements sit in the tree.

Wrap the form’s contents in a <fieldset disabled>, and every form control inside the fieldset is disabled. The disabled state inherits down the tree. Add <legend> inside the fieldset, and the legend becomes part of the accessible name of every control within. The grouping affects the accessibility tree.

Add a <label for="username"> and a <input id="username"> to the form, and the label becomes the accessible name of the input. Click the label, and the input is focused. The relationship is declared explicitly with for/id, but it’s a DOM-level relationship the browser interprets directly. If the input is inside the label — <label>Username <input></label> — the relationship is implicit through containment. Either way, the DOM structure communicates the relationship.

This is what containment as meaning means in practice. The platform looks at the tree and infers behavior from the structural relationships. Application code doesn’t have to wire it up. The DOM is already doing the work.

Anyone who has worked with desktop application frameworks will recognize this pattern.

In Apple’s Cocoa framework, every UI element belongs to a responder chain — a sequence of objects that an event can travel through. A click on a button doesn’t only go to the button. It travels up through the button’s containing view, the view controller, the window, and ultimately the application. Any of those responders can handle the event or let it pass through. The chain is structural — it follows the containment hierarchy of the UI — and the responder pattern is one of the defining architectural moves of NeXTSTEP / Cocoa / iOS development.

The DOM works the same way. The DOM event model has bubbling and capture phases. An event fires on the target element, then bubbles up through every ancestor. Any element along the path can listen for the event, inspect it, modify it, or stop its propagation. The pattern is, structurally, the Cocoa responder chain.

This is also recognizable from domain-driven design. Bounded contexts, in Eric Evans’s framing, are regions of an application where a specific vocabulary applies and specific invariants hold. An entity inside a bounded context inherits the context’s meaning. Crossing a context boundary requires explicit translation. The DOM, used architecturally, has the same shape. A region of the tree can establish a boundary — a context with a specific vocabulary (surface, feature, entity) — and elements inside the boundary inherit that context without having to know about it.

This isn’t an accidental parallel. The DOM is a hierarchical UI system, and hierarchical UI systems converge on similar architectures because the structural constraints are similar. Cocoa, the DOM, GTK, WinForms, SwiftUI, Jetpack Compose — each one has some version of structural containment that carries meaning. The DOM’s version is older than most of the others and has had two and a half decades to evolve.

Browser events already move through the DOM. The architectural value of this isn’t obvious until you build a system that leans on it.

When a user clicks a button, the click event fires on the button. The event then bubbles up to the button’s parent, its parent’s parent, and so on, all the way to the document root. At each step, listeners attached to that node can observe the event. The event object includes a target (the original button) and a currentTarget (the element whose listener is firing right now), so a listener high up the tree can see what was clicked and where the click originated.

This is the basis of event delegation, a pattern jQuery (Chapter 9) made widely known. Instead of attaching listeners to every button on the page, attach a single listener to a common ancestor and inspect the target when the event bubbles up:

document.addEventListener('click', (event) => {
const button = event.target.closest('[data-meta-event]')
if (!button) return
const eventName = button.dataset.metaEvent
// do something with the event
})

This is more than a performance trick. It’s an architectural affordance. A single listener at the top of a region can observe every interaction inside that region. The listener can walk up from the target through ancestors, collecting metadata as it goes. It can find the nearest enclosing form, the nearest enclosing dialog, the nearest enclosing boundary, the nearest enclosing route. It can build a complete picture of what was clicked, where, and in what context without any component below it having to know about the listener.

This is the pattern Kitsune’s metadata boundary uses. A <kit-boundary> element installs a single delegated listener for events inside it. When a click bubbles up from a button decorated with meta-event="profile.saved", the boundary collects the metadata, walks up to find ancestor boundaries, enriches the event with surface and feature and entity context, and emits a normalized application event with everything attached. The button didn’t import the analytics module. The form didn’t import the analytics module. The boundary listens, enriches, and emits — and any module that cares about the event subscribes to the runtime.

The chapter on metadata as a protocol surface (Ch 22) and the chapter on events as semantic communication (Ch 23) develop this pattern in detail. The point here is that the DOM’s bubbling event model is the substrate. Kitsune doesn’t invent this affordance. It uses it.

A small complication is worth naming because it matters for the web-components story.

Custom elements can use the Shadow DOM — an encapsulated DOM subtree that isn’t accessible from the document’s main tree through standard queries. Shadow DOM is one of the platform’s tools for component encapsulation. A <kit-button> can render its internal markup inside a shadow root, and the surrounding application can’t see into it with querySelector or similar.

Events fired inside a shadow root, by default, don’t escape it. The shadow boundary stops event propagation. This is a problem for the delegated-listener pattern above — if the click happens inside a custom element’s shadow DOM, the application’s top-level listener won’t see it.

The platform’s solution is composed events. An event created with new CustomEvent('something', { composed: true, bubbles: true }) crosses shadow boundaries when it bubbles. The event’s composedPath() method returns the full sequence of nodes the event traveled through, including those inside shadow roots.

This means a custom element can emit a composed event that the application’s boundary listener observes, even though the originating element is encapsulated. The encapsulation is preserved (the application can’t reach into the shadow DOM to manipulate things) while the communication remains open (events flow up freely if marked composed).

The pattern is significant for the rest of the book. Kitsune’s components use composed events to participate in the application’s event system without leaking their internal implementation. The boundary doesn’t care whether the source of an event is a <button> in a <form> or a <kit-button> whose internal shadow DOM contains a <button>. The composed event reaches the boundary either way.

CSS is another context system that uses DOM hierarchy directly.

A CSS custom property set on a parent element flows to its descendants:

[data-theme="dark"] {
--color-bg: #111;
--color-text: #f8f8f8;
}

Any element inside a data-theme="dark" region can use those custom properties without redefining them:

.card {
background: var(--color-bg);
color: var(--color-text);
}

The inheritance is automatic. The .card doesn’t have to be aware of the theme. The custom properties propagate down the tree until they’re overridden or used.

This is the same context-tree behavior the events example demonstrates, applied to presentation. A region of the DOM establishes a presentation context (a theme, a color scheme, a set of design tokens, a size mode), and every component inside the region inherits that context. The component doesn’t need to know which theme it’s in; it asks the cascade.

CSS has been doing this since the beginning, and it’s one of the reasons CSS feels coherent when used well. The cascade isn’t only a styling mechanism — it’s a context inheritance mechanism. The same architectural shape that the DOM uses for events is the shape CSS uses for properties.

Container queries (Chapter 12) extend this further. A container declares a containment context, and queries from descendants are answered against that container’s size. The container is, in effect, a layout context the descendants can inherit. The pattern is consistent: containment in the tree means inheritance of context.

It’s worth naming the framework parallel directly, because most readers will know it.

React’s Context API (React.createContext, useContext) provides a way to pass values through a React component tree without prop-drilling. A <ThemeContext.Provider value={...}> wraps a subtree, and any useContext(ThemeContext) call inside that subtree reads the value. The context system is, structurally, the DOM’s context-tree pattern reimplemented inside React.

Vue’s provide/inject does the same thing. Svelte’s contexts do the same thing. Every modern frontend framework has a context-inheritance mechanism, because the pattern is essential to any component-based UI.

The observation worth making is that the DOM already has this. Custom CSS properties propagate. Event bubbling propagates. The accessibility tree inherits roles and names from ancestors. Form controls inherit form state. The disabled attribute on a <fieldset> propagates. The lang attribute propagates. The contenteditable attribute propagates. The dir attribute propagates. A long list of HTML attributes have inheritance semantics built into the platform.

The frameworks reinvented the pattern in JavaScript because they didn’t want to depend on the DOM for it — the DOM was, at the time the patterns were established, hard to work with reliably across browsers, and the framework’s parallel reality was easier to control. Now that the DOM’s inheritance mechanisms work reliably and the platform’s primitives are stable, the framework version is a layer that could, in many cases, be replaced by the platform-native version underneath.

This is what use the platform means architecturally. Not don’t use a framework. Don’t reimplement what the platform already does, when the platform’s version is now reliable.

The chapter has been building toward an idea that deserves its own name. The Kitsune architecture in Part III calls it a boundary.

A boundary is a semantic region of the DOM that gives meaning to what happens inside it. The boundary establishes surface (where in the application this region appears), feature (which product capability this region belongs to), and entity (what the region is about). Components inside the boundary inherit this context. Events that bubble out of the boundary are enriched with the context. CSS custom properties scoped to the boundary apply to its contents.

In plain HTML, a boundary might be expressed with data attributes:

<main data-surface="settings-page" data-feature="preferences">
<section data-surface="profile-form"
data-entity-type="profile"
data-entity-id="me">
<form>
<button data-meta-event="profile.saved">Save profile</button>
</form>
</section>
<section data-surface="password-form"
data-entity-type="profile"
data-entity-id="me">
<form>
<button data-meta-event="password.changed">Change password</button>
</form>
</section>
</main>

The Save button’s meaning depends on which boundary it lives in. The same profile.saved event inside the profile-form section has different context from the same event inside an admin impersonation flow or an onboarding step. The boundary supplies the context. The button doesn’t have to know.

Kitsune later formalizes this with <kit-boundary> elements, programmatic boundary handles, and a metadata protocol. The point of this chapter is that the idea is browser-native. The DOM is already structured for context inheritance. A boundary is a discipline on top of that structure, not a new mechanism.

Without context, events are too small.

A click isn’t enough. Even save.clicked isn’t enough. Save what? Where? In which feature? For which entity? Under which route? In which mode? Was the user editing their own profile or admin-editing someone else’s? These are the questions every analytics call, audit log, observability breadcrumb, and authorization check needs answered. Without a context tree, each call site has to answer them manually:

track('Saved', {
surface: 'profile-form',
feature: 'preferences',
entityType: 'profile',
entityId: userId,
mode: currentUser.role === 'admin' ? 'admin' : 'self',
route: window.location.pathname,
// ...
})

Multiply this by every interesting event in the application, and a substantial fraction of the codebase becomes context-gathering code, repeated everywhere, easy to get wrong, brittle when refactored.

The DOM’s context tree provides an alternative. The boundary supplies surface, feature, and entity. The route adapter supplies the route context. The mode comes from a higher-level boundary. The component fires meta-event="profile.saved" and the runtime constructs the enriched event:

{
type: 'profile.saved',
context: {
route: '/settings/preferences',
surface: 'profile-form',
feature: 'preferences',
entity: { type: 'profile', id: 'user_123' },
mode: 'admin'
}
}

Modules subscribe to the runtime. The analytics module gets a fully-enriched event. The audit module gets the same event. The observability module gets the same event. None of them imports the component. The component doesn’t import any of them. The boundary supplied the context. The DOM supplied the propagation. The runtime supplied the coordination.

This is the move from local callbacks to application geography. The DOM’s context tree is the geography. The architecture above it is the discipline for using the geography productively.

The DOM’s tree structure isn’t an implementation detail to ignore. It’s a native context mechanism that the platform provides for free.

Modern frontend should use it for semantic regions, event delegation, metadata inheritance, theme inheritance, form grouping, accessibility landmarks, component containment, and diagnostics. Framework context systems remain useful, but they aren’t the only context systems available. The rendered tree can participate too.

Kitsune’s boundary model is a disciplined way to use what the DOM already provides. It doesn’t invent the context tree; it formalizes how to inherit from it.

The chapters that follow develop this further. Chapter 22 (Attributes Are a Protocol Surface) examines how attributes on DOM elements act as a declarative API. Chapter 23 (Events Are Not Callbacks) develops the event-as-semantic-communication argument in detail. Chapter 24 (Forms Are Transactions) takes the form-as-context-boundary idea seriously. The architectural shape that emerges across these chapters is the same shape — the DOM as a substrate that the application can lean on for context, propagation, and coordination, rather than treating it as a render target.

Build this structure in an HTML file:

<main data-surface="settings-page" data-feature="preferences">
<section data-surface="profile-form"
data-entity-type="profile"
data-entity-id="me">
<button data-meta-event="profile.saved">Save profile</button>
</section>
<section data-surface="password-form"
data-entity-type="profile"
data-entity-id="me">
<button data-meta-event="password.changed">Change password</button>
</section>
</main>

Write one delegated click listener on main. When a button is clicked:

  1. Find the closest element with data-meta-event.
  2. Walk ancestors up to main, collecting all data-surface values you encounter.
  3. Find the nearest data-feature attribute.
  4. Find the nearest data-entity-type and data-entity-id pair.
  5. Log a normalized object containing all of this.

The expected shape:

{
type: 'profile.saved',
context: {
surfaces: ['settings-page', 'profile-form'],
surface: 'profile-form',
feature: 'preferences',
entity: { type: 'profile', id: 'me' }
}
}

After the listener works, answer:

  1. How many lines of JavaScript did the listener take?
  2. If you added a third section with a different surface and entity, would the listener need any changes? (It shouldn’t.)
  3. If you wanted to filter events by feature (only log events whose feature is preferences), how would you express that?
  4. What happens if a button at the top of the page lacks the data-meta-event attribute? Does the listener handle that gracefully?
  5. How would the same pattern work for hover events, focus events, or form-submission events?

The point is to see the DOM as context, not output. The same pattern — one delegated listener at a boundary, components contributing metadata, the listener enriching the event — scales to entire applications. The architecture isn’t doing magic. It’s using the platform.