Команда 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
Сразу уточню: с 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, не переопределяя каждое свойство заново.Медиазапросы живут рядом с компонентом. Тёмная тема, ховеры и адаптивное поведение собраны там же, где описан сам компонент.
Революция :has()
Если есть одна возможность 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>
Стандартный браузерный элемент <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 сообщество

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