CSS in single-file components
See what scoped CSS compiles to, reach a child component or slot content on purpose with :deep() and :slotted(), and bind CSS values to state with v-bind().
What you'll learn
When you finish, you can do these.- 1Explain how scoped styles keep two .title rules apart
- 2Use :slotted() for slot content, :deep() inside a child
- 3Make a CSS colour follow your script with v-bind()
- 4Spot and fix a scoped rule that silently matches nothing
Picture a small online shop that sells mugs and teapots. Its page shows a card for a stoneware mug. The card's heading, the mug's name, has the class title, and a CSS rule for .title makes it large and blue. Lower down, a small heading has the class title too, and a second .title rule makes it grey. In plain CSS every rule reaches the whole page, so both rules hit both headings, and restyling the card changes a heading you never touched.
Vue's answer is <style scoped>: a style block whose rules stay inside their own component, one piece of the page in one .vue file. The Code tab holds the shop page as three components: App.vue, the page, with a colour picker and the grey heading; Card.vue, the product card; and PriceTag.vue, the price inside the card. By the end you can explain every rule in Card's style block. You need one idea from Class and style bindings, in the Beginner track: a class on a component's tag lands on its root, the top element of its template.
What scoped does to your CSS
scoped keeps Card's .title rule off App's heading by giving each component an id. When Vue compiles a component that has <style scoped>, it picks a short hash such as 7a3c9e12, adds the attribute data-v-7a3c9e12 to every element the component renders, and rewrites each selector, the part of a rule before the braces, to require that attribute.
Card's style block opens with a rule for the card and one for its heading, whose colour the section Values from state explains:
.card { border: 2px solid #dfe3e9; border-radius: 12px; padding: 16px; margin: 12px 0; }
.title { margin: 0 0 8px; font-size: 20px; color: v-bind(accent); }
Compiled, each selector asks for Card's id:
.card[data-v-7a3c9e12] { border: 2px solid #dfe3e9; border-radius: 12px; padding: 16px; margin: 12px 0; }
.title[data-v-7a3c9e12] { margin: 0 0 8px; font-size: 20px; color: var(--7a3c9e12-accent); }
App's heading has the class title too, but it carries App's id, so Card's rule never reaches it, and App's small grey .title rule never reaches Card's heading.
Scoping only limits where your rules go: a global stylesheet, or an unscoped <style> elsewhere, still reaches your elements.
Where a scoped rule stops
A scoped rule reaches only elements that carry its component's id, and not everything Card shows carries Card's. Its template has three kinds of elements:
<template>
<section class="card">
<h3 class="title">{{ title }}</h3>
<div class="body"><slot /></div>
<PriceTag class="tag" :amount="price" />
</section>
</template>
The section, the heading and the div with the class body are Card's own, and they carry Card's id. Inside that div is a slot: a <slot /> tag that shows whatever App writes between <Card> and </Card>, here the mug's description. The <PriceTag> tag shows the price: PriceTag.vue is a child component, a component used inside another, and Card is its parent.
The description belongs to App: App's template compiled it, so it carries App's id, and a plain .body p in Card misses it. Card reaches it with :slotted():
.body :slotted(p) { margin: 0; color: #4e5661; }
The description turns grey, with no space above or below. Vue compiles the rule to .body p[data-v-7a3c9e12-s]: elements that arrive through Card's slot get Card's id with an -s suffix, so the rule matches only them.
PriceTag's template is one paragraph, its root element, with a span inside that shows the amount:
<p class="price">Price <span class="amount">€{{ amount }}</span></p>
That root carries two ids, Card's and its own, so Card can place it from outside. The class tag on the <PriceTag> tag lands there too, so Card's .tag { margin-top: 12px; } reaches it without help. The span inside carries only PriceTag's id. To give it a yellow background from Card, wrap its part of the selector in :deep():
.tag { margin-top: 12px; }
.tag :deep(.amount) { background: #ffe9a8; }
The amount, €18, now sits on yellow. :deep() moves the attribute onto the part before it, .tag[data-v-7a3c9e12] .amount, so the span matches whoever rendered it.
:deep() also reaches markup inserted with v-html, the directive that fills an element with a string of HTML. Vue never compiles that string, so nothing in it carries an id. Say App writes <p v-html="'Glaze: <strong>sea green</strong>'"></p> into the card's slot: Card's .body strong misses the bold words, and .body :deep(strong) reaches them.
Use :deep() sparingly. It ties Card to PriceTag's class names, and renaming .amount inside PriceTag breaks Card without a warning. PriceTag can offer a sturdier way in: a prop, which is a named input it declares, or a class it documents.
Values from state, and CSS Modules
v-bind() inside <style> lets a CSS value follow the component's script, so the card's heading can take its colour from a colour picker. App.vue keeps the colour in a ref, a box that holds a value and tells Vue when it changes, and v-model keeps the picker and the ref in step:
<script setup lang="ts">
import { ref } from 'vue'
const accent = ref('#1566c5')
</script>
<template>
<label class="picker">Accent <input v-model="accent" type="color" /></label>
</template>
The <Card> tag passes the colour to Card as a prop, :accent="accent", and Card's .title rule reads it with color: v-bind(accent).
Vue compiles that to var(--7a3c9e12-accent), which reads a CSS custom property: a variable whose name starts with two dashes. Vue writes the property as an inline style, in the style attribute of Card's root element, where every element inside inherits it, and updates it whenever accent changes. The stylesheet never changes; only one inline value does. Pick a red in the picker, and the mug's name turns red.
CSS Modules are the other way to avoid clashes. <style module> replaces every class with a unique hashed name. The template reads the new names from $style, as in :class="$style.badge", and script reads them from useCssModule(). This playground cannot compile <style module>, so try it in a project. Most Vue code uses scoped.
What goes wrong
Both mistakes fail the same way: the rule compiles, matches nothing, and no warning appears. The first aims at the span inside PriceTag with a plain scoped selector. The price loses its yellow background.
.tag .amount { background: #ffe9a8; }
.tag :deep(.amount) { background: #ffe9a8; }
The broken rule compiles to .tag .amount[data-v-7a3c9e12]. It asks for an .amount that carries Card's id, and the span carries only PriceTag's.
The second styles slot content as if Card owned it. The description loses its grey colour and gets the browser's default paragraph margins back.
.body p { margin: 0; color: #4e5661; }
.body :slotted(p) { margin: 0; color: #4e5661; }
.body p[data-v-7a3c9e12] needs Card's id on the paragraph, and App rendered it. When a scoped rule does nothing, ask which component rendered the element. If it is not the one that holds the rule, you need :slotted() or :deep().
What to remember
scopedadds adata-v-attribute to the component's elements and to every selector in the block, so two components can both style.title.:slotted()reaches content a parent passed into your slot. A child component's root element carries the parent's id as well as its own;:deep()reaches the elements inside the child, andv-htmlmarkup.v-bind()in CSS becomes a custom property on the root element, so the value changes while the stylesheet stays the same.- A scoped rule that misses prints no warning. Ask which component rendered the element.
<style module>hashes class names and hands them to you as$style.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In Card.vue, add
border-color: v-bind(accent);to the.cardrule. Pick red with the colour picker: the border and the title turn red together. - In App.vue, add
<p v-html="'Glaze: <strong>sea green</strong>'"></p>inside<Card>. In Card.vue, add.body strong { color: #c2410c; }: "sea green" stays grey. Change the rule to.body :deep(strong)and it turns orange. - In Card.vue, change
.body :slotted(p)to.body p: the description loses its grey and gains space above and below. Then change.tag :deep(.amount)to.tag .amount: the yellow behind €18 disappears. Neither change prints a warning. Put both back and the card looks as it did.