Vue.js course · Module 6: Components
Scoped Styles and CSS Modules
In this lesson5
Style isolation in components works like the bulkheads between habitat modules - whatever happens in one compartment doesn't get into the neighboring ones.
Two components in the base use a .card class: one is a module card, the other a crew member card. Plain CSS applies to the whole page, so a rule written in one file repaints the cards in both. Vue offers several levels of isolation, and today you'll get to know each of them.
Scoped Styles
You know the scoped attribute from the Launch Zone: the rules in such a block apply only to the elements of the current component. Let's start with a card whose styles won't leak outside the file:
1<template>
2 <div class="card">
3 <h3 class="title">{{ title }}</h3>
4 </div>
5</template>
6
7<style scoped>
8/* Only for this component */
9.card {
10 border: 1px solid #ccc;
11 padding: 1rem;
12}
13
14.title {
15 color: #333;
16}
17</style>Vue adds an attribute such as data-v-7ba5bd90 to every element of the component and turns the .card selector into .card[data-v-7ba5bd90]. A crew member card in another file gets a different attribute, so this rule won't touch it. There is one deliberate exception: the root element of a child component is affected both by the child's styles and by the parent's scoped styles, so the parent can position it in its layout.
:deep() - a look inside the child
Sometimes, though, the parent has to style an element located deeper inside a child component. That's what the :deep() pseudo-selector is for:
1<style scoped>
2/* Deep selector - affect child components */
3.card :deep(.child-class) {
4 color: blue;
5}
6</style>The compiler turns this rule into .card[data-v-7ba5bd90] .child-class. The attribute stays only on .card, so .child-class also matches elements inside the children. The older >>> and /deep/ forms come from Vue 2 - the Vue 3 compiler still understands them, but it warns that they are deprecated and tells you to use :deep().
:slotted() and :global()
Two more pseudo-selectors deal with content from outside the component. A slot is a place in a child's template into which the parent puts its own content - you'll learn about it in Module Storage. Such content belongs to the parent, so the child's scoped styles don't cover it by default:
1<style scoped>
2/* Slotted content */
3:slotted(.slot-class) {
4 font-weight: bold;
5}
6
7/* Global in scoped */
8:global(.global-class) {
9 background: yellow;
10}
11</style>:slotted() directs a rule at the content inserted through a slot, while :global() turns off isolation for a single rule, so .global-class will work on the whole page. Use global rules inside scoped styles sparingly, because they are an open door in the bulkhead.
CSS Modules
The second way to isolate styles is CSS Modules. A <style module> block turns every class into a unique, generated name, and in the template you reach it through the $style object and the :class directive:
1<template>
2 <div :class="$style.card">
3 <h3 :class="$style.title">{{ title }}</h3>
4 <p :class="[$style.text, $style.highlighted]">Description</p>
5 </div>
6</template>
7
8<style module>
9.card {
10 border: 1px solid #ccc;
11}
12
13.title {
14 color: #333;
15}
16
17.text {
18 font-size: 14px;
19}
20
21.highlighted {
22 background: yellow;
23}
24</style>The square brackets apply two classes at once, because :class also accepts an array. The card class from this file has nothing to do with card in another component - both get different names. The difference from scoped is that isolation works through class names rather than attributes, and it covers only the elements to which you explicitly assign a class from $style. You can also read the generated names in the script with the useCssModule function:
1<script setup>
2import { useCssModule } from 'vue'
3
4const styles = useCssModule()
5console.log(styles.card) // e.g. "_card_1a2b3_2" - the name is generated by Vite
6</script>The name format depends on the build tool's configuration - by default Vite combines the class name, a hash of the content and the line number. A block can also get its own name, for example <style module="classes">, and then you write classes.card in the template.
Dynamic Styles
Sometimes a color should depend on the data, for example on the module type. The :style directive accepts an object, and its keys can also be CSS variables, meaning properties that start with two dashes:
1<template>
2 <div
3 class="card"
4 :style="{
5 '--primary-color': primaryColor,
6 '--border-width': borderWidth + 'px'
7 }"
8 >
9 <h3>{{ title }}</h3>
10 </div>
11</template>
12
13<script setup>
14import { ref } from 'vue'
15
16const primaryColor = ref('#8b7355')
17const borderWidth = ref(2)
18</script>
19
20<style scoped>
21.card {
22 border: var(--border-width) solid var(--primary-color);
23 color: var(--primary-color);
24}
25</style>The component sets the --primary-color and --border-width variables on its element, and the rule in <style scoped> reads them with the var() function. The stylesheet doesn't change - only the values of the variables change when the ref() containers change.
v-bind() in CSS
Vue can do the same thing more simply: the v-bind() function written directly into a CSS rule binds a value to a container from <script setup>:
1<template>
2 <div class="card">
3 <input v-model="color" type="color" />
4 <p>This text changes color!</p>
5 </div>
6</template>
7
8<script setup>
9import { ref } from 'vue'
10
11const color = ref('#8b7355')
12</script>
13
14<style scoped>
15p {
16 /* Reactive binding! */
17 color: v-bind(color);
18}
19</style>Pick a color in the field, and the text takes it on immediately. The compiler turns v-bind(color) into a CSS variable with a unique name, which Vue sets on the component's root element and updates on every change, so the stylesheet itself stays static.
Isolation Levels
From the widest to the narrowest reach: global styles without scoped apply to the whole page, <style scoped> to the elements of one component, CSS Modules only to elements with a class from $style, and an inline style through :style to one specific element. At NOVA LAB we write <style scoped> by default and reach for CSS Modules when class names are needed in the script - that's the starting point I recommend to you.
In the editor below, every component of the material settings panel has its own <style scoped> block. Change a color in one file and check that the other one stays unchanged. The panel also uses v-model on components, which you'll learn at the Communication Station.
Remember: scoped puts bulkheads around a module, CSS Modules give classes unique names, and v-bind() in CSS connects the look straight to the mission data.
Code for this lesson: App.vue
1<script setup>
2import { ref } from 'vue'
3import MaterialSettings from './MaterialSettings.vue'
4
5const printSettings = ref({
6 material: 'PLA',
7 temperature: 210,
8 speed: 50,
9 layerHeight: 0.2
10})
11
12const extruderSettings = ref({
13 flowRate: 100,
14 retraction: 5
15})
16
17function savePrintSettings() {
18 console.log('Print settings saved:', printSettings.value)
19 alert('Settings saved!')
20}
21
22function saveExtruderSettings() {
23 console.log('Extruder settings saved:', extruderSettings.value)
24 alert('Extruder settings saved!')
25}
26</script>
27
28<template>
29 <div class="settings-lab">
30 <div class="header">
31 <h1>Material Settings - NOVA LAB</h1>
32 <p class="subtitle">Component v-model</p>
33 </div>
34
35 <div class="info-panel">
36 <h3>v-model w komponentach</h3>
37 <p><strong>v-model</strong> is shorthand for :modelValue + @update:modelValue</p>
38 <p>You can use multiple v-model with different names: v-model:customName</p>
39 <code class="code-example">
40<!-- In the parent component -->
41v-model="value"
42v-model:customName="otherValue"
43
44<!-- In the child component -->
45defineProps(['modelValue', 'customName'])
46emit('update:modelValue', newValue)
47emit('update:customName', newValue)
48 </code>
49 </div>
50
51 <div class="settings-grid">
52 <!-- Single v-model -->
53 <div class="settings-card">
54 <h2>Basic print settings</h2>
55 <MaterialSettings v-model="printSettings" />
56
57 <div class="preview-section">
58 <h4>Value preview:</h4>
59 <pre>{{ JSON.stringify(printSettings, null, 2) }}</pre>
60 <button @click="savePrintSettings" class="btn btn-save">
61 Save settings
62 </button>
63 </div>
64 </div>
65
66 <!-- Multiple v-models -->
67 <div class="settings-card">
68 <h2>Extruder settings</h2>
69 <MaterialSettings
70 v-model="extruderSettings"
71 :show-advanced="true"
72 />
73
74 <div class="preview-section">
75 <h4>Value preview:</h4>
76 <pre>{{ JSON.stringify(extruderSettings, null, 2) }}</pre>
77 <button @click="saveExtruderSettings" class="btn btn-save">
78 Save extruder settings
79 </button>
80 </div>
81 </div>
82
83 <!-- Direct input with v-model -->
84 <div class="settings-card">
85 <h2>Manual editing (JSON)</h2>
86 <textarea
87 v-model="printSettings.material"
88 class="json-editor"
89 placeholder="Edit material name..."
90 ></textarea>
91 <p class="info-text">
92 Changes in the text field automatically update the printSettings object
93 </p>
94 </div>
95 </div>
96
97 <div class="summary-panel">
98 <h2>Summary of all settings</h2>
99 <div class="summary-grid">
100 <div class="summary-item">
101 <h4>Print settings</h4>
102 <ul>
103 <li>Material: <strong>{{ printSettings.material }}</strong></li>
104 <li>Temperature: <strong>{{ printSettings.temperature }}°C</strong></li>
105 <li>Speed: <strong>{{ printSettings.speed }}%</strong></li>
106 <li>Layer height: <strong>{{ printSettings.layerHeight }}mm</strong></li>
107 </ul>
108 </div>
109 <div class="summary-item">
110 <h4>Extruder settings</h4>
111 <ul>
112 <li>Flow rate: <strong>{{ extruderSettings.flowRate }}%</strong></li>
113 <li>Retraction: <strong>{{ extruderSettings.retraction }}mm</strong></li>
114 </ul>
115 </div>
116 </div>
117 </div>
118 </div>
119</template>
120
121<style scoped>
122.settings-lab {
123 background: linear-gradient(135deg, #0a0e27 0%, #1a1e3f 100%);
124 min-height: 100vh;
125 padding: 2rem;
126 color: #ffffff;
127 font-family: 'Courier New', monospace;
128}
129
130.header {
131 text-align: center;
132 margin-bottom: 2rem;
133 padding-bottom: 1rem;
134 border-bottom: 2px solid #00ff88;
135}
136
137.header h1 {
138 font-size: 2.5rem;
139 color: #00ff88;
140 text-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
141 margin: 0;
142}
143
144.subtitle {
145 color: #00b4d8;
146 font-size: 1.2rem;
147 margin-top: 0.5rem;
148}
149
150.info-panel {
151 background: rgba(0, 180, 216, 0.1);
152 border: 2px solid #00b4d8;
153 border-radius: 10px;
154 padding: 1.5rem;
155 margin-bottom: 2rem;
156}
157
158.info-panel h3 {
159 color: #00b4d8;
160 margin-top: 0;
161}
162
163.info-panel strong {
164 color: #00ff88;
165}
166
167.code-example {
168 display: block;
169 background: rgba(0, 0, 0, 0.5);
170 border-left: 4px solid #00ff88;
171 padding: 1rem;
172 border-radius: 5px;
173 color: #00ff88;
174 margin-top: 1rem;
175 white-space: pre;
176 overflow-x: auto;
177}
178
179.settings-grid {
180 display: grid;
181 grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
182 gap: 1.5rem;
183 margin-bottom: 2rem;
184}
185
186.settings-card {
187 background: rgba(0, 255, 136, 0.05);
188 border: 2px solid #00ff88;
189 border-radius: 10px;
190 padding: 1.5rem;
191}
192
193.settings-card h2 {
194 color: #00ff88;
195 margin-top: 0;
196 margin-bottom: 1.5rem;
197}
198
199.preview-section {
200 margin-top: 1.5rem;
201 padding-top: 1.5rem;
202 border-top: 2px solid rgba(0, 255, 136, 0.2);
203}
204
205.preview-section h4 {
206 color: #00b4d8;
207 margin-top: 0;
208}
209
210.preview-section pre {
211 background: rgba(0, 0, 0, 0.5);
212 padding: 1rem;
213 border-radius: 5px;
214 color: #00ff88;
215 overflow-x: auto;
216 border-left: 4px solid #00ff88;
217}
218
219.json-editor {
220 width: 100%;
221 min-height: 150px;
222 background: rgba(0, 0, 0, 0.3);
223 border: 2px solid #00b4d8;
224 border-radius: 5px;
225 padding: 1rem;
226 color: #ffffff;
227 font-family: 'Courier New', monospace;
228 resize: vertical;
229}
230
231.json-editor:focus {
232 outline: none;
233 border-color: #00ff88;
234 box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
235}
236
237.info-text {
238 color: #00b4d8;
239 font-size: 0.9rem;
240 margin-top: 1rem;
241}
242
243.btn-save {
244 background: #00ff88;
245 color: #0a0e27;
246 border: none;
247 padding: 0.75rem 1.5rem;
248 border-radius: 5px;
249 font-weight: bold;
250 cursor: pointer;
251 transition: all 0.3s ease;
252 font-family: 'Courier New', monospace;
253 margin-top: 1rem;
254}
255
256.btn-save:hover {
257 transform: scale(1.05);
258 box-shadow: 0 5px 15px rgba(0, 255, 136, 0.4);
259}
260
261.summary-panel {
262 background: rgba(0, 180, 216, 0.1);
263 border: 2px solid #00b4d8;
264 border-radius: 10px;
265 padding: 1.5rem;
266}
267
268.summary-panel h2 {
269 color: #00b4d8;
270 margin-top: 0;
271}
272
273.summary-grid {
274 display: grid;
275 grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
276 gap: 1.5rem;
277}
278
279.summary-item {
280 background: rgba(0, 0, 0, 0.3);
281 padding: 1.5rem;
282 border-radius: 8px;
283 border-left: 4px solid #00ff88;
284}
285
286.summary-item h4 {
287 color: #00ff88;
288 margin-top: 0;
289}
290
291.summary-item ul {
292 list-style: none;
293 padding: 0;
294}
295
296.summary-item li {
297 padding: 0.5rem 0;
298 color: #ffffff;
299 border-bottom: 1px solid rgba(0, 255, 136, 0.1);
300}
301
302.summary-item li:last-child {
303 border-bottom: none;
304}
305
306.summary-item strong {
307 color: #00b4d8;
308}
309</style>Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does the scoped attribute do in <style>?
2. How do you style child components in scoped styles?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
Add scoped styles to MarsPanel.vue with classes for the panel (.panel), title (.title), and status (.status).
- Click in order
Arrange the elements of a scoped style declaration:
- Vertical ordering
Arrange the steps for creating a component from start to use: