Стандарты

Tailwind-only стилизация

Стандарт определяет правила стилизации компонентов с использованием Tailwind CSS.

Стандарт определяет правила стилизации компонентов с использованием Tailwind CSS.

Правило: Только Tailwind

Запрещено: Использование <style> блоков в Vue-компонентах.

Разрешено: Только Tailwind-классы в атрибуте class.

<!-- ✅ Хорошо -->
<template>
  <div class="flex items-center gap-4 p-4 bg-default rounded-lg">
    <span class="text-lg font-medium text-default">Заголовок</span>
  </div>
</template>

<script setup lang="ts">
// логика
</script>
<!-- ❌ Плохо -->
<template>
  <div class="container">
    <span class="title">Заголовок</span>
  </div>
</template>

<script setup lang="ts">
// логика
</script>

<style scoped>
.container {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 1rem;
  background: var(--color-bg);
  border-radius: 0.5rem;
}

.title {
  font-size: 1.125rem;
  font-weight: 500;
}
</style>

Обоснование

Проблема со <style>Решение с Tailwind
Разрозненные стилиЕдиный дизайн-токены
Конфликты имён классовАтомарные классы
Сложность рефакторингаИзменения видны сразу
Дублирование CSSПереиспользование классов
Увеличение бандлаМинимальный CSS

Исключения

Глобальные стили

Глобальные стили допустимы в app/assets/css/:

app/assets/css/
├── main.css          # точка входа
├── base.css          # базовые reset-стили
└── fonts.css         # @font-face

Сторонние библиотеки

Переопределение стилей сторонних библиотек (редактор, календарь и т.д.):

/* app/assets/css/vendor/quill.css */
.ql-editor {
  min-height: 200px;
}

CSS-переменные темы

CSS-переменные определяются в UI layer, не в компонентах.

Длинные списки классов

При большом количестве классов используй перенос и группировку:

<template>
  <!-- По категориям: layout → spacing → sizing → typography → colors → effects -->
  <div
    class="
      flex items-center justify-between
      p-4 gap-4
      w-full max-w-md
      text-sm font-medium
      bg-default text-default border-default
      rounded-lg shadow-sm
      transition-colors hover:bg-muted
    "
  >
    Контент
  </div>
</template>

Порядок классов

  1. Layout: flex, grid, block, items-*, justify-*
  2. Spacing: p-*, m-*, gap-*
  3. Sizing: w-*, h-*, max-*, min-*
  4. Typography: text-*, font-*, leading-*
  5. Colors: bg-*, text-*, border-*
  6. Effects: rounded-*, shadow-*, opacity-*
  7. States: hover:*, focus:*, active:*
  8. Responsive: sm:*, md:*, lg:*

Динамические классы

Запрещено: Интерполяция в именах классов

Tailwind анализирует код статически при сборке. Динамически собранные имена классов не будут включены в CSS.

<!-- ❌ ЗАПРЕЩЕНО: класс не попадёт в бандл -->
<template>
  <div :class="`bg-${color}-500`">
  <div :class="`text-${size}`">
  <div :class="`p-${spacing}`">
</template>

<script setup>
const color = ref('red')    // bg-red-500 не будет в CSS!
const size = ref('lg')      // text-lg не будет в CSS!
const spacing = ref('4')    // p-4 не будет в CSS!
</script>
<!-- ✅ ПРАВИЛЬНО: полные имена классов -->
<template>
  <div :class="colorClass">
  <div :class="sizeClass">
</template>

<script setup>
const colorClass = computed(() => {
  const colors = {
    red: 'bg-red-500',
    blue: 'bg-blue-500',
    green: 'bg-green-500',
  }
  return colors[color.value]
})

const sizeClass = computed(() => {
  const sizes = {
    sm: 'text-sm p-2',
    md: 'text-base p-4',
    lg: 'text-lg p-6',
  }
  return sizes[size.value]
})
</script>

Условные классы

Для условных классов используй объект или массив:

<template>
  <!-- Простое условие -->
  <div :class="{ 'opacity-50': isDisabled }">

  <!-- Несколько условий -->
  <div
    :class="[
      'base-class',
      isActive && 'bg-primary text-white',
      isDisabled && 'opacity-50 pointer-events-none',
    ]"
  >

  <!-- Computed для сложной логики -->
  <div :class="statusClasses">
</template>

<script setup lang="ts">
const statusClasses = computed(() => {
  const base = 'px-2 py-1 rounded text-sm font-medium'

  switch (status.value) {
    case 'success':
      return `${base} bg-success/10 text-success`
    case 'error':
      return `${base} bg-error/10 text-error`
    default:
      return `${base} bg-muted text-muted`
  }
})
</script>

Tailwind-паттерны: group, peer, has

Tailwind позволяет стилизовать элементы на основе состояния родителя или соседа — то, что невозможно в обычном CSS без JavaScript.

group — стили на основе родителя

Стилизация дочерних элементов при hover/focus родителя:

<template>
  <!-- Родитель с class="group" -->
  <a href="/order/123" class="group block p-4 rounded-lg hover:bg-muted">
    <h3 class="font-medium group-hover:text-primary">Заказ #123</h3>
    <p class="text-muted group-hover:text-default">Подробнее →</p>
    <UIcon
      name="i-lucide-arrow-right"
      class="opacity-0 group-hover:opacity-100 transition-opacity"
    />
  </a>
</template>

Именованные группы

Для вложенных групп используй именование:

<template>
  <div class="group/card p-4 hover:bg-muted">
    <div class="group/header flex items-center">
      <h3 class="group-hover/header:underline">Заголовок</h3>
    </div>
    <p class="group-hover/card:text-primary">Контент карточки</p>
  </div>
</template>

peer — стили на основе соседа

Стилизация элемента на основе состояния предшествующего соседа:

<template>
  <div>
    <!-- Input с class="peer" -->
    <input type="email" class="peer" placeholder="Email" />

    <!-- Сообщение появляется при невалидном input -->
    <p class="invisible peer-invalid:visible text-error text-sm">
      Введите корректный email
    </p>

    <!-- Label поднимается при focus -->
    <label class="peer-focus:-translate-y-6 peer-focus:text-primary transition-all">
      Email
    </label>
  </div>
</template>

has — стили на основе потомка

Стилизация родителя на основе состояния дочернего элемента:

<template>
  <!-- Карточка подсвечивается если внутри checked checkbox -->
  <label class="block p-4 border rounded-lg has-[:checked]:border-primary has-[:checked]:bg-primary/5">
    <input type="checkbox" class="mr-2" />
    Выбрать этот вариант
  </label>

  <!-- Форма показывает рамку если внутри есть focus -->
  <form class="p-4 rounded-lg has-[:focus]:ring-2 has-[:focus]:ring-primary">
    <input type="text" placeholder="Имя" />
    <input type="email" placeholder="Email" />
  </form>
</template>

Таблица состояний

МодификаторТриггерПример
group-hover:Hover на родителе с groupgroup-hover:text-primary
group-focus:Focus на родителеgroup-focus:ring-2
peer-hover:Hover на предыдущем элементе с peerpeer-hover:visible
peer-invalid:Невалидный input-соседpeer-invalid:text-error
peer-checked:Checked checkbox-соседpeer-checked:bg-primary
has-[:checked]:Содержит checked элементhas-[:checked]:border-primary
has-[:focus]:Содержит focused элементhas-[:focus]:ring-2

Переиспользование стилей

Вариант 1: Компонент

Для переиспользуемых элементов создавай компонент:

<!-- components/StatusBadge.vue -->
<template>
  <span :class="classes">
    <slot />
  </span>
</template>

<script setup lang="ts">
const props = defineProps<{
  variant: 'success' | 'error' | 'warning' | 'info'
}>()

const classes = computed(() => {
  const base = 'px-2 py-1 rounded text-xs font-medium'
  const variants = {
    success: 'bg-success/10 text-success',
    error: 'bg-error/10 text-error',
    warning: 'bg-warning/10 text-warning',
    info: 'bg-info/10 text-info',
  }
  return `${base} ${variants[props.variant]}`
})
</script>

Вариант 2: Tailwind @apply (крайне редко)

Только для базовых утилит в глобальных стилях:

/* app/assets/css/base.css */
@layer components {
  .btn-base {
    @apply px-4 py-2 rounded font-medium transition-colors;
  }
}

Не рекомендуется: @apply теряет преимущества atomic CSS.

Чек-лист

  • Компонент не содержит <style> блок
  • Все стили через Tailwind-классы
  • Нет интерполяции в именах классов (bg-${color})
  • Длинные списки классов отформатированы
  • Динамические классы через :class binding с полными именами
  • Используются group/peer/has вместо JS для связанных состояний
  • Переиспользуемые элементы вынесены в компоненты

Связанные документы