Как я несколько лет пытался сделать состояния в CSS предсказуемыми
Случалось ли вам менять местами два CSS-правила — и случайно ломать компонент, хотя его логика вроде бы не изменилась?
.btn:hover {
background: dodgerblue;
}
.btn[disabled] {
background: gray;
}
У обоих селекторов одинаковая специфичность — (0, 2, 0). Если на кнопку одновременно навели курсор и она находится в состоянии disabled, браузер смотрит на порядок правил. Правило с :hover стоит последним — отключённая кнопка становится синей. Последним идёт [disabled] — остаётся серой.
На первый взгляд это мелкий граничный случай. Но за ним скрывается более общая проблема: состояния компонентов в CSS часто работают за счёт пересечения правил.
Пока состояний одно или два, всё выглядит вполне управляемо. Но добавьте :hover, :active, disabled, тёмную тему, брейкпоинты, data-атрибуты, контейнерные запросы и переопределения — и вы уже не просто пишете стили. Вы держите в голове целую систему разрешения состояний.
Я постоянно сталкивался с этим при разработке компонентных систем: кнопок, полей ввода, панелей, выпадающих меню и примитивов дизайн-системы. Написать первую версию обычно было несложно. Гораздо труднее — безопасно расширить её позже, не разбирая заново всю логику приоритетов.
Проблема не ограничивалась редкими ошибками из-за порядка правил. По мере роста требований даже небольшие изменения становилось всё сложнее вносить без риска что-нибудь сломать.
В какой-то момент я перестал спрашивать себя: «Как написать этот селектор?» — и сформулировал вопрос иначе:
Что, если описывать состояния компонента декларативно, а всю логику селекторов, необходимую для однозначного результата, поручить компилятору?
Из этого вопроса со временем вырос Tasty.
Основная идея за минуту
Вместо набора селекторов, которые конкурируют друг с другом через каскад и специфичность, я хотел описывать возможные состояния свойства в виде карты:
import { tasty } from '@tenphi/tasty';
const Button = tasty({
as: 'button',
styles: {
fill: {
'': '#primary',
':hover': '#primary-hover',
':active': '#primary-pressed',
'[disabled]': '#surface',
},
},
});
Если читать эту карту от высшего приоритета к низшему, получится следующее:
- если кнопка отключена, используем
#surface; - иначе, если она нажата, —
#primary-pressed; - иначе, если на неё наведён курсор, —
#primary-hover; - во всех остальных случаях —
#primary.
Самое важное начинается на следующем шаге.
Tasty превращает карту состояний в селекторы, которые не могут пересекаться:
/* [disabled] побеждает без дополнительных условий */
.t0[disabled] {
background: var(--surface-color);
}
/* :active не применяется к отключённой кнопке */
.t0:active:not([disabled]) {
background: var(--primary-pressed-color);
}
/* :hover не применяется при :active или disabled */
.t0:hover:not(:active):not([disabled]) {
background: var(--primary-hover-color);
}
/* базовое состояние исключает все перечисленные выше */
.t0:not(:hover):not(:active):not([disabled]) {
background: var(--primary-color);
}
Теперь специфичности и порядку правил нечего разрешать: в каждый момент подходит только одна ветка.
Главное преимущество становится заметно позже. Изменить или дополнить карту гораздо проще, чем вручную восстанавливать все связи между эквивалентными селекторами.
В этом и заключается основная идея:
Если разработчик задаёт приоритеты явно, сгенерированные селекторы должны выражать их однозначно.
Почему проблема шире, чем кажется на примере кнопки
Отключённая кнопка под курсором — лишь самый наглядный пример. Настоящие сложности начинаются, когда условия пересекаются менее очевидным образом.
Например, тёмная тема может включаться атрибутом корневого элемента, через prefers-color-scheme или обоими способами. Отступы могут меняться внутри узкого контейнера, но только на планшетной ширине. Деструктивный вариант кнопки может иначе вести себя при наведении, но не во время загрузки. Тема родительского элемента может переопределять стили дочернего.
Каждое правило по отдельности легко понять. Сложность — сохранить предсказуемыми отношения между ними. Небольшое изменение способно поменять область пересечения веток. Безобидный рефакторинг может привести к ошибке, зависящей от порядка правил. А расширение существующего компонента — заставить снова разбираться в логике селекторов, которую вы считали давно закрытым вопросом.
Мне нужна была модель, в которой добавление нового состояния не требует каждый раз заново выводить в уме всю матрицу селекторов.
Пример посложнее
Рассмотрим переиспользуемую карточку действия. Она может находиться в основной области страницы или в узком сайдбаре, менять раскладку в зависимости от ширины окна и представлять обычное либо опасное действие:
const ActionCard = tasty({
as: 'button',
styles: {
flow: {
'': 'column',
'@media(w >= 768px)': 'row',
},
fill: {
'': '#surface',
'@root(schema=dark)': '#surface-dark',
'theme=danger': '#danger',
'theme=danger & :hover': '#danger-hover',
},
padding: {
'': '4x',
'@(sidebar, w < 300px)': '2x',
},
},
});
По умолчанию содержимое карточки располагается в колонку, а на широких экранах — в строку. Фон следует цветовой схеме корневого элемента, если только не включена тема danger; для неё отдельно задано состояние при наведении. В узком контейнере сайдбара внутренние отступы уменьшаются. Разработчик описывает все решения рядом с теми свойствами, которых они касаются, а компилятор берёт на себя связи между селекторами и их приоритеты.
У объектного представления есть ещё одно практическое преимущество. Все стили элемента и его составных частей можно хранить в одном объекте. Если объединить его со вторым объектом стилей, расширится всё определение целиком, включая карты состояний. Не придётся возвращаться к переопределениям, разбросанным по разным селекторам.
Расширение превращается в композицию данных: разработчики объединяют определения стилей, а компилятор разрешает итоговые состояния. Для компонентной системы это особенно важно — она почти никогда не остаётся такой, какой была в первой версии.
Почему на это ушло несколько лет
Сама идея довольно проста. Но чтобы превратить её в систему, которой можно доверять в масштабе дизайн-системы, потребовались годы и сотни итераций.
Сгенерировать один хитроумный селектор — не самая сложная задача. Система должна была оставаться цельной, когда одновременно используются:
- псевдоклассы вроде
:hoverи:active; - атрибуты, булевы модификаторы и модификаторы со значениями;
- состояния корневого элемента;
- медиазапросы;
- контейнерные запросы;
- вложенные и составные селекторы;
- объединение объектов стилей элементов и их составных частей;
- типизированный API поверх модели стилизации.
Поддержать каждую возможность по отдельности было недостаточно. Любое нововведение должно было сочетаться со всем, что уже существует. Медиазапросы — работать вместе с модификаторами. Состояние корневого элемента — с псевдоклассами. А расширение объекта стилей — сохранять объявленные приоритеты, а не незаметно менять их порядок.
По мере развития модели мне снова и снова приходилось проверять, выдерживает ли исходная идея новые требования. Иногда выдерживала. Иногда — совершенно нет. Были периоды, когда я ломал DSL, пересматривал внутреннее представление состояний и переписывал значительные части компилятора, чтобы сохранить всё то же обещание предсказуемости.
С технической стороны пришлось заниматься парсингом, нормализацией, генерацией селекторов, кешированием, правилами расширения и производительностью. Но была и концептуальная задача: понять, чем именно должен быть Tasty.
Более удобным форматом CSS-объектов? Генератором атомарного CSS? Языком дизайн-системы? Компилятором стилей компонентов с состояниями? Какое-то время Tasty пытался быть всем сразу. Сложнее всего было провести границы так, чтобы эти роли усиливали друг друга, а не конфликтовали.
Долгое время я не был уверен, что идею вообще получится масштабировать достаточно чисто и что результат оправдает затраченные усилия. В отдельных местах подход заработал довольно быстро. Но сделать его надёжным в рамках целой дизайн-системы оказалось намного труднее.
Tasty с самого начала лежит в основе Cube UI Kit. Сейчас в этой дизайн-системе больше ста компонентов, и на ней работает Cube Cloud — реальный корпоративный продукт. Ранние версии Tasty внутри проекта были, безусловно, экспериментальными. Нагрузка продакшена и обратная связь команды показали, где модель работает хорошо, а где её нужно менять.
Что для меня здесь важнее всего
Непересекающиеся селекторы интересны не потому, что это технически изящный трюк. Они устраняют целый класс неоднозначностей, разбираться с которыми разработчик в принципе не должен.
Когда я стилизую компонент, я хочу описать, как он выглядит в каждом значимом состоянии. Я не хочу вручную кодировать логику браузерного «тай-брейка» всякий раз, когда эти состояния пересекаются.
Именно ради этого создаётся Tasty:
- предсказуемое поведение компонентов;
- меньше случайных регрессий из-за порядка правил;
- простое расширение через композицию объектов стилей;
- модель стилизации, польза которой растёт вместе с дизайн-системой.
Для небольшого лендинга всё это, скорее всего, избыточно. Обычный CSS часто будет правильным выбором.
Но если вы разрабатываете компоненты, которым предстоит пережить годы изменений, рост числа вариантов, расширение тем и работу множества авторов, предсказуемость начинает приносить вполне ощутимую пользу.
В Tasty также есть типизированные API компонентов, составные элементы, интеграции с SSR, извлечение CSS без рантайма, инструменты для редакторов, линтинг, токены, рецепты и многое другое. Всё это важно, но следует из принципа, с которого начался проект:
Состояния компонентов должно быть легко описать и трудно сделать неоднозначными.
Чтобы превратить эту фразу в инструмент, который мне не стыдно выпустить, потребовалось несколько лет.
Если проблема вам знакома
Попробовать Tasty прямо в браузере можно в песочнице, а полное описание языка и возможностей доступно в документации.
Если решите попробовать, мне особенно интересно узнать, что показалось понятным сразу, что — непривычным и где документация пропускает важный шаг в рассуждениях. Такая обратная связь формировала проект с самого начала и продолжает влиять на него сейчас. Лучше всего оставить её в GitHub Issues.
Tasty вырос из проблемы, с которой я сталкивался много лет. Если она знакома и вам, буду рад узнать, помогает ли такой подход.
Ссылки: Документация | Песочница | GitHub | npm | Telegram