Vue scoped styles vs CSS Modules: which to use?
Contents
Scoped styles and CSS Modules both solve style leakage in Vue SFCs, but they solve it differently, one through attribute-selector rewriting at compile time, the other through class-name hashing borrowed from webpack/PostCSS conventions. That difference determines how they behave with :deep(), slots, dynamic classes, and specificity edge cases that only surface once a component library grows past a handful of contributors.
This guide breaks down the compiler internals, shows working code for both, and gives a decision table for picking one on a real project. If you're still evaluating whether Vue.js is the right foundation for your codebase, it's worth weighing the broader reasons for choosing Vue for your project before diving into styling internals.
Scoped styles vs CSS Modules: The core difference
Vue single-file components solve style leakage two different ways, and the mechanism, not the syntax, is what should drive your choice.
Scoped styles append a data-v-* attribute to every element in the template and rewrite each selector to match it, so the CSS still ships as plain class names with an attribute guard bolted on:
<style scoped>
.title { color: red; }
</style>
<!-- compiles to -->
<style>
.title[data-v-f3f3eg9] { color: red; }
</style>
CSS Modules take the opposite route. The build step hashes class names themselves, and you reference them through a $style object:
<template>
<p :class="$style.title">Hello</p>
</template>
<style module>
.title { color: red; }
</style>
This gives true local scope with zero reliance on attribute selectors, at the cost of losing plain class names in your markup.
The choice comes down to what you're optimizing for. If you want scoping that still falls back to global styles when needed, scoped styles win; if you want a hard guarantee that no child components ever inherit a class by accident, reach for a CSS module instead.
The rest of this piece covers :deep(), :slotted(), useCssModule, and when each strategy actually holds up at the root of your component tree. This kind of production debugging is common as more companies adopting Vue.js scale their component libraries across multiple apps.
How Vue scoped CSS works under the hood
When you add <style scoped> to a Vue single-file component, the compiler runs a PostCSS transform during the SFC pipeline, not at runtime. It parses the <template> block, appends a unique data-v-* attribute (an 8-character hash derived from the file path and content) to every element, and rewrites each CSS selector to append the same attribute selector. .button { color: blue; } becomes .button[data-v-7ba5bd90] { color: blue; }.
This matters because it changes CSS specificity math. An attribute selector adds the same weight as a class, so .button[data-v-xxx] is more specific than a plain .button from a global stylesheet, which is exactly why third-party component libraries sometimes lose visual overrides after a scoped wrapper is added.
This is a known failure mode in shared component libraries: two components can generate the same truncated hash after a build-tool change, and their scoped rules start bleeding into each other in production.
Child component internals are invisible to a parent's scoped block by default, since the hash only gets attached to elements owned by that component's own template. That's why :deep() exists: it strips the attribute restriction from the child side of a combinator, compiling :deep(.title) into .title[data-v-parent] instead of a fully scoped pair. :slotted() solves the inverse case, targeting content passed into a <slot> from the parent template.
Both are compiler tricks, not new CSS features.
Using <style scoped> in a Vue SFC
Scoped styles are Vue's default answer to CSS leakage: add the scoped attribute to a single-file component's <style> block and every class is automatically confined to that component's own template. Here's a minimal Vue SFC and its compiled output:
<template>
<div class="card">
<h2 class="title">Hello</h2>
</div>
</template>
<script setup>
// component logic
</script>
<style scoped>
.card { padding: 16px; }
.title { color: navy; }
</style>
Compiled, the template gets a data-v-7ba5bd90 attribute on each element, and the CSS is rewritten to .card[data-v-7ba5bd90] { padding: 16px; }. No build config, no naming convention, no manual class hashing. For a small team shipping a single Vue app, this is usually enough: fast to write, zero import ceremony, and the styles stay predictable as long as you know when :deep() and :slotted() become necessary for child and slot content.
Styling children and slots: :deep() and :slotted()
The data-v- attribute Vue generates for scoped styles only gets applied to elements rendered directly in the current component's template, so a rule like .card h2 { color: red; } silently fails to touch anything inside a child component or a slot, because that markup carries a different data-v- hash.
Vue's fix is the :deep() selector: .card :deep(.title) { color: red; } compiles to .card[data-v-xxx] .title { color: red; }, dropping the hash on the descendant side so the rule reaches into child DOM regardless of which component owns it.
This is exactly the failure mode that shows up when extracting a shared component from a larger one: the parent's scoped selector stops matching the child's new data-v-* hash the moment the markup moves into its own component boundary, and :deep() is what restores the intended look without dropping scoping entirely.
The old ::v-deep .title combinator syntax still compiles in most setups but is deprecated; write :deep() as a function, not a pseudo-element, or lint rules in newer Vue tooling will flag the build.
Slot content needs a separate mechanism because it's authored in the parent's template but rendered inside the child's DOM tree, so neither the parent's nor the child's plain scoped styles apply to it by default. :slotted() targets that content explicitly: ::slotted(.icon) { margin-right: 4px; } inside the child's <style scoped> block reaches into whatever markup the parent projected through the default or a named slot.
On teams shipping a shared component library, every :deep() usage is worth treating as a signal to audit the parent-child contract rather than a routine styling fix, since it usually means the class name crossing the boundary isn't part of the component's documented API.
The :global() escape hatch
The :global() selector lets a scoped styles block reach outside its own data-v-* boundary, applying an unscoped rule from inside an otherwise scoped <style> tag. Write :global(.utility-class) { color: inherit; } and Vue strips the hash entirely for that selector, compiling it as a plain global rule.
This is the escape hatch for styling a third-party class injected by a widget (a date picker, a chart library) that never passes through your own template and so can't be touched by :deep(). A scoped block fighting a vendor component's class with zero effect is the classic symptom, and wrapping the selector in :global() is what resolves it.
CSS Modules skip this problem structurally. Since class names come from the $style object rather than compiler-hashed selectors, there's no scope boundary to escape, and no equivalent hatch exists. If your component leans on third-party DOM you don't control, that's a real point in favor of scoped styles over CSS Modules, not against them.
Using <style module> and the $style object
CSS Modules gives a Vue single-file component (SFC) compile-time class hashing, exposed through a $style object instead of applied automatically like scoped styles. Add module to the <style> tag, and the compiler injects $style as a computed property in Options API or a binding returned by useCssModule() in Composition API.
<template>
<p :class="$style.warning">Locally scoped, no data-v- hash</p>
</template>
<style module>
.warning {
color: red;
}
</style>
Under the hood, css-modules/css-modules generates a locally scoped identifier (something like _warning_1k2b3), and Vue maps it into the $style object at render time rather than rewriting selectors post-hoc the way scoped styles do. There is no data-v-* attribute rewriting pass here, no attribute-selector matching against child elements, just a plain object lookup.
For Options API, bind it explicitly:
export default {
computed: {
className() {
return this.$style.warning
}
}
}
This class-object model removes an entire category of style-related regression bugs, the ones caused by data-v-* hash collisions between two builds of a shared component sharing the same class name, since there's no shared attribute namespace left to collide in.
CSS Modules with the composition API: useCssModule
UseCssModule is the Composition API hook that reads the $style object outside <template>, needed whenever a component computes class names in <script setup> rather than binding them inline. Vue's compiler still generates a locally scoped, hashed class map from your <style module> block; useCssModule just gives you programmatic access to it.
<script setup>
import { computed, useCssModule } from 'vue'
const style = useCssModule()
const props = defineProps<{ level: 'warning' | 'error' }>()
const rootClass = computed(() => style[props.level])
</script>
<template>
<p :class="rootClass">Locally scoped, dynamically resolved</p>
</template>
<style module>
.warning { color: orange; }
.error { color: crimson; }
</style>
This matters once a component's class logic outgrows a single :class="$style.x" binding, for example when a shared button component picks its style from a prop rather than a static template class, common on design-system components where a variant is computed, not literal.
One edge case worth flagging: useCssModule('customName') accepts a module name argument, required if you name your block <style module="customName">. Miss the argument and the hook silently returns undefined, a common debugging trap on multi-module SFCs.
For typed projects, pair CSS Modules with typed-scss-modules to generate .d.ts files per stylesheet, catching typos in $style keys at compile time instead of runtime.
TypeScript, v-bind(), and preprocessor support
Neither scoped styles nor CSS Modules gives you compile-time class safety out of the box, but the gap closes differently for each. With scoped styles, class="foo" in your template is just a string; typo it and nothing catches you until the browser renders unstyled markup.
CSS Modules at least route class names through $style, so typed-scss-modules (or vue-tsc with a .d.ts generator) can autogenerate typed exports from your SASS/LESS files, giving you autocomplete and a build failure on a renamed class. This kind of tooling reflects the broader evolution of CSS and TypeScript tooling within modern frontend development.
v-bind() in CSS works identically in both approaches, it injects reactive script values as CSS custom properties, so background: v-bind(themeColor) updates without a re-render. It's a common fit for theme tokens in scoped components, since it avoids re-triggering the scoped attribute's specificity chain.
Preprocessor support (SASS, LESS, Stylus) is handled by Vite the same way for both: <style lang="scss" scoped> and <style module lang="scss"> both compile before the scoping transform runs, so nesting, mixins, and variables behave identically either way.
When to use scoped styles vs CSS Modules
Pick scoped styles for small-to-mid teams shipping product UI fast, and CSS Modules once you have multiple engineers touching the same component library or a design system that gets extended across squads. This decision is just one piece of a broader puzzle when choosing your overall stack for a project.
Scoped styles win on speed. You write plain CSS, Vue's compiler appends a data-v-* attribute to every element and rewrites your selectors to match it, and you never think about naming again. That's fine until a shared component gets refactored and a child's markup moves outside the parent's template: retrofitting :deep() into a card component after a slot restructuring breaks child styling that had silently relied on attribute inheritance is a common follow-up fix.
CSS Modules trade that convenience for explicitness. Every class resolves through the $style object (or useCssModule in <script setup>), so collisions are structurally impossible rather than avoided by convention. Teams that already run a BEM naming convention on top of vanilla CSS tend to adopt CSS Modules fastest, since the discipline of block-element-modifier naming maps directly onto locally scoped, hashed class names.
| Criterion | Scoped styles | CSS Modules |
|---|---|---|
| Team size | 1-5 devs | 5+ devs, shared libraries |
| Setup cost | Zero config | $style / useCssModule wiring |
| Slot styling | Needs :slotted() |
Needs manual class passing |
| Naming discipline | Optional (BEM helps) | Enforced by module system |
Teams that switch a shared component library from scoped styles to CSS Modules consistently report the same payoff: style regression bugs tied to hash collisions drop sharply, because the module system removes the shared attribute namespace those collisions depend on.
Common pitfalls: Hash collisions and specificity wars
The most common scoped-styles failure is a data-v-* hash collision inside a shared component library. Vue derives the hash from the component's file path and content at build time.
In mono-repos with symlinked packages or duplicated dependency trees, two libraries can compile a component with the same name under different bundlers and end up with overlapping attributes at the root of the rendered markup.
These bundler quirks are a reminder that framework choice shapes how much of this complexity you inherit. If you're still weighing options, see this comparison on choosing the right frontend framework.
The symptom rarely announces itself as a build bug. Instead it looks like a specificity war: a child component's styles silently override the parent's, or a slot renders unstyled, and nobody suspects the shared attribute until someone diffs the compiled output.
CSS Modules sidestep this problem structurally. A $style class name is scoped by the module system itself rather than by an injected attribute, so there's no shared namespace for two component styles to collide in, even under identical bundler misconfigurations.
Specificity fights show up differently depending on which approach you want.
Scoped styles add an attribute selector, which bumps specificity just enough that overriding a child component's style from a parent template needs :deep(). Teams that forget this end up stacking !important instead, which quietly reintroduces the global styles problem scoped CSS was meant to solve.
CSS Modules avoid that specificity creep entirely, but composing styles across files means tracking which class each $style key resolves to. That's where useCssModule and typed lookups earn their keep, especially once a css module is shared across several child components in the same tree.
FAQ: Vue scoped styles and CSS Modules
How does Vue scoped CSS work under the hood?
<style scoped> block. The transform happens at build time, not runtime, so there is no CSS-in-JS overhead. This matters when auditing generated CSS in a production bundle for dead selectors.
What's the difference between `:deep()` and the old `::v-deep` syntax?
:deep() selector is the current, function-style replacement and produces cleaner compiled output for reaching into child component markup. Use :deep() in any new component; migrate legacy ::v-deep rules when you touch that file.
How do I use CSS modules with the composition API?
<template>, or call useCssModule() inside <script setup> when you need class names in logic. This gives locally scoped, hashed class names without any data-v-* attribute, the pattern to reach for whenever a component needs conditional class logic in a computed property.
Why isn't my scoped style applying to a child component?
:deep(), or expose a class prop from the child instead. This is a common surprise after refactors that move markup into a new child component.
Do CSS modules perform better than scoped styles?
Does useCssModule work in <script setup>?
useCssModule() works inside <script setup> and is the recommended way to access module classes outside the template. Call it once at the top of the script block, optionally passing a custom module name if you're using named CSS Modules. This is the same API used in the Options API, just imported directly.
Do CSS modules support TypeScript autocompletion?
Should I use BEM naming with Vue scoped styles?
<style scoped> are shorter and just as safe.
