Навигация

Стилизация во 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-киты и управлять порядком применения стилей.

Содержание

  1. Стили в SFC
  2. scoped-стили и data-атрибуты
  3. Работа с дочерними компонентами: :deep, :slotted, :global
  4. v-bind() в CSS и useCssVars
  5. CSS Modules: style module и useCssModule
  6. Динамические классы и стили
  7. Темизация через CSS custom properties
  8. Интеграция с Tailwind и UI-китами
  9. Порядок стилей и каскад
  10. Лучшие практики и антипаттерны
  11. Ключевые тезисы для интервью
  12. Заключение
  13. Полезные ссылки

Стили в 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:

  1. Добавляет каждому элементу шаблона уникальный атрибут вида data-v-7ba5bd90 (хэш на основе расположения компонента).
  2. Переписывает селекторы, дописывая этот атрибут к последней части составного селектора.
<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 — так вы на практике сравните оба подхода. Дальше в разделе мы разберём анимации и переходы, которые тесно связаны со стилями, и доступные компоненты.

Полезные ссылки