Declarative Shadow DOM: Server-Render Your Web Components

Declarative Shadow DOM: Server-Render Your Web Components

The Web Component SSR Problem

Web Components have always had a server-rendering problem. Shadow DOM — the encapsulation layer that makes components self-contained — could only be created with JavaScript. You’d ship an empty custom element tag, wait for JavaScript to load and execute, then the component would “pop” into existence as the shadow tree was attached.

This caused flash of unstyled content (FOUC), poor Core Web Vitals (especially LCP and CLS), and SEO issues because search engines couldn’t see content inside shadow roots that hadn’t been created yet.

Declarative Shadow DOM (DSD) fixes this. It lets you define shadow roots directly in HTML, using a <template> element with the shadowrootmode attribute. The browser attaches the shadow root during HTML parsing — before any JavaScript runs. Your Web Components render instantly, fully styled, from the first paint.

How It Works

Here’s the simplest possible Declarative Shadow DOM:

<my-card>
  <template shadowrootmode="open">
    <style>
      .card {
        border: 1px solid #e2e8f0;
        border-radius: 12px;
        padding: 20px;
        font-family: system-ui;
      }
      h2 { margin-top: 0; color: #1e293b; }
    </style>
    <div class="card">
      <h2>Hello from Shadow DOM</h2>
      <p>This content was rendered by the HTML parser, not JavaScript.</p>
    </div>
  </template>
</my-card>

When the browser encounters this markup, it doesn’t render the <template> as a normal template (which would be inert). Instead, it:

  1. Creates a shadow root on the <my-card> element
  2. Moves the template’s content into the shadow root
  3. Removes the <template> element from the DOM

The result is identical to calling this.attachShadow({ mode: 'open' }) in JavaScript — but it happens during HTML parsing, before any script executes.

The shadowrootmode Attribute

The shadowrootmode attribute accepts two values:

  • open — the shadow root is accessible via JavaScript (element.shadowRoot returns the shadow root)
  • closed — the shadow root is hidden from JavaScript (element.shadowRoot returns null)

For most use cases, open is the right choice. Closed shadow roots are useful for highly encapsulated components where you want to prevent external JavaScript from accessing the shadow tree, but they make debugging harder.

Slots and Light DOM Content

DSD supports slots, just like imperative Shadow DOM:

<user-profile>
  <template shadowrootmode="open">
    <style>
      .profile { display: flex; align-items: center; gap: 16px; }
      .avatar { width: 48px; height: 48px; border-radius: 50%; }
      ::slotted(h3) { margin: 0; font-size: 1.1rem; }
      ::slotted(p) { margin: 4px 0 0; color: #64748b; }
    </style>
    <div class="profile">
      <slot name="avatar"></slot>
      <div>
        <slot name="name">Unknown User</slot>
        <slot name="role"></slot>
      </div>
    </div>
  </template>

  <!-- Light DOM content projected into slots -->
  <img slot="avatar" src="/avatars/alice.jpg" alt="Alice" class="avatar">
  <h3 slot="name">Alice Chen</h3>
  <p slot="role">Senior Engineer</p>
</user-profile>

The light DOM content (img, h3, p) is projected into the shadow tree’s named slots. Search engines see this light DOM content in the HTML response, which is crucial for SEO.

Server-Side Rendering With DSD

The primary use case for Declarative Shadow DOM is server-side rendering. Your server generates the full HTML including the shadow root, and the browser renders it immediately.

With Node.js

function renderUserCard(user) {
  return `
    <user-card>
      <template shadowrootmode="open">
        <style>
          :host { display: block; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; }
          .name { font-weight: 600; font-size: 1.1rem; }
          .email { color: #6b7280; font-size: 0.9rem; }
        </style>
        <div class="name">${escapeHtml(user.name)}</div>
        <div class="email">${escapeHtml(user.email)}</div>
      </template>
    </user-card>
  `;
}

// Express route
app.get('/users', (req, res) => {
  const users = getUsers();
  const html = users.map(renderUserCard).join('');
  res.send(wrapInLayout(html));
});

With PHP

<?php foreach ($users as $user): ?>
  <user-card>
    <template shadowrootmode="open">
      <style>
        :host { display: block; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; }
        .name { font-weight: 600; }
        .email { color: #6b7280; }
      </style>
      <div class="name"><?= htmlspecialchars($user['name']) ?></div>
      <div class="email"><?= htmlspecialchars($user['email']) ?></div>
    </template>
  </user-card>
<?php endforeach; ?>

No framework required. Any server-side language that can output HTML can use Declarative Shadow DOM.

Hydration: Adding Interactivity

DSD gives you the initial render. When you need interactivity, JavaScript takes over — but the user sees content immediately instead of waiting for JS to load.

class UserCard extends HTMLElement {
  constructor() {
    super();
    // The shadow root already exists from DSD!
    // Don't call attachShadow() — it would throw
  }

  connectedCallback() {
    // Access the existing declarative shadow root
    const shadow = this.shadowRoot;

    if (shadow) {
      // Add interactivity to the pre-rendered content
      shadow.querySelector('.name').addEventListener('click', () => {
        this.dispatchEvent(new CustomEvent('user-selected', {
          detail: { name: shadow.querySelector('.name').textContent }
        }));
      });
    }
  }
}

customElements.define('user-card', UserCard);

Key detail: if a declarative shadow root already exists, calling attachShadow() throws an error. Check this.shadowRoot first and reuse the existing shadow root.

Style Encapsulation

Styles inside the shadow root are scoped to the component. External page styles don’t leak in, and shadow styles don’t leak out.

<style>
  /* This does NOT affect content inside shadow roots */
  p { color: red; font-size: 24px; }
</style>

<p>This is red and 24px.</p>

<my-component>
  <template shadowrootmode="open">
    <p>This is NOT red. Shadow DOM protects me.</p>
  </template>
</my-component>

This encapsulation is the entire point of Shadow DOM. Components are self-contained — their styles work regardless of what page they’re placed on.

Browser Support

BrowserSupport
Chrome / EdgeSince version 111
FirefoxSince version 123
SafariSince version 16.4

All major browsers support DSD as of 2024. The shadowrootmode attribute replaced the older shadowroot attribute (without the “mode” suffix), which was an earlier proposal. Make sure you use shadowrootmode, not shadowroot.

Common Mistakes

1. Calling attachShadow() When DSD Already Exists

If the HTML includes a declarative shadow root, calling this.attachShadow() in your custom element throws a DOMException. Always check this.shadowRoot first:

connectedCallback() {
  const shadow = this.shadowRoot || this.attachShadow({ mode: 'open' });
  // Now it works both with and without DSD
}

2. Duplicating Styles

If your server renders the DSD styles AND your JavaScript re-inserts them during hydration, you end up with duplicate style blocks. Design your component to reuse the existing shadow content, not replace it.

3. Using shadowroot Instead of shadowrootmode

The shadowroot attribute (without “mode”) was the original Chrome-only implementation. The standardized attribute is shadowrootmode. The old attribute may still work in Chrome but isn’t supported cross-browser.

4. Expecting DSD Without a Custom Element

DSD works on any element, not just custom elements. But if you’re building reusable components, use custom elements with DSD for the best developer experience and framework interop.

Best Practices

  • Use DSD for the initial render. Server-render the full shadow tree including styles. Let JavaScript handle only interactivity.
  • Always include styles in the shadow root. Since shadow DOM scopes CSS, your component’s styles must be inside the shadow root to take effect.
  • Design for progressive enhancement. Components should be functional (readable, properly styled) from the DSD markup alone. JavaScript should enhance, not create.
  • Escape user content. Since you’re generating shadow DOM from server-side templates, always escape dynamic content to prevent XSS.
  • Use named slots for SEO-critical content. Content in light DOM (projected via slots) is visible to search engines. Put important text in the light DOM, not hard-coded in the shadow template.

Wrapping Up

Declarative Shadow DOM solves the last major objection to Web Components: server-side rendering. You can now build encapsulated, reusable components that render instantly from HTML, work without JavaScript for the initial paint, and hydrate for interactivity when JS loads.

Combined with custom elements, slots, and CSS shadow parts, DSD makes Web Components a genuinely viable choice for production websites — not just SPAs that can afford a loading spinner while JavaScript initializes.

FAQ

Does DSD work with static site generators?

Yes. Any tool that outputs HTML can include DSD markup. Astro, Eleventy, Hugo, and even plain HTML files can contain declarative shadow roots. The browser handles the shadow attachment during parsing — no build tool integration needed.

Can I use DSD with React or Vue?

Sort of. React and Vue render to their own virtual DOM, which doesn’t natively emit <template shadowrootmode> tags. But you can use Web Components inside React/Vue apps, and those components can use DSD when server-rendered. Libraries like Lit SSR handle this integration.

Does DSD affect performance?

Positively. By rendering the shadow tree during HTML parsing, you eliminate the JavaScript execution cost of attachShadow() and innerHTML assignment. This improves LCP and reduces CLS. The tradeoff is slightly larger HTML responses (since the shadow content is inlined), but this is typically offset by reduced JavaScript payloads.

Is the template element removed after the shadow root is created?

Yes. The <template shadowrootmode> element is consumed by the parser. After the shadow root is attached, the template element no longer exists in the DOM. document.querySelector('template[shadowrootmode]') returns null.

editor's pick

latest video

news via inbox

Nulla turp dis cursus. Integer liberos  euismod pretium faucibua

Leave A Comment