Scoped Styles
How <style scoped> compiles to an attribute selector, and why it stops at a child component's internals.
<style scoped> /* .sel is compiled to .sel[data-v-hash] */ </style>A <style scoped> block in a single-file component applies only to that component's own template. It exists so fifty components can each define a class called .title without colliding.
How it works
At compile time Vue gives the component a hash and adds it as an attribute, such as data-v-f3f3eg9, to every element in that template. Each selector in the block gets the matching attribute selector appended, so .card becomes .card[data-v-f3f3eg9]. There is no shadow DOM and no runtime work: it is ordinary CSS with one extra attribute selector. Specificity and the cascade behave normally, and global rules still reach your elements. Scoping stops your rules getting out, not other rules getting in.
Where the boundary sits
Elements inside a child component carry the child's hash, not yours, so your rules cannot reach them. The child's root element is the one exception: it receives both hashes. That means a parent can set layout on a child it renders — margin, width, grid placement — which is deliberate, because layout belongs to the parent.
What trips people up
Markup inserted with v-html never passes through the template compiler, so it gets no attribute and scoped rules miss it entirely. Element selectors such as p match more than you expect inside recursive components, and attribute selectors are slower than class selectors, so prefer classes. For genuinely global rules, keep a plain <style> block beside the scoped one in the same file.
Example
<!-- ProductCard.vue -->
<script setup lang="ts">
const props = defineProps<{
title: string
body: string
}>()
</script>
<template>
<article class="card">
<h3 class="card-title">{{ props.title }}</h3>
<p class="card-body">{{ props.body }}</p>
</article>
</template>
<style scoped>
.card {
border: 1px solid #d6d6d6;
border-radius: 6px;
padding: 1rem;
}
.card-title {
margin: 0 0 0.5rem;
font-size: 1.125rem;
}
.card-body {
margin: 0;
color: #444;
}
</style>When to use it
- Style a card, form row or table cell without worrying what class names other components already use.
- Set the width and margin of a child component from the parent that lays it out.
- Keep one global rule, such as a focus outline or a dragging state, next to the component that owns it.
More examples
What the compiler produces
The hash is the whole mechanism. Reading this once makes the rest of scoping obvious: a selector that does not end up next to a hashed element will not match, which is why v-html content and a child's inner elements are out of reach.
<!-- the rendered markup and stylesheet for the .card rule above -->
<article class="card" data-v-f3f3eg9>
<h3 class="card-title" data-v-f3f3eg9>Hard drive</h3>
<p class="card-body" data-v-f3f3eg9>Two terabytes.</p>
</article>
<style>
.card[data-v-f3f3eg9] {
border: 1px solid #d6d6d6;
border-radius: 6px;
padding: 1rem;
}
</style>A parent styling a child's root element
The legitimate way to style a child from outside: put a class on the child tag and style that class. It works because the child's root element gets the parent's hash too. Use it for size and position, and leave the child's internals to the child.
<!-- PriceRow.vue -->
<script setup lang="ts">
import PriceTag from './PriceTag.vue'
const props = defineProps<{
amounts: number[]
}>()
</script>
<template>
<div class="row">
<price-tag
v-for="amount in props.amounts"
:key="amount"
:amount="amount"
class="cell"
/>
</div>
</template>
<style scoped>
.row {
display: flex;
gap: 0.5rem;
}
/* .cell lands on PriceTag's root element, which carries both hashes */
.cell {
flex: 0 0 6rem;
}
</style>A global block beside a scoped one
One file can hold both kinds of block. Reach for the unscoped block for behaviour that must affect descendants you do not own, such as suppressing text selection everywhere during a drag, and keep everything else scoped.
<!-- AppShell.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const dragging = ref(false)
</script>
<template>
<div class="shell" :class="{ 'is-dragging': dragging }">
<slot />
</div>
</template>
<style>
/* no scoped attribute: this rule is global, on purpose */
.is-dragging * {
user-select: none;
}
</style>
<style scoped>
.shell {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
}
</style>
Discussion