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?
answer
- where does SFC CSS normally go
- page CSS stops at the shadow root
- custom element mode inlines styles
- children need the same mode
basics
~20 sOrdinary 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 sBy 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 linesimport { 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
Remember that a .ce.vue file keeps its styles inside the component so they can be injected into the custom element's shadow root.
Explain why extracted SFC CSS cannot style shadow content and how custom element mode exposes styles for injection.
Spot unstyled nested children, make the whole tree compile in custom element mode, and weigh shadowRoot: false, nonce and per-instance style cost.
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.