Skip to content
TutorialStyling & UI

Scoped styles, CSS modules and v-bind() in single-file components

A <style> block in a .vue file can be scoped, a CSS module, or plain global CSS, and any of them can read a value from your script with v-bind(). Here is what each one compiles to and when to pick it.

vuejs.school team24 Jul 20256 min readVue 3.5

You write a rule in a parent component's <style scoped> block to restyle an element inside a child, and nothing happens. There is no error, and DevTools shows no matching rule. The cause is the way Vue scopes CSS, and once you know it you can reach into a child or into slot content on purpose, rather than by adding !important.

Two other <style> features solve different problems: CSS modules give each class a unique generated name, and v-bind() feeds a value from your script into a CSS rule. You will see what all three compile to and when to pick each. The examples are plain Vue 3.5 single-file components built with Vite. Nothing here needs a plugin or a config change.

How scoped styles stay inside a component

Here is a small component with a scoped style block.

components/PriceTag.vue
<script setup lang="ts">
defineProps<{ amount: number }>()
</script>

<template>
  <p class="price">
    <span class="amount">{{ amount.toFixed(2) }}</span>
    <span class="currency">GBP</span>
  </p>
</template>

<style scoped>
.price {
  font-weight: 600;
}
.amount {
  font-size: 1.5rem;
}
</style>

At build time the compiler gives the component a short id and does two things with it. Every element the template renders gets a data-v- attribute carrying that id, and every selector in the scoped block gets the same attribute appended. The rendered HTML is <p class="price" data-v-7ba5bd90>, and the CSS that reaches the browser is:

.price[data-v-7ba5bd90] {
  font-weight: 600;
}
.amount[data-v-7ba5bd90] {
  font-size: 1.5rem;
}

That is the whole mechanism. There is no shadow DOM and no renaming of your classes. A rule only matches an element that carries this component's attribute, so a .price in another component is untouched even though it uses the same class name.

Two practical consequences follow:

  • Keep using classes. A bare element selector such as p becomes p[data-v-7ba5bd90], and the browser has to check the attribute on every paragraph on the page. The Vue docs call a scoped element selector many times slower; a class avoids most of the cost.
  • Only this template's elements are marked. Anything that arrives another way (HTML inserted with v-html, or elements created by a library) has no attribute and is not styled by scoped rules.

A parent's scoped rule also reaches a child's root

When a scoped parent renders a child component, the child's root element gets both attributes: the child's own and the parent's. Vue does this on purpose so a parent can position its children. Say ProductCard renders an <article class="card"> with <PriceTag class="price" /> inside it. The resulting <p> carries data-v- for both components, so a rule in ProductCard like .price { margin-top: 1rem } lands.

That is the only element inside a child that the parent can see. Everything nested below the root carries the child's attribute alone.

One exception: a child whose template has more than one root element (a fragment) has no single root to mark. The parent's attribute goes nowhere, and its rule does not apply.

Reaching inside a child with ()

Now the parent wants the amount inside PriceTag to be red, and writes the selector it would write in plain CSS.

components/ProductCard.vueBreaks
<style scoped>
.card .amount {
  color: crimson;
}
</style>

The compiler turns this into .card .amount[data-v-1b2c3d4e], with the parent's attribute on the last part of the selector. But .amount lives inside PriceTag, so it carries only the child's attribute, and nothing matches. The style silently does nothing, and DevTools lists no matching rule for the span.

:deep() tells the compiler to put the attribute before the part you wrap instead of after it.

components/ProductCard.vueWorks
<style scoped>
.card :deep(.amount) {
  color: crimson;
}
</style>

This compiles to .card[data-v-1b2c3d4e] .amount. Only the .card part needs the parent's attribute now, so the rule reaches any .amount inside a card that ProductCard renders, including elements rendered by a child or inserted with v-html. An .amount anywhere else on the page is still untouched.

Use :deep() from a parent that has a good reason to restyle a child, such as a page overriding spacing in a shared component. Keep the part inside :deep() as specific as you can, because it matches every descendant of the card, grandchildren included.

Style slot content with (), global classes with ()

Slot content is the opposite case. When a parent passes <p> elements into a <slot />, those elements are rendered by the parent and carry the parent's attribute, so the child's scoped rules do not reach them. If the child has an opinion about what goes in its slot, it wraps the selector in :slotted().

Panel.vue uses both: a slotted rule for paragraphs passed in, and one global utility class.

components/Panel.vue
<template>
  <section class="panel">
    <slot />
  </section>
</template>

<style scoped>
:slotted(p) {
  margin: 0 0 0.75rem;
}
:global(.visually-hidden) {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}
</style>

:slotted(p) compiles to p[data-v-<id>-s], and Vue adds that -s attribute to the content rendered in this component's slots.

:global() does what it says: the rule is emitted without any attribute, exactly as if it were in a plain <style> block. It is handy for one utility class that a component needs to exist, without a second style block. If you have more than a couple of global rules, a separate <style> without scoped in the same file is clearer, and a component may have both.

CSS modules: classes as an object

<style module> takes a different approach to isolation. Instead of adding an attribute, it renames every class to something unique and hands you the mapping as an object called $style.

components/Badge.vue
<script setup lang="ts">
import { useCssModule } from 'vue'

defineProps<{ tone: 'info' | 'warn' }>()

const classes = useCssModule()
</script>

<template>
  <span :class="[classes.badge, classes[tone]]"><slot /></span>
</template>

<style module>
.badge {
  padding: 0.125rem 0.5rem;
  border-radius: 999px;
}
.info {
  background: #dbeafe;
}
.warn {
  background: #fef3c7;
}
</style>

In the browser classes.badge is a string such as _badge_8f3kd_1, and the stylesheet uses that same generated name. The names are hashed, so two components can each have a .badge class without clashing.

In the template, $style is enough: :class="[$style.badge, $style[tone]]" renders the same classes. useCssModule() returns that object inside <script setup>, which you need when script code picks a class, for example in a computed. If a component has several module blocks, give each a name with <style module="layout">, then read it as layout in the template or with useCssModule('layout').

The trade-off is visibility. With scoped styles, a .price in the DOM still says price, which makes debugging and end-to-end tests easier. With modules you read _price_8f3kd_1 and have to map it back in your head.

v-bind() in CSS: a value from your script

Sometimes the number belongs to your data, not your stylesheet: the width of a progress bar, a colour chosen by the user, a column count. v-bind() inside a <style> block reads a binding from the component and keeps the CSS up to date when it changes.

components/ProgressBar.vue
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{ value: number; colour?: string }>()

const width = computed(() => `${props.value}%`)
const fill = computed(() => props.colour ?? '#2563eb')
</script>

<template>
  <div class="track">
    <div class="fill" />
  </div>
</template>

<style scoped>
.track {
  height: 8px;
  border-radius: 4px;
  background: #e5e7eb;
}
.fill {
  height: 100%;
  width: v-bind(width);
  background: v-bind(fill);
  transition: width 200ms ease;
}
</style>

Stylesheets are static, so the compiler does not rewrite the CSS on every change. It replaces each v-bind(width) with a CSS custom property, something like var(--7ba5bd90-width). It also adds a small effect to the component that sets --7ba5bd90-width: 40% (for a value of 40) as an inline style on the component's root element, here the .track div.

When value changes, only that inline property changes, and the browser does the rest. Custom properties are inherited, so every element inside the root can read them. That is how .fill, a child of .track, gets its width and colour.

You can pass an expression instead of a name by quoting it: v-bind('props.value + "%"'). A computed reads better and gives you a place to put a default, so prefer it for anything beyond a plain ref.

Choose between scoped styles, CSS modules and v-bind()

Use <style scoped> by default. It keeps your class names readable, costs nothing to learn, and the attribute rule is easy to reason about once you know it. Reach for :deep() from a parent when you must adjust a child, and keep the selector tied to an element the parent owns.

Use <style module> when the class name is data: a component that picks between variants with classes[tone], or a design system that shares styles between components through composition (the CSS modules composes keyword).

Use v-bind() for a value from props or state, instead of building inline style objects by hand; it keeps the rule in CSS where it belongs. Each component instance sets one value per binding, on its root, so every item a v-for renders inside it sees the same value. When each item needs its own width or colour, bind :style on the item instead.

Check it stuck3 questions from the CSS in single-file components quiz.
Practice now
Was this helpful?

Keep reading