Обновлённая версия. С момента первой публикации вышел 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 |
Клик на любой элемент |
Поиск элементов |
По |
DOM breadcrumbs |
Навигация по предкам элемента |
|
Отдельные вкладки для каждого брейкпоинта/контейнера |
Псевдо-состояния |
Редактирование |
Цветовой пикер |
Нативный пикер для цветовых свойств |
CSS custom properties |
Браузер и редактор |
SCSS |
Серверный скан всех |
Viewport-переключатель |
Кнопки |
Создание правил |
Добавить CSS к элементу, у которого нет исходника |
История сессии |
Git-style диффы всех изменений, откат батчами |
Tailwind detection |
Предупреждение вместо попытки патчить утилиты |
|
Показать/скрыть панель |
Поддержка форматов CSS
Формат |
Чтение |
Патч |
|---|---|---|
Обычный |
✅ |
✅ |
|
✅ |
✅ |
CSS Modules |
✅ |
✅ |
Vue |
✅ |
✅ |
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)

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

Artyx71 Автор
30.05.2026 09:32Согласен, это главный недостаток. Скоро обновлю — добавлю гифки с демонстрацией работы и скриншоты панели. Заодно статья уже устарела: с момента публикации появились поддержка Next.js, элементный поиск, цветовой пикер, клавиатурные шорткаты и ещё несколько фич. Так что будет и обновлённый функционал и визуал.
viiprogrammer
Сейчас везде мода пушить node_modules в репозиторий? (причем в 3 или более экземплярах) Выглядит как ошибка новичка.
А так в целом интересно, но все же devtools кажется более надежным и юзабельным вариантом, как бы лень не было нажать кнопку
В публикации весь код в одну строку, стоило бы поправить
Artyx71 Автор
Спасибо за фидбек, всё по делу.
По node_modules — да, это мой недосмотр. Уже исправил.
По DevTools согласен, для просмотра и отладки они удобнее. LiveStyleSync просто решает другую задачу: позволяет сразу сохранять изменения из браузера в файл, без ручного копирования.
В публикации код был отформатирован