skip to content

Scoped & Module Styles

Scoped styles tag selectors with a data-v attribute, with :deep(), :slotted() and :global() as escapes; CSS modules and v-bind() in style are the alternatives. Interviewers probe leaks.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In a Vue single-file component, how does <style scoped> keep CSS local, and which outside elements can its rules still match?

level: juniorimportance: must knowfreq 70%

answer

  1. an attribute, not a class
  2. a per-component data-v hash
  3. attribute appended to the last compound
  4. the child's single root is shared

basics

~20 s

The SFC compiler stamps a per-component data-v-<hash> attribute on the elements its template renders and appends that attribute selector to each scoped rule. A child component's single root element also carries it, so parent rules still reach it.

solid answer

~40 s

With `scoped`, the SFC compiler gives the component an id such as `data-v-f3f3eg9`, adds that attribute to every element its template renders, and rewrites each selector so its **last** compound carries it: `.example` becomes `.example[data-v-f3f3eg9]` and `h1 .foo` becomes `h1 .foo[data-v-f3f3eg9]`. A child component's inner elements carry the child's id, not the parent's, so parent rules don't leak into them. The deliberate exception is the child's root: when the child renders a single root element, that element carries both ids, so the parent can style it for layout. Markup inserted with `v-html` carries no id at all, so scoped rules skip it.

code

vue · 22 lines
vue
<!-- ProductList.vue -->
<script setup lang="ts">
import ProductCard from './ProductCard.vue'
defineProps<{ ids: number[] }>()
</script>

<template>
  <section class="grid">
    <ProductCard v-for="id in ids" :key="id" :id="id" />
  </section>
</template>

<style scoped>
/* compiles to .grid > .product-card[data-v-<ProductList id>] */
.grid > .product-card {
  margin: 8px;
}
/* compiles to .price[data-v-<ProductList id>]: never matches the child's inner .price */
.price {
  font-weight: bold;
}
</style>

go deeper

for a junior

Recall that scoped adds a data-v-<hash> attribute to the component's elements and to its selectors, and that the child's root element is the one outside element parent styles still touch.

for a middle

Explain the rewrite precisely: only the last compound selector gets the attribute, so ancestors in the selector can be anywhere, and a multi-root child loses the parent's attribute.

for a senior

Show you can diagnose a style that silently stopped applying by inspecting the data-v attributes in the DOM, and that you avoid bare element selectors and recursive-component descendant traps.

for a principal

Frame scoped styles as a compile-time convention rather than real encapsulation, and weigh it against CSS modules or shadow DOM when a design system must guarantee isolation.

## What `scoped` promises A `<style>` block in a Vue single-file component (SFC) is global by default: its rules are injected into the page like any stylesheet and match every element in the document. Adding the `scoped` attribute changes that. Its rules then apply to the elements rendered by **this component's own template**, and not to elements rendered by other components. The effect resembles Shadow DOM encapsulation, but no Shadow DOM is involved and no polyfill is needed: it is a **compile-time rewrite** done by the SFC compiler (`@vue/compiler-sfc`, through a PostCSS transform) plus an attribute the renderer puts on elements. ## What the compiler emits The compiler assigns the component an id of the form `data-v-<hash>`. Two things are then generated from it: - **In the template output**, every element the component renders gets that attribute, for example `<div class="example" data-v-f3f3eg9>`. - **In the CSS output**, every selector is rewritten so its **last compound selector** also requires the attribute. ```css /* source, inside <style scoped> */ .example { color: red; } h1 .foo { color: red; } /* compiled */ .example[data-v-f3f3eg9] { color: red; } h1 .foo[data-v-f3f3eg9] { color: red; } ``` | Source selector | Compiled selector | What must carry the attribute | |---|---|---| | `.example` | `.example[data-v-f3f3eg9]` | the `.example` element | | `h1 .foo` | `h1 .foo[data-v-f3f3eg9]` | only the `.foo` element | | `p` | `p[data-v-f3f3eg9]` | the `p` element | Note the second row: only the **rightmost** element must belong to the component. The `h1` ancestor can be anywhere in the page, including outside the component. Scoping constrains the element being styled, not every element in the selector. ## The child root element rule Elements rendered by a child component carry the **child's** id, so a parent rule such as `.title[data-v-parent]` cannot match a `.title` inside the child. That is the leak scoping prevents. There is one deliberate exception, stated in the Vue docs: **a child component's root node is affected by both the parent's scoped CSS and the child's scoped CSS.** The renderer adds the parent's attribute to the element that is the child's root, so the parent can position, size or space the child for layout purposes. Details worth knowing: 1. It applies only when the child renders a **single root element**. If the child renders several top-level nodes (a fragment), none of them receives the parent's attribute, and a parent rule that used to match the root silently stops matching. 2. It reaches the root only, never the child's inner elements. 3. It is a feature, not a leak to fix: the child's own scoped rules and the parent's rules both apply to that one element, and the cascade decides conflicts as usual. ## What scoped rules do not reach - **Inner elements of child components.** Reaching them needs the `:deep()` pseudo-class. - **Markup inserted with `v-html`.** That HTML is parsed from a string at runtime, not compiled from the template, so it has no `data-v` attribute; scoped rules skip it unless written with `:deep()` from an element the component owns. - **Content a child renders from its slot, from the child's point of view.** Slot content is compiled in the parent's template and carries the parent's id; the child reaches it with `:slotted()`. ## Costs and tips from the Vue docs - **Scoped styles do not remove the need for classes.** A bare element selector such as `p { color: red }` becomes `p[data-v-...]`, which the docs call many times slower than a class selector; prefer `.example` style selectors. - **Be careful with descendant selectors in recursive components.** A rule `.a .b` still matches every `.b` inside a recursive child placed under `.a`, because the recursive child is the same component and carries the same id. - **Scoped and global blocks can coexist.** One SFC may hold a plain `<style>` block for global rules and a `<style scoped>` block for local ones. ## Summary Scoping is an attribute contract: the compiler marks the component's elements with `data-v-<hash>` and requires that mark on the element each rule styles. Everything else follows from who carries the mark: the component's own elements do, a child's single root carries both marks, and a child's inner elements, `v-html` output and elements outside the component do not.

  • A parent's scoped rule on a child's root stopped matching after the child gained a second top-level element. Why?
    The parent's `data-v` attribute is added to a child's root only when the child renders a single root element. Once the child's template has two top-level nodes it renders a fragment, none of those elements receives the parent's attribute, and the parent rule no longer matches. Wrap the child in one root again, or style the layout from the parent's own wrapper element.
  • Why do the Vue docs recommend classes over bare element selectors inside <style scoped>?
    A scoped `p { color: red }` compiles to `p[data-v-...]`, an element selector combined with an attribute selector, which the Vue docs describe as many times slower for the browser to match. Using a class, such as `.intro[data-v-...]`, virtually removes that cost, so scoped styles still benefit from class-based selectors.

Scoped CSS works like a building pass stamped with one company's name: the guard (the selector) only checks the pass of the person being styled, and a visitor standing in the doorway, the child's root, happens to carry two passes.

saying these in an interview costs you the question

  • Scoped styles use Shadow DOM under the hood.
  • Scoped styles work by adding a unique class name to each element.
  • A parent's scoped styles can never affect any element of a child component.
  • Every element in a scoped selector such as .a .b must belong to the component.
  • Scoped rules automatically apply to markup inserted with v-html.
open as a page

In a Vue SFC with <style scoped>, your rule for a third-party date picker's inner .dp-cell never applies; why, and how do you fix it without going global?

level: seniorimportance: must knowfreq 62%

basics

~10 s

Your rule compiles to .dp-cell[data-v-yours], but the picker's inner cells carry the library's attribute or none, so it never matches. Write .picker-wrap :deep(.dp-cell), which compiles to .picker-wrap[data-v-yours] .dp-cell and stays under your wrapper.

open as a page

In a Vue 3 component with <style scoped>, why do its rules not style the content a parent passes into its <slot>, and what does :slotted() change?

level: middleimportance: should knowfreq 38%

basics

~20 s

Slot content is compiled in the parent's template, so its elements carry the parent's data-v attribute, not the child's. :slotted(.x) compiles to .x[data-v-child-s], and Vue adds that -s attribute to whatever the child renders through its <slot>.

open as a page

In a Vue SFC, how does v-bind() inside a <style> block make CSS follow component state, and what does it compile to?

level: middleimportance: should knowfreq 45%

basics

~20 s

Each v-bind() becomes var(--<hashed-name>) in static CSS, and the component sets that custom property as an inline style on its root element, updating it when the bound state changes. Expressions must be quoted, as in v-bind('theme.color').

open as a page

In a Vue SFC, how do you use a <style module> block from the template and from <script setup>, and how does it differ from scoped?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

A <style module> block hashes its class names and exposes a map as $style in the template, or under a custom name with module="classes". In script setup, useCssModule() or useCssModule('classes') returns the same map. Scoped keeps class names and adds attributes.

open as a page