skip to content

A Vue 3 widget built with `defineCustomElement` renders in a shadow root, but its nested child components appear unstyled; why, and how do you fix it?

level: seniorimportance: should knowfreq 22%

answer

  1. where does SFC CSS normally go
  2. page CSS stops at the shadow root
  3. custom element mode inlines styles
  4. children need the same mode

basics

~20 s

Ordinary Vue SFC styles are extracted into the page's CSS file, which cannot reach inside a shadow root. Only SFCs compiled in custom element mode, such as .ce.vue files, carry CSS for injection, so nested children need that mode too.

solid answer

~50 s

By default the SFC build extracts every `<style>` block into a global CSS file. A Vue custom element renders into a shadow root, and page-level CSS does not apply inside it, so extracted styles are useless there. An SFC imported in **custom element mode** — a `.ce.vue` file, or any file matched by the build plugin's `customElement` option — keeps its CSS as strings on the component's `styles` option, and `defineCustomElement` injects them as `<style>` tags into each element's shadow root. Since Vue 3.5, child components rendered inside the element also get their `styles` injected into the host's shadow root — but only if they were compiled in custom element mode too. A child written as a plain `.vue` file has no `styles` to inject, so it renders unstyled. Rename the children to `.ce.vue`, or widen the plugin's `customElement` match.

code

ts · 14 lines
ts
import { defineCustomElement } from 'vue'
// Every SFC in the widget's tree is a .ce.vue file,
// so each one carries its CSS on the styles option.
import ReviewWidget from './ReviewWidget.ce.vue' // renders StarButton.ce.vue

console.log(ReviewWidget.styles) // array of inlined CSS strings

customElements.define(
  'review-widget',
  defineCustomElement(ReviewWidget, {
    // 3.5+: set on injected <style> tags; the page supplies its per-response nonce
    nonce: document.documentElement.dataset.cspNonce,
  }),
)

go deeper

for a junior

Remember that a .ce.vue file keeps its styles inside the component so they can be injected into the custom element's shadow root.

for a middle

Explain why extracted SFC CSS cannot style shadow content and how custom element mode exposes styles for injection.

for a senior

Spot unstyled nested children, make the whole tree compile in custom element mode, and weigh shadowRoot: false, nonce and per-instance style cost.

for a principal

Choose the widget's styling contract — encapsulated with CSS-variable theming or light DOM with page CSS — before consumers start depending on either.

## Where SFC styles normally go In an ordinary Vue app, the SFC build plugin takes each component's `<style>` block, processes it (including scoped rewriting), and **extracts** it into a CSS file that the page loads with a `<link>`. That is ideal for a normal page: one cacheable stylesheet, no CSS inside JavaScript. ## Why that fails inside a custom element A component turned into a custom element with `defineCustomElement` mounts inside the element's **shadow root** by default. The shadow boundary is a style boundary: selectors in the page's stylesheets do not match elements inside it. So the extracted CSS file can be on the page and still style nothing in the widget. ## Custom element mode The official SFC tooling has a second compilation mode for this case: 1. A file whose name ends in `.ce.vue` — or any file matched by the build plugin's `customElement` option — is compiled in **custom element mode**. 2. In that mode, its `<style>` blocks are not extracted; they are **inlined as strings** and exposed on the component's `styles` option (`Example.styles` is an array of CSS strings). 3. When `defineCustomElement` creates an element from that component, it inserts a `<style>` tag for each string into the element's shadow root. The root component is usually the one people rename, because that is the file passed to `defineCustomElement`. ## The nested-children problem The widget's root is `Widget.ce.vue`, but it renders `StarButton.vue` and `Tooltip.vue`. Those two are compiled normally, so their styles went to the extracted CSS file. Inside the shadow root they render with no styles. Since **Vue 3.5**, when a component renders inside a Vue custom element that has a shadow root, Vue injects that component's `styles` into the host's shadow root as well (child styles before parent styles, so the parent can override). The requirement is that the child **has** a `styles` option — that is, it was compiled in custom element mode. Before 3.5, only the root component's styles were injected. Fixes, in order of preference: - **Rename the children** to `.ce.vue`, so every component in the widget's tree carries its CSS. - **Widen the build plugin's `customElement` option** (a pattern, or `true` for all SFCs in a dedicated widget build) so you do not have to rename files. - **Keep shared design-system styles in one string** added to the root component's `styles`, when children come from a library you cannot recompile. ## The `shadowRoot: false` escape hatch Vue 3.5 added a `shadowRoot` option: `defineCustomElement(Comp, { shadowRoot: false })` renders the component as the element's light-DOM children, with no shadow root. Page CSS then applies — but so does every page style you did **not** want, and the `styles` option is not injected at all; in development Vue warns that style injection is not supported with `shadowRoot: false`. It trades encapsulation for simplicity, and it also changes how slots behave. ## Other options added in 3.5 | Option | What it does | |---|---| | `shadowRoot` | `false` renders without a shadow root, as above | | `nonce` | sets a `nonce` attribute on the injected `<style>` tags, for pages with a strict Content Security Policy | | `configureApp` | configures the element's own app instance, for example installing a plugin | A page with a Content Security Policy that forbids inline styles would otherwise block every injected `<style>` tag, and the widget would render unstyled for a completely different reason — worth checking in the browser console before assuming a build problem. ## What it costs - The CSS is shipped inside JavaScript and inserted into **every instance's** shadow root, so hundreds of widgets on one page mean hundreds of `<style>` tags. - Server-rendered output repeats the styles per element. - Page theming cannot reach in except through CSS custom properties, which do inherit across the boundary — design the widget's theming around them. When interviewers ask this, they are checking that you know the default build extracts CSS, that `.ce.vue` is an opt-in per file, and that since 3.5 the whole component tree — not just the root — must opt in.

  • What changes if you pass `shadowRoot: false` to `defineCustomElement`?
    Vue 3.5 then renders the component as the element's light-DOM children with no shadow root. Page CSS reaches the widget — useful for sites that must theme it — but page styles can also break it, and Vue does not inject the component's `styles` at all; it warns in development that style injection is not supported with `shadowRoot: false`. The page must ship the widget's CSS itself.
  • How can the host page theme a shadow-root Vue widget without breaking encapsulation?
    Expose design tokens as CSS custom properties. Custom properties inherit through the shadow boundary, so the widget's injected CSS can use `var(--rw-accent, #444)` and the page sets `--rw-accent` on the element or an ancestor. Everything else stays encapsulated, and the token names become part of the widget's documented contract.

saying these in an interview costs you the question

  • Scoped styles in the root .ce.vue file also style every child component.
  • The page's global stylesheet styles elements inside the shadow root.
  • shadowRoot: false keeps the injected styles but drops encapsulation.
  • Any component rendered inside the element gets its styles injected, whatever its file type.
  • Styles are injected once per page, not once per element instance.