Vue.js course · Module 6: Components

Scoped Styles and CSS Modules

6 min read
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. 1. What does the scoped attribute do in <style>?

  2. 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:

Useful articles