Обновлённая версия. С момента первой публикации вышел v1.0.0: добавлены viewport-переключатель, цветовой пикер, поиск элементов, клавиатурный шорткат и WS-баннер статуса соединения.

Каждый раз, когда я хотел поправить отступ или цвет в процессе разработки, я делал одно и то же:

открыл DevTools → нашёл элемент → поменял значение → понравилось → скопировал → переключился в редактор → нашёл файл → вставил.

Это семь шагов ради однострочного изменения. Я сделал LiveStyleSync, чтобы это был один шаг.

Если вы не работаете с Vite — коротко: это популярный инструмент для запуска фронтенд-проектов во время разработки. Большинство современных React, Vue, Svelte и Astro проектов используют именно его.


Что это такое

LiveStyleSync добавляет небольшую панель поверх вашего Vite-приложения в режиме разработки. Вы кликаете на любой элемент, редактируете CSS-свойства прямо в панели, и изменение записывается в исходный файл. Vite подхватывает изменение через HMR (hot module replacement — это когда браузер обновляет только изменившийся стиль, не перезагружая всю страницу) мгновенно.

Клик на элемент → редактируем значение → Vite HMR обновляет браузер → исходник обновлён

Никакого копи-паста. Никакого переключения вкладок.

Важное уточнение, которое появилось в комментариях: это не замена DevTools. DevTools удобнее для отладки, инспектирования и экспериментов. LSS решает другую задачу — сохранять изменение прямо в файл, минуя ручное копирование. Если вы часто ловите себя на том, что выставляете значение в DevTools, а потом идёте вставлять его в редактор — для этого и сделан проект.


Быстрый старт

npm install livestylesync-overlay livestylesync-vite-plugin
// vite.config.ts
import { defineConfig } from "vite";
import { liveStyleSync } from "livestylesync-vite-plugin";

export default defineConfig({
  plugins: [liveStyleSync()],
});
// main.ts
import { mount } from "livestylesync-overlay";

if (import.meta.env.DEV) {
  mount();
}

Всё. Панель появится в углу приложения.

Работает с любым фреймворком на Vite: React, Vue, Nuxt, SvelteKit, Astro, Solid. Оверлей не имеет peer-зависимости от React — Preact собирается внутрь бандла и изолирован от приложения.


Как это работает изнутри

Мост между CSSOM и исходным файлом

Когда браузер загружает страницу, он читает все CSS-файлы и строит из них внутреннюю структуру в памяти — CSSOM (CSS Object Model). Это просто объект, через который JavaScript может читать и менять стили. Проблема в том, что браузер знает правило .card { background: #fff }, но не знает, в каком файле и в какой строке оно объявлено.

Это главная техническая задача. Нужно связать document.styleSheets (CSSOM браузера) с конкретным .css или .scss файлом на диске.

У объекта CSSStyleSheet есть два пути получить источник:

Случай 1 — внешний файл. Если CSS подключён через <link>, у листа есть sheet.href вида http://localhost:5173/src/styles.css. Из этого URL можно вытащить путь.

Случай 2 — <style> тег. SCSS, CSS Modules, Vue scoped — Vite компилирует их и вставляет как <style> теги прямо в <head>. У таких тегов href равен null. Но Vite добавляет атрибут data-vite-dev-id с абсолютным путём к исходнику:

<style type="text/css" data-vite-dev-id="/home/user/project/src/styles.scss"> 
  .card { background: #1a1a2e; }
</style>

Код в оверлее читает этот атрибут:

for (const sheet of Array.from(document.styleSheets)) {
  let fileUrl = sheet.href; // для <link> тегов

  if (!fileUrl && sheet.ownerNode instanceof HTMLElement) {
    fileUrl = sheet.ownerNode.getAttribute("data-vite-dev-id"); // для <style> тегов
  }

  // cross-origin или без источника — пропускаем
  if (!fileUrl) {
    continue;
  }
}

Получив fileUrl, мы знаем два из трёх нужных координат: файл и CSS-правило (из CSSOM). Третья координата — конкретная строка в файле — это уже задача PostCSS на сервере.


Почему PostCSS, а не regex

Итак, мы знаем файл и знаем правило которое нужно изменить. Остаётся найти нужную строку в файле и заменить значение. Первое, о чём думаешь — регулярное выражение или string.replace. Это не работает по нескольким причинам.

Проблема 1: двоеточие в разных контекстах.

CSS использует : в трёх несвязанных местах: селекторах (.foo:hover), значениях (content: "a: b"), самих декларациях (color: red). Regex по color: попадёт не туда.

Проблема 2: форматирование.

/* вариант 1 */
.card {
  background:#fff;
}

/* вариант 2 */
.card {
  background: #fff;
}

/* вариант 3 — с комментарием */
.card {
  background: #fff; /* default */
}

Regex нужно поддерживать все варианты или нормализовывать файл — что разрушает форматирование.

Проблема 3: SCSS-нестинг и @media внутри правила.

.card {
  background: #fff;

  @media (max-width: 768px) {
    background: #000;
  }

  &:hover {
    background: #eee;
  }
}

Найти нужное объявление в этой структуре регуляркой — нетривиально.

PostCSS — инструмент для трансформации CSS через JavaScript. Он разбирает файл в AST (abstract syntax tree, дерево узлов). Можно представить это как разобранное предложение: не просто строка текста, а структура — где подлежащее, где сказуемое. В случае CSS: каждый узел имеет тип — Rule (селектор), Declaration (свойство: значение), AtRule (@media, @container). Нужно найти Rule с нужным selector, в нём найти Declaration с нужным prop — и заменить value. Всё остальное (отступы, комментарии, переносы строк) PostCSS хранит в raws и воспроизводит при toString() — форматирование не ломается.

root.walkRules((rule) => {
  if (rule.selector !== targetSelector) return;

  rule.walkDecls(prop, (decl) => {
    // меняем только значение, всё остальное не трогаем
    decl.value = newValue;
  });
});

writeFileSync(filePath, root.toString()); // форматирование сохранено

Для SCSS используется postcss-scss — он понимает $variables, нестинг и миксины, которые стандартный PostCSS не парсит.


HMR: от setTimeout к подтверждению

Когда файл на диске изменяется, Vite замечает это через файловый watcher, перекомпилирует модуль и отправляет клиенту сигнал по WebSocket (постоянное соединение между браузером и dev-сервером). Браузер применяет обновлённый CSS без перезагрузки — это и есть HMR.

Первая версия LSS выглядела так: после отправки патча ждать 400 мс, потом перечитать CSSOM.

send({ fileUrl, selector, prop, value });

setTimeout(() => {
  editor.refresh();
}, 400); // фиксированное ожидание

Проблема: после записи файла Vite проходит несколько шагов — файловый watcher обнаруживает изменение, Vite перекомпилирует модуль, отправляет HMR-обновление клиенту, браузер применяет новый CSS. На медленных машинах 400 мс не хватало. На быстрых — зря ждал.

Решение: сервер отправляет подтверждение только после записи файла.

// сервер — после writeFileSync:
socket.send(
  JSON.stringify({
    type: "patched",
  })
);

// клиент:
if (msg.type === "patched") {
  setTimeout(() => {
    editor.refresh();
  }, 300); // небольшой буфер для HMR
}

300 мс теперь отсчитываются от момента, когда файл уже записан.


Псевдо-состояния: как редактировать :hover который не активен

CSS-псевдо-классы вроде :hover, :focus, :active — это условные стили, которые браузер применяет только в определённый момент. Проблема: когда вы кликаете на элемент чтобы его выбрать, :hover с него пропадает. А значит el.matches(".button:hover") возвращает false, и правило вообще не попадает в панель.

Решение — двухшаговый матчинг. Сначала пробуем проверить как есть. Если не прошло и в селекторе есть псевдо-класс — убираем его и проверяем снова:

const INTERACTIVE_PSEUDOS = [
  ":hover",
  ":focus",
  ":active",
  ":checked",
];

function stripInteractivePseudos(selector: string): string {
  let s = selector;

  for (const p of INTERACTIVE_PSEUDOS) {
    s = s.split(p).join("");
  }

  return s.trim();
}

let matches = el.matches(effectiveSelector);

if (!matches && isPseudoRule) {
  matches = el.matches(
    stripInteractivePseudos(effectiveSelector)
  );
}

.button:hover → strip → .button → матч прошёл. Правило попадает в панель.


Vue scoped: хэши в селекторах

В Vue есть механизм <style scoped> — стили применяются только к текущему компоненту и не утекают глобально. Работает это так: Vue автоматически добавляет уникальный атрибут к каждому HTML-элементу компонента (data-v-3f7bd2) и дописывает его ко всем CSS-селекторам:

/* исходник */.card { background: #fff; }/* в браузере */.card[data-v-3f7bd2] { background: #fff; }

Два несовпадения решаются нормализацией перед отправкой:

const selector = effectiveSelector
  .replace(/\[data-v-[a-f0-9]+\]/g, "")
  .trim();

const cleanUrl = fileUrl.includes("?vue&type=style")
  ? fileUrl.split("?")[0]
  : fileUrl;

На сервере patchVue парсит .vue файл, вычленяет <style> блок, прогоняет через PostCSS и записывает обратно только этот блок, не трогая <template> и <script>.


Viewport-переключатель

Частая задача при разработке — проверить адаптивную вёрстку на разных экранах без DevTools. В v1.0.0 появились кнопки 375, 768, 1024 прямо в панели.

Нажатие создаёт <style id="lss-viewport"> в <head>:

// убираем хэш из селектора
const selector = effectiveSelector
  .replace(/\[data-v-[a-f0-9]+\]/g, "")
  .trim();

// ".card[data-v-3f7bd2]" → ".card"


// убираем query-параметры из URL
const isVue = fileUrl.includes("?vue&type=style");
const cleanUrl = isVue
  ? fileUrl.split("?")[0]
  : fileUrl;

// → "main.vue"

Повторное нажатие — сброс. Реализация намеренно простая: viewport браузера остаётся прежним, контент просто зажат по ширине. Для проверки большинства брейкпоинтов при разработке этого достаточно.


Цветовой пикер

Для CSS-свойств, значение которых является цветом (color, background, border-color и т.д.), рядом со значением появляется цветной квадратик. Клик открывает нативный color picker браузера.

Неактивное состояние
Неактивное состояние
Активное состояние
Активное состояние

Под квадратиком — прозрачный <input type="color"> с нулевым opacity. Квадратик отображает текущий цвет через background: value. При изменении — значение сразу применяется и отправляется патч.

{isColorProp(prop) && (
  <div
    style={{
      width: 16,
      height: 16,
      background: value,
      borderRadius: 2,
    }}
  >
    <input
      type="color"
      value={toHex(value)}
      onChange={(e) => onChange(prop, e.target.value)}
      style={{
        position: "absolute",
        inset: 0,
        opacity: 0,
        cursor: "pointer",
      }}
    />
  </div>
)}

Поиск элементов

Кликать на каждый элемент работает хорошо для видимых блоков. Но что если элемент маленький, перекрыт другим, или его нужно найти по классу?

В панели есть строка поиска. Вводите .class-name, #id или любой CSS-селектор — оверлей подсвечивает все совпадения на странице и позволяет выбрать нужный.


WS-баннер: видно когда сервер упал

Раньше, если Vite dev-сервер падал, оверлей молча висел — было непонятно, дойдёт патч или нет. Теперь панель показывает статус соединения.

Три состояния:

  • connected — зелёная точка в шапке

  • reconnecting — жёлтый баннер ⟳ Reconnecting to dev server...

  • disconnected — серый баннер ✗ Dev server not running

Под капотом — exponential backoff: [1s, 2s, 4s, 8s, 16s, 30s]. При восстановлении соединения автоматически дренируется очередь — если успели отправить патч пока сервер был недоступен, он не потеряется.

const selParts = selector
  .split(",")
  .map((s) => s.trim());

if (selParts.some((p) => p === "*" || p.startsWith("*:"))) {
  continue;
}

Клавиатурный шорткат

Alt+S — показать/скрыть панель. Работает глобально, не зависит от фокуса. Удобно когда панель перекрывает элемент, который нужно рассмотреть.


Технические грабли, на которые я наступил

Универсальный селектор *

В document.styleSheets есть правила вроде *, ::before, ::after { box-sizing: border-box }. Без фильтрации эти правила появлялись в панели для каждого элемента. Фикс:

if (
  !(rule instanceof CSSMediaRule) &&
  !(rule instanceof CSSSupportsRule) &&
  (rule as any).conditionText !== undefined
) {
  // это @container
}

CSSContainerRule нет в TypeScript lib

TypeScript поставляется с описаниями встроенных браузерных API — типами для document, window, CSS-объектов и т.д. Но @container (относительно новая CSS-функция для адаптивных компонентов) там не описан. instanceof CSSContainerRule — не компилируется, TypeScript говорит "такого типа нет".

Пришлось определять через duck-typing — то есть проверять не "это тот тип?", а "у этого объекта есть нужные свойства?". У @container есть conditionText, но нет instanceof CSSMediaRule:

(selected as HTMLElement).style.setProperty(prop, oldValue);

// или если oldValue пустой:
(selected as HTMLElement).style.removeProperty(prop);

Inline-стили перекрывают откат

При откате изменений через историю стили возвращались в файл, но element.style оставался с перезаписанными inline-значениями, которые перекрывали восстановленные стили. CSS-специфичность: inline всегда побеждает.

Фикс — явно очищать inline-свойство при откате:

if (oldValue) {
  (selected as HTMLElement).style.setProperty(prop, oldValue);
} else {
  (selected as HTMLElement).style.removeProperty(prop);
}

Два механизма undo разъехались

В какой-то момент оказалось два независимых стека отмены (undo). Представьте: нажимаете Ctrl+Z, и одна часть приложения откатывается, а другая — нет, потому что у каждой своя "история". Один стек был внутри useStyleEditor (только CSS), второй в общей истории сессии (CSS + SCSS переменные + CSS custom properties).

При смешанной сессии они показывали разные состояния. Это открытый баг — рефакторю под единый стек в issue #62.


Что умеет

Фича

Описание

Element picker

Клик на любой элемент

Поиск элементов

По .class, #id, CSS-селектору с подсветкой

DOM breadcrumbs

Навигация по предкам элемента

@media и @container

Отдельные вкладки для каждого брейкпоинта/контейнера

Псевдо-состояния

Редактирование :hover, :focus, :active

Цветовой пикер

Нативный пикер для цветовых свойств

CSS custom properties

Браузер и редактор :root переменных

SCSS $переменные

Серверный скан всех .scss файлов, редактирование $var

Viewport-переключатель

Кнопки 375/768/1024 без DevTools

Создание правил

Добавить CSS к элементу, у которого нет исходника

История сессии

Git-style диффы всех изменений, откат батчами

Tailwind detection

Предупреждение вместо попытки патчить утилиты

Alt+S

Показать/скрыть панель

Поддержка форматов CSS

Формат

Чтение

Патч

Обычный .css

.scss

CSS Modules .module.css

Vue <style scoped>

Tailwind-утилиты

⚠️ определяет, предупреждает

Inline styles


Ограничения

Vite-only. LSS — Vite-плагин. Vite — это dev-сервер и сборщик, который используют большинство современных фронтенд-фреймворков: React (через Vite), Vue, Nuxt, SvelteKit, Astro, Solid, Qwik.

Если ваш проект использует другой инструмент — webpack, Turbopack (это то, что стоит за Next.js по умолчанию), Parcel, esbuild — LSS не подключится, потому что заточен под Vite API.

Если вы пишете бэкенд на Django/Rails/Laravel, но фронт собирается через Vite отдельно — всё работает, LSS подключается к фронтовой части.


Попробовать

GitHub: https://github.com/Artyx71/livestylesync

StackBlitz (без установки): https://stackblitz.com/github/Artyx71/livestylesync/tree/main/apps/demo

npm install livestylesync-overlay livestylesync-vite-plugin

Буду рад фидбэку — особенно если попробуете на проекте отличном от React, или наткнётесь на кейс с нестандартной структурой CSS. Открывайте issue или пишите в комментарии.

Комментарии (10)


  1. viiprogrammer
    30.05.2026 09:32

    Сейчас везде мода пушить node_modules в репозиторий? (причем в 3 или более экземплярах) Выглядит как ошибка новичка.

    А так в целом интересно, но все же devtools кажется более надежным и юзабельным вариантом, как бы лень не было нажать кнопку

    В публикации весь код в одну строку, стоило бы поправить


    1. Artyx71 Автор
      30.05.2026 09:32

      Спасибо за фидбек, всё по делу.

      По node_modules — да, это мой недосмотр. Уже исправил.

      По DevTools согласен, для просмотра и отладки они удобнее. LiveStyleSync просто решает другую задачу: позволяет сразу сохранять изменения из браузера в файл, без ручного копирования.

      В публикации код был отформатирован


  1. cmyser
    30.05.2026 09:32

    Картинок не хватает, я примерно представляю конечно как это работает, но скорее всего неправильно


    1. Artyx71 Автор
      30.05.2026 09:32

      Согласен, это главный недостаток. Скоро обновлю — добавлю гифки с демонстрацией работы и скриншоты панели. Заодно статья уже устарела: с момента публикации появились поддержка Next.js, элементный поиск, цветовой пикер, клавиатурные шорткаты и ещё несколько фич. Так что будет и обновлённый функционал и визуал.


      1. cmyser
        30.05.2026 09:32

        Лучше тогда вторую статью)