Vanilla CSS — единственное, что вам нужно
- пятница, 12 декабря 2025 г. в 00:00:11
Команда JavaScript for Devs подготовила перевод статьи о том, как 37signals создают современные веб-приложения без Tailwind, Sass и сборщиков. Опираясь только на возможности нативного CSS, они строят масштабируемую архитектуру, используют :has(), color-mix(), CSS Layers, container queries и другие возможности, которые многие разработчики ещё даже не пробовали.
В апреле 2024 года Джейсон Зимдарс из 37signals опубликовал пост о современных паттернах CSS в Campfire. Он рассказал, как их команда создаёт сложные веб-приложения, используя только ванильный CSS. Никакого Sass. Никакого PostCSS. Никаких сборщиков.
Этот пост засел у меня в голове. За последние полтора года 37signals выпустили ещё два продукта (Writebook и Fizzy), построенных на той же философии отсутствия сборки. Мне стало интересно, выдержали ли эти паттерны проверку временем. Эволюционировали ли они?
Я открыл исходники Campfire, Writebook и Fizzy и проследил, как менялась их архитектура CSS. То, что начиналось как любопытство, переросло в настоящее удивление. Это не просто последовательные паттерны. Это улучшающиеся паттерны. Каждый новый релиз опирается на предыдущий, постепенно внедряя всё более современные возможности CSS и при этом сохраняя ту же философию «никаких сборщиков».
И это не любительские проекты. Campfire — приложение для обмена сообщениями в реальном времени. Writebook — платформа для публикации текстов. Fizzy — полнофункциональный инструмент для управления проектами с канбан-досками, drag-and-drop и сложным управлением состоянием. Вместе они представляют почти 14 000 строк CSS в 105 файлах.
И ни одна строка не зависит от сборочных инструментов.
Сразу уточню: с Tailwind всё в порядке. Это отличный инструмент, который помогает разработчикам быстрее выпускать продукты. Подход utility-first вполне прагматичен, особенно для команд, которым сложно принимать архитектурные решения в CSS.
Но в какой-то момент utility-first стали воспринимать как единственно возможный ответ. Между тем CSS сильно эволюционировал. Язык, которому раньше нужны были препроцессоры ради переменных и вложенности, теперь имеет:
нативные кастомные свойства (переменные)
нативную вложенность
CSS Layers для управления специфичностью
color-mix() для динамической работы с цветом
clamp(), min(), max() для адаптивных размеров без медиазапросов
37signals посмотрели на эту картину и сделали ставку: возможностей современного CSS достаточно. Сборка не нужна.
После трёх продуктов становится ясно, что ставка себя оправдала.
Откройте любую из этих трёх кодовых баз — и вы увидите одинаковую плоскую структуру:
app/assets/stylesheets/
├── _reset.css
├── base.css
├── colors.css
├── utilities.css
├── buttons.css
├── inputs.css
├── [component].css
└── ...И всё. Никаких поддиректорий. Никаких частичных файлов. Никаких сложных деревьев импортов. Один файл на одну концепцию, с названием, которое прямо описывает, что в нём лежит.
Нулевая конфигурация. Нулевая сборка. Нулевое ожидание.
Я бы очень хотел увидеть что-то подобное в стартовых Rails-приложениях. Простую базовую структуру с _reset.css, base.css, colors.css и utilities.css, уже готовыми к использованию. Думаю, многие разработчики тянутся к Tailwind не потому, что им особенно нравятся utility-классы, а потому что ванильный CSS не предлагает точки старта. Нет корзин. Нет соглашений. Возможно, CSS тоже нужен свой omakase.
В оригинальном посте Джейсон отлично объяснил OKLCH. Это перцептуально равномерное цветовое пространство, которое использ��ется во всех трёх приложениях. Коротко: в отличие от RGB или HSL, параметр lightness в OKLCH действительно соответствует тому, насколько ярким цвет воспринимает человек. Синий с яркостью 50% выглядит столь же светлым, как и жёлтый с яркостью 50%.
Важно то, что этот фундамент остаётся одинаковым во всех трёх приложениях:
:root {
/* Raw LCH values: Lightness, Chroma, Hue */
--lch-blue: 54% 0.15 255;
--lch-red: 51% 0.2 31;
--lch-green: 65% 0.23 142;
/* Semantic colors built on primitives */
--color-link: oklch(var(--lch-blue));
--color-negative: oklch(var(--lch-red));
--color-positive: oklch(var(--lch-green));
}Тёмная тема превращается в простейшую задачу:
@media (prefers-color-scheme: dark) {
:root {
--lch-blue: 72% 0.16 248; /* Lighter, slightly desaturated */
--lch-red: 74% 0.18 29;
--lch-green: 75% 0.20 145;
}
}Любой цвет, который ссылается на эти примитивы, обновляется автоматически. Никакого дублирования. Никакого отдельного файла для тёмной темы. Один медиазапрос — и всё приложение меняет облик.
Fizzy идёт дальше и использует color-mix():
.card {
--card-color: oklch(var(--lch-blue-dark));
/* Derive an entire color palette from one variable */
--card-bg: color-mix(in srgb, var(--card-color) 4%, var(--color-canvas));
--card-text: color-mix(in srgb, var(--card-color) 30%, var(--color-ink));
--card-border: color-mix(in srgb, var(--card-color) 33%, transparent);
}Один базовый цвет — четыре согласованных производных. Измените цвет карточки через JavaScript (element.style.setProperty('--card-color', '...')), и вся её цветовая схема обновится автоматически. Никакой подмены классов. Никаких пересчётов стилей. Просто CSS делает то, что он делает лучше всего.
Вот паттерн, которого я совсем не ожидал: все три приложения используют единицы ch для горизонтальных отступов.
:root {
--inline-space: 1ch; /* Horizontal: one character width */
--block-space: 1rem; /* Vertical: one root em */
}
.component {
padding-inline: var(--inline-space);
margin-block: var(--block-space);
}Почему символы? Потому что отступы должны соотноситься с контентом. Промежуток в 1ch между словами ощущается естественно, потому что это буквально ширина одного символа. Когда размер шрифта меняется, отступы пропорционально масштабируются.
Это же делает их адаптивные брейкпоинты неожиданно изящными:
@media (min-width: 100ch) {
/* Desktop: content is wide enough for sidebar */
}Вместо того чтобы спрашивать «это планшет?», они спрашивают: «достаточно ли места для 100 символов текста?» Это семантично. Это основано на контенте. И это работает.
Стоит сразу снять главный вопрос. Эти приложения действительно используют утилитарные классы:
/* From utilities.css */
.flex { display: flex; }
.gap { gap: var(--inline-space); }
.pad { padding: var(--block-space) var(--inline-space); }
.txt-large { font-size: var(--text-large); }
.hide { display: none; }Разница в другом: эти утилиты дополняют стили, а не составляют их основу. Базовое оформление живёт в семантических классах компонентов. Утилиты решают исключения: разовые корректировки вёрстки, условное скрытие элемента.
Сравните с типичным компонентом на Tailwind:
<!-- Tailwind approach -->
<button class="inline-flex items-center gap-2 px-4 py-2 rounded-full
border border-gray-300 bg-white text-gray-900
hover:bg-gray-50 focus:ring-2 focus:ring-blue-500">
Save
</button>И эквивалент у 37signals:
<!-- Semantic approach -->
<button class="btn">Save</button>
.btn {
--btn-padding: 0.5em 1.1em;
--btn-border-radius: 2em;
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: var(--btn-padding);
border-radius: var(--btn-border-radius);
border: 1px solid var(--color-border);
background: var(--btn-background, var(--color-canvas));
color: var(--btn-color, var(--color-ink));
transition: filter 100ms ease;
}
.btn:hover {
filter: brightness(0.95);
}
.btn--negative {
--btn-background: var(--color-negative);
--btn-color: white;
}Да, это больше CSS. Но посмотрите, что вы получаете взамен:
HTML остаётся читаемым. class="btn btn--negative" говорит, что это за элемент, а не как он выглядит.
Изменения каскадируются. Обновите --btn-padding один раз — обновятся все кнопки.
Варианты легко комбинируются. Добавьте .btn--circle, не переопределяя каждое свойство заново.
Медиазапросы живут рядом с компонентом. Тёмная тема, ховеры и адаптивное поведение собраны там же, где описан сам компонент.
Если есть одна возможность CSS, которая меняет всё, то это :has(). Десятилетиями для того, чтобы стилизовать родителя на основе состояния дочернего элемента, требовался JavaScript. Больше нет.
Writebook использует его для переключения сайдбара — без единой строчки JavaScript:
/* When the hidden checkbox is checked, show the sidebar */
:has(#sidebar-toggle:checked) #sidebar {
margin-inline-start: 0;
}Fizzy применяет его для управления раскладкой колонок канбана:
.card-columns {
grid-template-columns: 1fr var(--column-width) 1fr;
}
/* When any column is expanded, adjust the grid */
.card-columns:has(.cards:not(.is-collapsed)) {
grid-template-columns: auto var(--column-width) auto;
}Campfire использует его для интеллектуального оформления кнопок:
/* Circle buttons when containing only icon + screen reader text */
.btn:where(:has(.for-screen-reader):has(img)) {
--btn-border-radius: 50%;
aspect-ratio: 1;
}
/* Highlight when internal checkbox is checked */
.btn:has(input:checked) {
--btn-background: var(--color-ink);
--btn-color: var(--color-ink-reversed);
}Это пример того, как CSS делает то, для чего раньше был нужен JavaScript. Управление состоянием. Условное отображение. Выбор родителя. Всё декларативно. Всё в стилях.
Больше всего меня поразило то, как эта архитектура развивается от релиза к релизу.
Campfire (первый релиз) заложил фундамент:
цвета в OKLCH
кастомные свойства для всего
отступы, основанные на ширине символов
плоская файловая структура
View Transitions API для плавных переходов между страницами
Writebook (второй релиз) добавил современные возможности:
container queries для адаптивности на уровне компонентов
@starting-style для входных анимаций
Fizzy (третий релиз) полностью ушёл в современный CSS:
CSS Layers (@layer) для управления специфичностью
color-mix() для динамического построения цветов
сложные цепочки :has(), заменяющие управление состоянием в JavaScript
По этим трем продуктам видно, как команда учится, экспериментирует и выпускает всё более продвинутый CSS. В Fizzy они используют возможности, о существовании которых многие разработчики даже не знают.
/* Fizzy's layer architecture */
@layer reset, base, components, modules, utilities;
@layer components {
.btn { /* Always lower specificity than utilities */ }
}
@layer utilities {
.hide { /* Always wins over components */ }
}CSS Layers решают ту самую проблему специфичности, которая мучила CSS с самого начала. Теперь не важно, в каком порядке загружаются файлы. Не важно, сколько классов вы навешиваете. Победителя определяют слои. И точка.
Есть один приём, который встречается во всех трёх приложениях и заслуживает отдельного внимания. Их индикаторы загрузки не используют ни изображения, ни SVG, ни JavaScript. Только CSS-маски.
Вот реальная реализация из файла Fizzy spinners.css:
@layer components {
.spinner {
position: relative;
&::before {
--mask: no-repeat radial-gradient(#000 68%, #0000 71%);
--dot-size: 1.25em;
-webkit-mask: var(--mask), var(--mask), var(--mask);
-webkit-mask-size: 28% 45%;
animation: submitting 1.3s infinite linear;
aspect-ratio: 8/5;
background: currentColor;
content: "";
inline-size: var(--dot-size);
inset: 50% 0.25em;
margin-block: calc((var(--dot-size) / 3) * -1);
margin-inline: calc((var(--dot-size) / 2) * -1);
position: absolute;
}
}
}Кадры анимации вынесены в отдельный файл animation.css:
@keyframes submitting {
0% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 0% }
12.5% { -webkit-mask-position: 0% 50%, 50% 0%, 100% 0% }
25% { -webkit-mask-position: 0% 100%, 50% 50%, 100% 0% }
37.5% { -webkit-mask-position: 0% 100%, 50% 100%, 100% 50% }
50% { -webkit-mask-position: 0% 100%, 50% 100%, 100% 100% }
62.5% { -webkit-mask-position: 0% 50%, 50% 100%, 100% 100% }
75% { -webkit-mask-position: 0% 0%, 50% 50%, 100% 100% }
87.5% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 50% }
100% { -webkit-mask-position: 0% 0%, 50% 0%, 100% 0% }
}Три точки, подпрыгивающие по очереди.

background: currentColor означает, что индикатор автоматически наследует цвет текста. Он работает в любом контексте, любой теме, любой цветовой схеме. Никаких дополнительных ресурсов. Чистое CSS-творчество.
Стандартный браузерный элемент <mark> выглядит как жёлтый текстовыделитель. Работает, но особой изящности в этом нет. В Fizzy для подсветки совпадений в результатах поиска выбирают другой подход: вокруг найденного слова рисуется будто бы от руки обведённый кружок.

Вот реализация из circled-text.css:
@layer components {
.circled-text {
--circled-color: oklch(var(--lch-blue-dark));
--circled-padding: -0.5ch;
background: none;
color: var(--circled-color);
position: relative;
white-space: nowrap;
span {
opacity: 0.5;
mix-blend-mode: multiply;
@media (prefers-color-scheme: dark) {
mix-blend-mode: screen;
}
}
span::before,
span::after {
border: 2px solid var(--circled-color);
content: "";
inset: var(--circled-padding);
position: absolute;
}
span::before {
border-inline-end: none;
border-radius: 100% 0 0 75% / 50% 0 0 50%;
inset-block-start: calc(var(--circled-padding) / 2);
inset-inline-end: 50%;
}
span::after {
border-inline-start: none;
border-radius: 0 100% 75% 0 / 0 50% 50% 0;
inset-inline-start: 30%;
}
}
}HTML выглядит так:<mark class="circled-text"><span></span>webhook</mark>.
Пустой span существует исключительно ради двух псевдоэлементов (::before и ::after), которые рисуют левую и правую половины окружности.
Техника использует асимметричные значения border-radius, создавая живой, чуть небрежный, «рисованный» эффект. Свойство mix-blend-mode: multiply делает окружность полупрозрачной относительно фона, а в тёмной теме переключение на screen обеспечивает корректное смешивание.

Никаких изображений. Никаких SVG. Только рамки и border-radius создают иллюзию нарисованного от руки кружка.
И Fizzy, и Writebook анимируют HTML-элементы <dialog>. Раньше это было мучительно сложно. Секрет заключается в @starting-style.
Вот реальная реализация из Fizzy (dialog.css):
@layer components {
:is(.dialog) {
border: 0;
opacity: 0;
transform: scale(0.2);
transform-origin: top center;
transition: var(--dialog-duration) allow-discrete;
transition-property: display, opacity, overlay, transform;
&::backdrop {
background-color: var(--color-black);
opacity: 0;
transform: scale(1);
transition: var(--dialog-duration) allow-discrete;
transition-property: display, opacity, overlay;
}
&[open] {
opacity: 1;
transform: scale(1);
&::backdrop {
opacity: 0.5;
}
}
@starting-style {
&[open] {
opacity: 0;
transform: scale(0.2);
}
&[open]::backdrop {
opacity: 0;
}
}
}
}Переменная --dialog-duration определена глобально как 150 мс.

Правило @starting-style задаёт начальное состояние анимации в момент появления элемента. В сочетании с allow-discrete становится возможным анимировать переход между display: none и display: block. Модальное окно плавно увеличивается и проявляется. Подложка затемняется отдельно. Никаких JS-библиотек для анимаций. Никакого ручного переключения классов. Всё делает браузер.
Я не призываю вас завтра же отказаться от сборочных инструментов. Но предлагаю пересмотреть свои предположения.
Возможно, вам не нужны Sass или PostCSS. В нативном CSS уже есть переменные, вложенность и color-mix(). Возможности, которые раньше требовали полифилов, теперь поддерживаются всеми основными браузерами.
Возможно, вам не нужен Tailwind в каждом проекте. Особенно если ваша команда достаточно хорошо понимает CSS, чтобы собрать небольшой дизайн-систему.
Пока индустрия стремительно движется к всё более сложным цепочкам инструментов, 37signals идут спокойным шагом в противоположную сторону. Подходит ли такой подход всем? Нет. Большим командам с разным уровнем владения CSS Tailwind может дать полезные ограничения. Но для многих проектов их путь напоминает: проще иногда действительно лучше.

Друзья! Эту статью перевела команда «JavaScript for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Frontend. Подписывайтесь, чтобы быть в курсе и ничего не упустить!