Стилизация во Vue: scoped, v-bind и CSS Modules
Во Vue стили живут прямо в компоненте: .vue-файл объединяет разметку, логику и CSS. Это удобно, но порождает вопросы, которых нет при обычной вёрстке — как ограничить область действия правил, как связать реактивное состояние с CSS и как избежать конфликтов при сборке приложения из сотен компонентов. Vue даёт для этого собственные механизмы: scoped-атрибуты, :deep()/:slotted()/:global(), v-bind() в CSS и встроенные CSS Modules.
В этой статье разберём, как на самом деле работает <style scoped>, как дотянуться до дочерних компонентов и контента слотов, как пробросить реактивные значения в стили, как применять CSS Modules и динамические классы, как строить темизацию через CSS custom properties, а также как интегрировать Tailwind и UI-киты и управлять порядком применения стилей.
Содержание
- Стили в SFC
- scoped-стили и data-атрибуты
- Работа с дочерними компонентами: :deep, :slotted, :global
- v-bind() в CSS и useCssVars
- CSS Modules: style module и useCssModule
- Динамические классы и стили
- Темизация через CSS custom properties
- Интеграция с Tailwind и UI-китами
- Порядок стилей и каскад
- Лучшие практики и антипаттерны
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Стили в SFC
Single File Component может содержать три блока: <script>, <template> и один или несколько <style>. Блок стилей бывает трёх видов:
<!-- глобальный стиль: применяется ко всему документу -->
<style>
h1 { font-family: system-ui; }
</style>
<!-- стиль, ограниченный текущим компонентом -->
<style scoped>
.card { border-radius: 8px; }
</style>
<!-- стиль как CSS Modules: классы хэшируются -->
<style module>
.title { font-weight: 600; }
</style>
Ключевые возможности блока <style>:
- Несколько блоков в одном файле. Можно совмещать глобальные и scoped-стили, разносить
<style module>и обычный CSS. - Атрибуты
langиsrc.<style lang="scss" scoped>включает препроцессор, а<style scoped src="./card.css">подключает внешний файл с нужным режимом. - Обработка сборщиком. Блок стилей компилируется плагином Vue для Vite/Webpack: обрабатываются
scoped,moduleиv-bind(). Именно поэтому эти механизмы доступны только внутри SFC, а не в отдельном.css-файле.
Важно помнить: обычный <style> без атрибутов глобален. Компонентно-локальное поведение нужно включать осознанно — через scoped или module. Общая теория подходов к CSS (CSS Modules вне Vue, CSS-in-JS, Tailwind, токены, методологии именования) разобрана в статье Способы применения CSS, токены и архитектура стилей; здесь мы сфокусируемся на Vue-механике.
scoped-стили и data-атрибуты
<style scoped> — самый распространённый способ изолировать стили компонента. Vue не использует Shadow DOM: вместо этого он применяет атрибутивный скоупинг.
При компиляции Vue:
- Добавляет каждому элементу шаблона уникальный атрибут вида
data-v-7ba5bd90(хэш на основе расположения компонента). - Переписывает селекторы, дописывая этот атрибут к последней части составного селектора.
<script setup>
</script>
<template>
<div class="card">
<p class="title">Заголовок</p>
</div>
</template>
<style scoped>
.card { border-radius: 8px; }
.card .title { font-weight: 600; }
</style>
Результат примерно такой:
<div class="card" data-v-7ba5bd90>
<p class="title" data-v-7ba5bd90">Заголовок</p>
</div>
.card[data-v-7ba5bd90] { border-radius: 8px; }
.card .title[data-v-7ba5bd90] { font-weight: 600; }
Что из этого следует практически:
- Стили остаются в документе — это не настоящая инкапсуляция, а пространство имён из атрибутов. Никакой Shadow DOM не создаётся.
- Специфичность немного растёт. Селектору добавляется атрибут, поэтому
pпревращается вp[data-v-...]. Глобальный.cardтакой же специфичности будет перебит только порядком. - Дочерний компонент не затрагивается целиком. Правила родителя не проникают во внутреннюю разметку ребёнка — там свой
data-v-атрибут. - Корневой элемент ребёнка получает атрибут родителя тоже. Поэтому родитель может задать ребёнку внешние стили (например,
margin) обычным scoped-правилом — это осознанное решение, чтобы компонент поддавался раскладке. - Каждый корень получает атрибут. Если у компонента несколько корневых узлов (fragment), атрибут ставится на все.
scoped подходит для стилей самого компонента. Но иногда нужно больше — заглянуть внутрь ребёнка или стилизовать содержимое слота. Для этого есть специальные селекторы.
Работа с дочерними компонентами: :deep, :slotted, :global
Три псевдокласса расширяют scoped-стили. Все они используются внутри <style scoped>.
:deep()
Пробивает границу вниз — в элементы, отрендеренные дочерним компонентом. Обычно пишется после селектора-обёртки:
<!-- Parent.vue -->
<template>
<Child class="panel" />
</template>
<style scoped>
.panel :deep(.row) {
display: flex;
gap: 8px;
}
</style>
Компилируется примерно в .panel[data-v-родителя] .row. То есть стиль применяется к .row внутри ребёнка, но только если он лежит внутри элемента .panel, помеченного скоуп-атрибутом родителя.
:deep() без предка (:deep(.row) { }) тоже работает, но тогда правило становится почти глобальным в пределах атрибута и легко «протекает» — предпочитайте явную обёртку. Применяйте :deep() только к компонентам, которые вы контролируете: это связывает вас с их внутренней разметкой.
:slotted()
Стилизует контент, переданный в компонент через слот. Такой контент создаётся в родителе и принадлежит его scope, поэтому scoped-стили ребёнка на него не действуют. :slotted() снимает это ограничение:
<!-- Card.vue -->
<template>
<div class="card">
<slot />
</div>
</template>
<style scoped>
:slotted(.highlight) {
background: #fffbe6;
padding: 4px 8px;
}
</style>
<!-- использование -->
<Card>
<p class="highlight">Важное уведомление</p>
</Card>
Стиль применится к .highlight, который родитель положил в слот.
:global()
Выводит правило из-под scoped-атрибута, делая его глобальным:
<style scoped>
:global(.visually-hidden) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
</style>
Так из компонента можно повлиять на сторонний класс, который не помечен data-v-атрибутом. Используйте точечно — это возврат к глобальному CSS со всеми его рисками.
Устаревшие формы
Исторически существовали синтаксисы >>>, /deep/ и ::v-deep. Первые два не работают в препроцессорах, третий объявлен устаревшим. Актуальная и рекомендуемая форма — :deep(). Не смешивайте их в одном проекте.
v-bind() в CSS и useCssVars
Иногда стиль зависит от реактивного значения из <script>. Гонять целые объекты через :style не всегда удобно: псевдоклассы и медиа-запросы так не выразить. Vue позволяет использовать реактивные значения прямо в CSS через v-bind():
<script setup>
import { ref } from 'vue';
const color = ref('tomato');
</script>
<template>
<p class="accent">Динамический цвет</p>
</template>
<style>
.accent {
color: v-bind(color);
}
</style>
Под капотом Vue компилирует v-bind(color) в обращение к CSS-переменной:
.accent {
color: var(--7ba5bd90-color);
}
а имя переменной генерируется с хэшем компонента, чтобы избежать коллизий. Рантайм выставляет --7ba5bd90-color на корневом элементе компонента и обновляет её при изменении реактивного значения. Механизм работает в обычном <style> и в <style scoped>.
Нюансы v-bind():
- Значение приводится к строке или числу. Объекты и массивы не сериализуются автоматически — передавайте уже готовую строку.
- Сложные выражения берутся в кавычки:
v-bind('theme.font'),v-bind('props.offset + "px"'). - Нужен один корневой элемент, иначе переменную некуда выставлять (у фрагментов Vue не может выбрать узел).
- Работает только внутри SFC — это компилируемая директива, в отдельном
.css-файле её нет.
useCssVars
v-bind() — синтаксический сахар. Его рантайм-эквивалент — composable useCssVars, который пригодится в render-функциях или когда переменные нужно вычислять программно:
import { ref, useCssVars } from 'vue';
export default {
setup() {
const color = ref('tomato');
useCssVars(() => ({
color: color.value,
}));
return { color };
},
};
useCssVars getter возвращает объект CSS-переменных, которые Vue выставляет на корневом DOM-элементе компонента. Как и с v-bind(), у компонента должен быть один корень.
Практическое правило: для стилей в <style> используйте v-bind() — он читается лучше; useCssVars держите для случаев, когда CSS-блока нет (render-функции) или переменные собираются в коде.
CSS Modules: style module и useCssModule
Если нужна настоящая локальность классов с хэшированными именами — в Vue есть встроенные CSS Modules. Атрибут module превращает блок в модуль:
<template>
<p :class="classes.title">Заголовок</p>
</template>
<style module="classes">
.title {
font-weight: 600;
}
</style>
Vue компилирует имена классов в хэши (например, .title → ._title_1a2b3) и инжектит объект-словарь в компонент. Доступ к нему:
- В шаблоне — через переменную модуля. По умолчанию это
$style:
<template>
<p :class="$style.title">…</p>
</template>
<style module>
.title { font-weight: 600; }
</style>
- В
<script setup>— черезuseCssModule():
<script setup>
import { useCssModule } from 'vue';
const classes = useCssModule();
</script>
<template>
<p :class="classes.title">…</p>
</template>
<style module>
.title { font-weight: 600; }
</style>
- Именованные модули. Атрибут
module="classes"задаёт имя, и тогдаuseCssModule('classes')возвращает соответствующий словарь. Это позволяет держать в файле несколько независимых модулей.
<script setup>
import { useCssModule } from 'vue';
const base = useCssModule('base');
const theme = useCssModule('theme');
</script>
<style module="base">
.card { padding: 16px; }
</style>
<style module="theme">
.card { border: 1px solid #e5e7eb; }
</style>
В Options API объект модуля доступен как this.$style (или как this.$<moduleName>). Внутри модуля продолжают работать :global(.foo) для глобальных селекторов и composes из postcss-modules.
CSS Modules дают изоляцию без обёртки из data-атрибутов и хорошую интеграцию с TypeScript (через .d.ts), но динамические значения в них всё равно пробрасывают через CSS-переменные. Общая теория CSS Modules — в статье про способы применения CSS.
Динамические классы и стили
Привязка class и style — базовая Vue-механика, но на собеседовании важно чётко знать все формы и их взаимодействие со статическими атрибутами.
:class
:class принимает три формы:
<script setup>
import { ref, computed } from 'vue';
const isActive = ref(true);
const hasError = ref(false);
const sizeClass = computed(() => (isActive.value ? 'is-active' : ''));
</script>
<template>
<!-- объект: ключ включается, если значение истинно -->
<div :class="{ active: isActive, 'text-danger': hasError }" />
<!-- массив: строки, объекты и вложенные объекты -->
<div :class="['card', { active: isActive }, sizeClass]" />
<!-- строка / computed -->
<div :class="sizeClass" />
</template>
Статический class и :class Vue объединяет, а не перезаписывает:
<div class="card" :class="{ active: isActive }" />
<!-- результат: class="card active" -->
На компоненте class не задаётся как пропс, а проваливается на корневой элемент (attribute inheritance). Если у компонента отключён inheritAttrs: false, проваливание нужно делать вручную через v-bind="$attrs".
:style
:style принимает объект или массив объектов:
<script setup>
import { ref, reactive } from 'vue';
const activeColor = ref('#3b82f6');
const fontSize = ref(16);
const baseStyles = reactive({ padding: '8px 16px' });
</script>
<template>
<p :style="{ color: activeColor, fontSize: fontSize + 'px' }">…</p>
<!-- массив объектов объединяется -->
<p :style="[baseStyles, { borderColor: activeColor }]">…</p>
<!-- custom properties задаются строкой в kebab-case -->
<p :style="{ '--accent': activeColor }">…</p>
</template>
Правила, которые стоит помнить:
- Свойства можно писать в camelCase (
fontSize) или kebab-case ('font-size'); Vue добавит вендорные префиксы там, где нужно. - Для CSS custom properties используйте kebab-case в кавычках (
'--accent'), иначе Vue предупредит о некорректном имени. - Специфичность inline-стиля максимальна, поэтому переопределять его можно только другим inline-стилем или
!important.
Практический ориентир: классы — для состояния и вариантов, inline-стили — только для истинно динамических значений (позиция тултипа, ширина прогресс-бара, значение из пользовательского ввода), которые нельзя выразить заранее известным классом. Псевдоклассы, медиа-запросы и анимации inline-стилями не выразить.
Темизация через CSS custom properties
CSS custom properties (CSS-переменные) — главный мост между JS и CSS во Vue: они наследуются, работают в рантайме и проходят сквозь границы компонентов. Это делает их идеальным механизмом темизации.
Токены объявляют глобально, а компоненты ссылаются на них из scoped-стилей:
/* global.css, подключается один раз в точке входа */
:root {
--color-surface: #ffffff;
--color-text: #111827;
--color-accent: #3b82f6;
}
:root[data-theme='dark'] {
--color-surface: #111827;
--color-text: #f9fafb;
--color-accent: #60a5fa;
}
<!-- Card.vue -->
<template>
<div class="card">…</div>
</template>
<style scoped>
.card {
background: var(--color-surface);
color: var(--color-text);
border-left: 3px solid var(--color-accent);
}
</style>
Почему это работает: scoped-компилятор добавляет атрибуты к селекторам, но не трогает каскад и наследование. Значения --color-*, заданные на :root, наследуются вниз по DOM и оказываются доступны внутри любого компонента — даже за его скоуп-границей.
Переключение темы сводится к смене атрибута на корне документа:
// composables/useTheme.ts
import { ref, watchEffect } from 'vue';
export function useTheme() {
const theme = ref<'light' | 'dark'>('light');
watchEffect(() => {
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = theme.value;
}
});
return { theme };
}
Важные нюансы:
- Не объявляйте
:rootвнутри<style scoped>. Компилятор перепишет его в:root[data-v-...], а у<html>такого атрибута нет — правило не сработает. Токены темы держите в глобальном (unscoped) стиле. - Локальное переопределение делается селектором:
.card { --color-accent: green; }изменит значение только внутри.cardи его потомков. - Fallback полезен, но срабатывает только если переменная не задана вовсе:
var(--color-surface, #fff). v-bind()— динамическая надстройка над тем же механизмом: он выставляет переменную на элементе, а CSS её читает. Для статичной темы достаточно обычных custom properties.- Custom properties строковые: для вычислений нужен
calc(var(--base) * var(--scale)), а безразмерные значения умножают на единицу —calc(var(--value) * 1px).
Паттерн «токены на :root + var() в scoped-стилях + смена атрибута/класса для темы» — стандарт для дизайн-систем и UI-китов. Подробнее о токенах и их уровнях (primitive, semantic, component) — в статье про архитектуру стилей.
Интеграция с Tailwind и UI-китами
Vue хорошо дружит с утилитарными и компонентными подходами к стилям — но у каждого есть своя Vue-специфика.
Tailwind CSS
Tailwind подключается как плагин сборщика (@tailwindcss/vite для v4 или через PostCSS). Утилитарные классы пишут прямо в шаблоне, а условные варианты — через :class:
<template>
<button
class="rounded-md px-4 py-2 font-medium transition-colors"
:class="variant === 'primary'
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'border border-blue-500 text-blue-500 hover:bg-blue-50'"
>
<slot />
</button>
</template>
Практические моменты:
- Объединение классов. Условная логика в строке быстро становится нечитаемой. Используйте
clsxвместе сtailwind-merge(twMerge(clsx(...))), чтобы корректно разрешать конфликты классов — прямого аналогаcnв Vue нет. @applyв SFC. В<style>можно применять утилиты:@apply px-4 py-2;. В Tailwind v4 для доступа к токенам внутри блока стилей требуется@referenceна основной CSS.- Тёмная тема. Со стратегией
darkMode: 'class'переключение темы — это класс или атрибут на<html>, как и в подходе с custom properties.
UI-киты
Библиотеки (Vuetify, PrimeVue, Naive UI, Element Plus) обычно предоставляют свою систему темизации:
- Токены темы задаются через API библиотеки или её CSS-переменные; переопределяйте их на уровне приложения, а не в каждом компоненте.
- Стилизация внутренностей компонента кита из вашего компонента требует
:deep()— внутренняя разметка принадлежит чужому scope. - Не воюйте с библиотекой через
!important. Если переопределение не срабатывает, дело в специфичности или порядке: помогут@layerили более точный селектор.
Общий принцип для дизайн-системы: глобальные токены один раз, компонентные стили — scoped, интеграция с китом — через его тему, а не через грубые переопределения.
Порядок стилей и каскад
Когда в приложении десятки компонентов, важно понимать, в каком порядке применяются их стили.
- Внутри одного SFC блоки
<style>применяются в порядке объявления. Если два правила равной специфичности, победит объявленное ниже. - Между компонентами порядок задаёт граф импортов бандлера. Он предсказуем в пределах сборки, но не является стабильным контрактом: изменение импортов, ленивая загрузка или разделение чанков могут переставить стили. Не полагайтесь на «мой компонент перекроет предыдущий» по порядку подключения.
- Scoped-стили повышают специфичность. Селектору добавляется
[data-v-...], поэтомуpконкурирует уже какp[data-v]. Между собой scoped-селекторы равны по специфичности, и решает порядок. - Глобальные стили подключайте один раз — в точке входа (
main.ts): reset, токены, типографика должны быть в документе до компонентных стилей. @layer— надёжный инструмент порядка. Объявленные слои (@layer reset, base, components, utilities) задают приоритет независимо от специфичности и порядка импортов. Это особенно полезно при интеграции с UI-китами, где важно, чтобы ваши утилиты гарантированно перекрывали базовые стили.
Пример:
@layer reset, base, components, utilities;
@layer components {
.card { background: var(--color-surface); }
}
@layer utilities {
.bg-red { background: red; }
}
Утилиты всегда выиграют у компонентов, даже если объявлены раньше и имеют меньшую специфичность.
Лучшие практики и антипаттерны
Делайте
- По умолчанию используйте
scoped. Глобальные стили — только для reset, токенов и утилит. - Держите токены темы в одном глобальном стиле и читайте их через
var()в scoped-блоках. - Пробрасывайте реактивные значения через
v-bind()или custom properties, а не через генерацию новых классов в рантайме. - Предпочитайте классы inline-стилям. Inline — для значений, которые нельзя выразить заранее известным классом.
- Применяйте
:deep()точечно и только к компонентам под вашим контролем. - Используйте именованные CSS Modules (
<style module="classes">), когда модулей в файле несколько, — так код читается лучше, чем безликий$style. - Управляйте каскадом через
@layer, а не через!importantи порядок импортов.
Не делайте
- Не переопределяйте стили библиотек
!important. Это почти всегда признак неверной специфичности или порядка — лечится@layerили:deep().
<style scoped>
/* ❌ Война специфичностей */
:deep(.btn) { color: red !important; }
</style>
- Не объявляйте
:rootв scoped-стилях. Правило перепишется под недостижимый атрибут и молча не сработает. - Не строите глубокие цепочки
:deep()..a :deep(.b) :deep(.c)хрупко ломается при любом рефакторинге чужой разметки. - Не передавайте объекты в
v-bind()или:styleбез сериализации. Объект превратится в[object Object]. - Не используйте inline-стили для псевдоклассов, медиа-запросов и анимаций — это невозможно; нужен класс.
- Не полагайтесь на порядок стилей между компонентами. Явно задавайте приоритет через слои.
Ключевые тезисы для интервью
<style scoped>изолирует стили через data-атрибуты (data-v-<hash>), но это не полноценная инкапсуляция: правила остаются в документе, а корневой элемент дочернего компонента получает атрибут родителя.:deep()пробивает границу вниз в дочерний компонент,:slotted()стилизует контент слотов, а:global()выводит правило из-под scoped-атрибута.v-bind()в CSS компилируется вvar(--hash-name)и обновляется реактивно, аuseCssVars— его рантайм-эквивалент; значение приводится к строке или числу.<style module>даёт хэшированные классы: в шаблоне —$style, в<script setup>—useCssModule()(с именем модуля при<style module="classes">).- Темизация строится на CSS custom properties: токены объявляют на
:root, и они наследуются сквозь компоненты, а:rootвнутри scoped-блока не сработает. :classи:styleпринимают строку, объект или массив и проваливаются на корень компонента; порядок стилей между SFC задаётся графом импортов, и им управляют через@layer, а не порядком импортов.
Заключение
Стилизация во Vue строится на нескольких механизмах, у каждого из которых своя роль. scoped изолирует стили компонента через data-атрибуты, :deep()/:slotted()/:global() точечно снимают эти границы, v-bind() и CSS custom properties связывают CSS с реактивным состоянием, а CSS Modules дают настоящую локальность классов.
Ключевые практические правила: держите scoped по умолчанию, а глобальные стили — только для токенов и reset; темизацию стройте на CSS custom properties с объявлением на :root; реактивные значения пробрасывайте через v-bind() или переменные, а не через генерацию классов; порядок и специфичность регулируйте @layer, а не !important. И помните, что стили из разных SFC применяются в порядке, на который не стоит закладываться.
Попробуйте собрать маленький компонент с scoped, темой на custom properties и одним v-bind()-значением, а затем продублируйте его же на CSS Modules — так вы на практике сравните оба подхода. Дальше в разделе мы разберём анимации и переходы, которые тесно связаны со стилями, и доступные компоненты.