javascript

Vanilla CSS — единственное, что вам нужно

  • пятница, 12 декабря 2025 г. в 00:00:11
https://habr.com/ru/articles/975450/

Команда 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 сильно эволюционировал. Язык, которому раньше нужны были препроцессоры ради переменных и вложенности, теперь имеет:

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. Но посмотрите, что вы получаете взамен:

  1. HTML остаётся читаемым. class="btn btn--negative" говорит, что это за элемент, а не как он выглядит.

  2. Изменения каскадируются. Обновите --btn-padding один раз — обновятся все кнопки.

  3. Варианты легко комбинируются. Добавьте .btn--circle, не переопределяя каждое свойство заново.

  4. Медиазапросы живут рядом с компонентом. Тёмная тема, ховеры и адаптивное поведение собраны там же, где описан сам компонент.

Революция :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. Подписывайтесь, чтобы быть в курсе и ничего не упустить!