Визуальный стек статейного сайта: типографика, цвет и комьюнити-слой

от автора

Визуальный стек статейного сайта складывается из сотен мелких решений — от оси GRAD в шрифте до длительности анимации закрытия модального окна. Каждое такое решение либо работает на чтение, либо ворует внимание у текста.

Почему один шрифтовой файл заменяет целое семейство начертаний

Разработчики обычно ограничиваются двумя параметрами: размером шрифта и межстрочным интервалом. Такой подход остаётся точкой входа, но не раскрывает возможностей современных шрифтовых технологий.

Переменный шрифт представляет собой не набор статичных начертаний, а непрерывное пространство параметров. Inter Variable, к примеру, поддерживает ось wght для управления весом от 100 до 900 и ось opsz для оптического размера, который автоматически подстраивает форму глифов под кегль. Некоторые шрифтовые семейства добавляют ось GRAD — grade, которая меняет визуальную плотность символа без изменения его ширины. Связка этих осей позволяет точно настраивать типографику под конкретные условия отображения, не загружая отдельные файлы для каждого начертания.

body {  font-family: 'Inter Variable', sans-serif;  font-variation-settings: 'wght' 400, 'opsz' 32;}h1 {  font-variation-settings: 'wght' 750, 'opsz' 72, 'GRAD' 0;}

На тёмном фоне буквы воспринимаются визуально жирнее из-за эффекта irradiation — светлое на тёмном «расплывается» в сторону фона. Ось GRAD позволяет компенсировать это без изменения геометрии глифов:

@media (prefers-color-scheme: dark) {  body { font-variation-settings: 'wght' 400, 'opsz' 32, 'GRAD' -25; }}

Fluid-шкала вместо брейкпоинтов

Типографическая шкала через clamp() задаёт непрерывный поток вместо дискретных скачков на медиазапросах. Каждое значение описывает минимум на узком вьюпорте, скорость роста через vw и максимум на широком:

:root {  --step--1: clamp(0.75rem,  0.7rem  + 0.25vw, 0.875rem);  --step-0:  clamp(1rem,     0.95rem + 0.25vw, 1.125rem);  --step-1:  clamp(1.2rem,   1.1rem  + 0.5vw,  1.5rem);  --step-2:  clamp(1.44rem,  1.25rem + 1vw,    2rem);  --step-3:  clamp(1.728rem, 1.4rem  + 1.5vw,  2.5rem);  --step-4:  clamp(2.074rem, 1.6rem  + 2.5vw,  3.5rem);}

Коэффициент роста между ступенями (~1.2) сохраняет музыкальность шкалы на любом размере экрана без ручной синхронизации значений.

OpenType-функции

Шрифты с поддержкой OpenType содержат альтернативные глифы, которые по умолчанию выключены. Включение требует явного указания:

.article-body {  font-feature-settings:    'kern' 1,   /* кернинг */    'liga' 1,   /* стандартные лигатуры: fi, fl */    'calt' 1,   /* контекстные альтернативы */    'onum' 1;   /* строчные цифры — не торчат выше строки */}/* Только в таблицах и метаданных: */.data-table, .post-meta {  font-feature-settings: 'tnum' 1; /* табличные цифры — одинаковая ширина */}

onum (oldstyle numerals) — принципиальная деталь для длинного читательского текста. Заглавные цифры (0–9 стандартные) визуально выпирают из строки, строчные интегрируются в ритм текста.

Оптическое выравнивание заголовков

Из-за боковых полей глифов заголовки с засечками или геометрическими шрифтами кажутся «съехавшими» вправо относительно начала колонки. Компенсация:

h1, h2, h3 {  text-indent: -0.05em;  hanging-punctuation: first last;}

hanging-punctuation выносит кавычки и тире за поле колонки — так оптическая граница совпадает с геометрической.

Иерархия prose-блоков

Статья содержит несколько типов блоков с разной функцией. Назначение каждому собственного размерного токена делает иерархию явной и легко корректируемой без правки каждого блока отдельно:

:root {  --sbrd-single-body:       1.0rem;  /* основной текст */  --sbrd-single-quote:      0.93rem; /* цитата — тише body */  --sbrd-single-list:       0.90rem; /* списки — вспомогательный слой */  --sbrd-single-disclaimer: 0.84rem; /* служебный блок */  --sbrd-single-code:       0.875rem;}

Ключевое соотношение: body > quote > list > disclaimer. Нарушение этой иерархии — когда список совпадает по размеру с основным текстом — создаёт ощущение «двух конкурирующих потоков» вместо структурированного чтения.

Цвет: перцептивная система на OKLCH

Почему не HSL

Модель HSL оперирует математической геометрией, игнорируя физиологию человеческого зрения. При равных значениях параметра яркости желтый и синий цвета будут казаться глазу совершенно разными по светлоте, причем желтый всегда выглядит значительно светлее. Пространство OKLCH устраняет этот перекос, выравнивая цветовые координаты под реальное восприятие. Равные цифры светлоты в данной системе гарантируют, что любые оттенки считываются как одинаково яркие, что позволяет строить визуально сбалансированные интерфейсные палитры.

Это принципиально для систем состояний — success, warning, danger, info:

:root {  --state-success: oklch(65% 0.18 145);  /* зелёный */  --state-warning: oklch(75% 0.18 85);   /* янтарный */  --state-danger:  oklch(62% 0.22 25);   /* красный */  --state-info:    oklch(65% 0.18 250);  /* синий */}

При фиксированном L и сопоставимом C все четыре цвета воспринимаются одинаково «loud» — ни один не доминирует над другим только за счёт яркости.

Relative color syntax

CSS Relative Color Syntax позволяет строить производные от базового токена математически, без ручного подбора значений:

:root {  --accent: oklch(65% 0.2 250);  --accent-light:      oklch(from var(--accent) calc(l + 0.15) c h);  --accent-dark:       oklch(from var(--accent) calc(l - 0.15) c h);  --accent-muted:      oklch(from var(--accent) l calc(c * 0.4) h);  --accent-complement: oklch(from var(--accent) l c calc(h + 180));  --accent-10: oklch(from var(--accent) l c h / 0.10);  --accent-20: oklch(from var(--accent) l c h / 0.20);}

При изменении --accent все производные пересчитываются автоматически. Это исключает ситуацию, когда смена акцентного цвета требует ручного обновления десятков переменных.

Три уровня токенов

Хорошо организованная система строится от примитивов к семантике и от семантики к компонентам:

/* Уровень 1 — примитивы, не используются напрямую */--blue-500: oklch(55% 0.18 250);--gray-950: oklch(10% 0.01 250);/* Уровень 2 — семантика */--color-bg-primary:    var(--gray-950);--color-text-primary:  var(--gray-50);--color-text-secondary:var(--gray-400);--color-border-subtle: var(--gray-800);/* Уровень 3 — компоненты */--article-bg:   var(--color-bg-primary);--callout-border: var(--color-border-default);

Компоненты никогда не ссылаются на примитивы напрямую. Это гарантирует, что при смене темы достаточно переопределить семантический слой.

Контраст как инструмент иерархии

WCAG задаёт минимальную планку. Реальная задача контраста — создать визуальную иерархию, при которой взгляд читателя двигается по странице предсказуемо:

Основной текст статьи     ~12:1   максимальная читаемостьКомментарии               ~8:1    важно, но вторичноМетаданные (дата, счёт)   ~5:1    сканируетсяПлейсхолдеры              ~3.5:1  намёк, не контентДекоративные границы      ~2:1    структура без акцента

Использование одного уровня контраста для всех элементов — это визуальный шум. Иерархия контраста равна иерархии внимания.

Поверхности и пространство

Surface ladder

Глубина темного интерфейса создаётся не тенями (они теряют смысл на тёмном фоне), а последовательным осветлением слоёв:

--surface-0: oklch(12% 0.01 250);  /* страница */--surface-1: oklch(15% 0.01 250);  /* карточки */--surface-2: oklch(18% 0.015 250); /* поднятые элементы */--surface-3: oklch(22% 0.02 250);  /* hover */--surface-4: oklch(26% 0.02 250);  /* активные состояния */

Каждый слой отличается от предыдущего примерно на 3% по L. Это создаёт воспринимаемую глубину без резких переходов. Ключевое: surface-0 — это фон страницы, а не самый тёмный элемент.

Тёмная тема: специфические ловушки

Размещение блоков чистого белого цвета (#ffffff) на глубоком тёмном фоне (#080b0f) формирует зоны экстремального контраста. Человеческий глаз адаптируется к общему уровню освещённости интерфейса. Резкие перепады яркости вызывают эффект гало и быструю утомляемость. Формируйте поднятые карточки и модальные окна исключительно через токены поверхностных слоёв (—surface-1, —surface-2). Прямое указание background: white в стилях компонентов исключите полностью.

Насыщенность (chroma) акцентных оттенков на тёмном фоне требует принудительного снижения. Применение максимально яркого синего oklch(65% 0.25 250) на подложке oklch(12%) провоцирует оптическую вибрацию. Светлый и тёмный цвета начинают конкурировать за фокус, создавая ложные границы и мерцание на стыке. Ограничьте рабочий диапазон насыщенности на тёмной подложке значениями 0.15–0.20. Превышение указанного порога неизбежно генерирует визуальный шум.

Атмосфера: детали которые не копируются одной строкой

Noise texture

Плоский заливной цвет выглядит дёшево на высококачественных экранах. Тонкий зернистый слой добавляет материальность, имитируя физическую текстуру:

.article-hero::after {  content: '';  position: absolute;  inset: 0;  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");  pointer-events: none;}

opacity: 0.04 — работающий диапазон. Ниже — незаметно, выше — становится артефактом.

Gradient mesh

Многоточечный градиентный фон за заголовком создаёт глубину без тяжёлых изображений:

.article-header__mesh {  position: absolute;  inset: -50%;  background:    radial-gradient(ellipse 60% 50% at 20% 50%,      oklch(55% 0.2 280 / 0.15) 0%, transparent 70%),    radial-gradient(ellipse 50% 60% at 80% 30%,      oklch(60% 0.18 200 / 0.12) 0%, transparent 70%);  filter: blur(60px);  animation: mesh-drift 20s ease-in-out infinite alternate;  pointer-events: none;}@keyframes mesh-drift {  0%   { transform: translate(0, 0) scale(1); }  50%  { transform: translate(2%, 1%) scale(1.02); }  100% { transform: translate(1%, -1%) scale(1.01); }}

blur(60px) делает переходы между пятнами мягкими. inset: -50% — обёртка больше контейнера, чтобы размытые края не обрезались.

Scroll-driven animations: CSS без JavaScript

Прогресс чтения

Прогресс-бар как анимация, привязанная к скроллу корневого элемента:

@keyframes progress {  from { width: 0%; }  to   { width: 100%; }}.reading-progress {  position: fixed;  top: 0; left: 0;  height: 2px;  background: var(--accent);  animation: progress linear both;  animation-timeline: scroll(root);}

Появление элементов

view() timeline привязывает анимацию к вхождению элемента в вьюпорт:

.article-body > * {  animation: fade-up linear both;  animation-timeline: view();  animation-range: entry 0% entry 30%;}@keyframes fade-up {  from { opacity: 0; transform: translateY(24px); filter: blur(4px); }  to   { opacity: 1; transform: translateY(0);    filter: blur(0);   }}

animation-range: entry 0% entry 30% — анимация завершается, когда элемент прошёл 30% от нижней границы вьюпорта. После этого элемент остаётся полностью видимым.

Reduced motion

При prefers-reduced-motion: reduce движение отключается, но контент не исчезает — только fade:

@media (prefers-reduced-motion: reduce) {  .article-header__mesh { animation: none; }  .article-body > * {    animation: fade-in linear both;    animation-timeline: view();    animation-range: entry 0% entry 20%;  }  @keyframes fade-in {    from { opacity: 0; }    to   { opacity: 1; }  }}

Комьюнити-слой: визуальная система

Комьюнити-элементы живут поверх контента. Их цветовая логика не должна конкурировать с текстом статьи — комьюнити всегда вторично по вниманию.

Аватары: детали которые замечают

Вместо белой рамки — свечение цвета самого аватара. Это технически сложнее, но визуально корректнее: рамка отделяет, свечение интегрирует:

.avatar {  width: 36px; height: 36px;  border-radius: 50%;  flex-shrink: 0;  box-shadow:    0 0 0 2px var(--surface-1),    0 0 0 3px oklch(from var(--user-color) l c h / 0.4);}

Цвет аватара генерируется детерминированно из имени пользователя — одинаковое имя всегда даёт одинаковый цвет:

function userColor(username) {  let hash = 0;  for (let i = 0; i < username.length; i++) {    hash = username.charCodeAt(i) + ((hash << 5) - hash);  }  const hue = Math.abs(hash) % 360;  return `oklch(65% 0.18 ${hue})`;}

Фиксированные L и C при переменном H гарантируют, что все сгенерированные цвета одинаково яркие — никто не получает «страшно тёмный» или «слепящий» аватар.

Стек аватаров раздвигается при наведении:

.avatar-stack .avatar { margin-left: -10px; transition: margin 0.2s ease; }.avatar-stack:hover .avatar { margin-left: 4px; }.avatar-stack .avatar:hover { z-index: 10; transform: translateY(-2px) scale(1.1); }

Форма комментария: живые состояния

Форма существует минимум в трёх состояниях, каждое из которых должно ощущаться физически отличным:

Покой — нейтральный --surface-1, тонкая граница.

Взаимодействие — при :focus-within граница переходит в акцентный цвет, фон поднимается до --surface-2, появляется glow, тулбар с действиями въезжает снизу:

.comment-form:focus-within {  border-color: var(--border-focus);  background: var(--surface-2);  box-shadow:    0 0 0 3px var(--glow-focus),    0 8px 24px rgb(0 0 0 / 0.15);}.comment-form__toolbar {  opacity: 0;  transform: translateY(4px);  transition: opacity 0.2s ease, transform 0.2s ease;}.comment-form:focus-within .comment-form__toolbar {  opacity: 1;  transform: translateY(0);}

field-sizing: content на textarea позволяет полю расти с контентом без JavaScript:

.comment-textarea {  field-sizing: content;  min-height: 80px;  max-height: 400px;  resize: none;}

Счётчик символов меняет цвет при приближении к лимиту через data-атрибуты — это разделяет логику состояния и его визуализацию:

.char-counter[data-warning='true'] { color: var(--state-warning); }.char-counter[data-danger='true']  { color: var(--state-danger);  }

Кнопки: три варианта

Primary — заливка акцентным цветом, тёмный текст поверх, при hover поднимается и светлеет:

.btn--primary:hover {  background: oklch(from var(--accent) calc(l + 0.05) c h);  box-shadow: 0 4px 12px oklch(from var(--accent) l c h / 0.4);  transform: translateY(-1px);}

Ghost — без заливки, только граница. При hover фон появляется — не наоборот.

Danger — скрытая угроза: в покое не выглядит опасной, только при наведении проявляет красный фон. Это снижает тревожность от нейтрального UI, сохраняя сигнал при намеренном взаимодействии.

Состояние загрузки через aria-busy без изменения разметки:

.btn[aria-busy='true'] { pointer-events: none; opacity: 0.7; }.btn[aria-busy='true'] .btn__text { opacity: 0; }.btn[aria-busy='true']::before {  content: '';  position: absolute;  width: 16px; height: 16px;  border: 2px solid currentColor;  border-top-color: transparent;  border-radius: 50%;  animation: spin 0.6s linear infinite;}

Реакции на абзацы

Триггер появляется в gutter при наведении на абзац — не занимает места в основном потоке:

.reactions-trigger {  position: absolute;  left: -2.5rem;  top: 50%;  transform: translateY(-50%) scale(0.8);  opacity: 0;  transition: opacity 0.15s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);}.paragraph-wrapper:hover .reactions-trigger {  opacity: 1;  transform: translateY(-50%) scale(1);}

Пружинная кубическая кривая cubic-bezier(0.34, 1.56, 0.64, 1) — spring easing. Значения >1 по y создают небольшой overshooting, иммитирующий физическую упругость. Это отличает UI от «планшетного» равномерного движения.

Попап с эмодзи появляется от триггера с тем же spring:

.reaction-emoji:hover {  transform: scale(1.3) translateY(-2px);  background: var(--surface-3);}

Микроанимации: каждая решает задачу

Каждая анимация в хорошем UI несёт информацию — не декоративна.

Появление нового комментария сообщает: «этого здесь не было». blur при появлении акцентирует переход из несуществующего состояния в существующее:

@keyframes comment-appear {  from { opacity: 0; transform: translateY(12px); filter: blur(2px); }  to   { opacity: 1; transform: translateY(0);    filter: blur(0);   }}

Удаление схлопывает элемент — пространство не остаётся пустым резко:

@keyframes comment-remove {  to { opacity: 0; transform: translateX(-12px) scale(0.97); max-height: 0; margin: 0; padding: 0; }}

Лайк использует двухфазный bounce — сначала сжатие (антиципация), потом overshooting. Это имитирует физику нажатой кнопки:

@keyframes like-bounce {  0%   { transform: scale(1); }  30%  { transform: scale(0.8); }  60%  { transform: scale(1.35); }  80%  { transform: scale(0.95); }  100% { transform: scale(1); }}

Скелетон вместо спиннера — shimmer повторяет форму будущего контента, снижая воспринимаемое время ожидания:

@keyframes skeleton-shimmer {  from { background-position: -200% 0; }  to   { background-position:  200% 0; }}.skeleton {  background: linear-gradient(90deg,    var(--surface-1) 0%, var(--surface-3) 50%, var(--surface-1) 100%);  background-size: 200% 100%;  animation: skeleton-shimmer 1.5s ease infinite;}

Счётчик лайков прокручивается как слот-машина — новое число въезжает снизу, старое уезжает вверх. Это делает изменение заметным без цветового сигнала:

.like-count { display: inline-block; overflow: hidden; height: 1.2em; }.like-count__inner { display: flex; flex-direction: column; transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }.like-count__inner.increment { transform: translateY(-50%); }

Онлайн-индикатор пульсирует с помощью расширяющегося box-shadow — не мигает, что раздражает, а «дышит»:

@keyframes online-pulse {  0%, 100% { box-shadow: 0 0 0 0 oklch(65% 0.18 145 / 0.6); }  50%       { box-shadow: 0 0 0 4px oklch(65% 0.18 145 / 0); }}

Toast-уведомления содержат прогресс-бар автозакрытия — пользователь видит, сколько времени осталось:

.notification::after {  content: '';  position: absolute;  bottom: 0; left: 0;  height: 2px;  background: var(--notification-color, var(--accent));  animation: notification-timer var(--duration, 5s) linear forwards;}@keyframes notification-timer {  from { width: 100%; }  to   { width: 0%; }}

Модальные окна

backdrop-filter: blur на подложке создаёт эффект «стекла» — контент под модалкой виден, но вторичен. Это важно: blur сообщает пользователю, что он находится в другом слое, а не что фон исчез:

.modal-backdrop {  backdrop-filter: blur(8px);  background: rgb(0 0 0 / 0.6);}.modal {  animation: modal-appear 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;}@keyframes modal-appear {  from { opacity: 0; transform: scale(0.94) translateY(8px); }  to   { opacity: 1; transform: scale(1) translateY(0); }}

Закрытие — отдельная анимация, быстрее появления. Это интуитивно: пользователь хочет вернуться к контенту быстро.

Архитектура CSS: @layer против войны специфичности

Каскадные слои делают порядок применения правил явным:

@layer reset, base, tokens, layout, components, utilities, overrides;

Правило в utilities всегда перекроет components независимо от специфичности селекторов. Это устраняет !important-войны и делает переопределения предсказуемыми. Компоненты комьюнити должны жить в одном слое с компонентами статьи — не в отдельном @layer поверх, иначе создаётся своя точка конфликта.

Доступность как часть системы

Focus visible — не браузерный default:

:focus-visible {  outline: 2px solid var(--accent);  outline-offset: 3px;  border-radius: 4px;}:focus:not(:focus-visible) { outline: none; }

Второе правило убирает outline при клике мышью — focus-стиль нужен клавиатурным пользователям, а не всем подряд.

Touch targets — минимум 44×44 пикселей на mobile для кнопок реакций. Кнопки меньшего размера — это не экономия пространства, это ошибки пользователей.

ARIAaria-busy на кнопках в loading-состоянии, live regions для toast-уведомлений и новых комментариев. Скринридер должен объявлять об изменениях, которые произошли без действия пользователя.

Безопасность комьюнити-функций

Визуальный слой комьюнити-функций работает только до тех пор, пока система не сталкивается с реальным трафиком. Как только на сайте появляются реакции, голосования и комментарии, включаются автоматизированные сценарии. Защита здесь строится не на сложных алгоритмах, а на понимании того, как именно работают боты и уязвимые парсеры.

Лимиты по IP вместо сессий Настройка ограничений только по сессии — это иллюзия защиты. Скрипту не составляет труда генерировать новую сессию каждые пять секунд, просто очищая куки. Если вы ограничиваете частоту реакций или голосов по сессии, бот обойдёт лимит за минуту. Привязка к IP-адресу усложняет задачу: сменить IP на лету без использования дорогой инфраструктуры прокси-серверов гораздо сложнее. Да, это не спасёт от крупного ботнета, но отсечёт 90% автоматизации, написанной на коленке.

Отпечатки браузера вместо cookies Браузеры массово блокируют сторонние cookies. Полагаться на них при подсчёте голосов или блокировке спама бессмысленно — вы просто потеряете часть легитимных пользователей. Рабочая альтернатива — связка IP-адреса, User-Agent и временного окна. Если один и тот же браузер с одного адреса пытается проголосовать десять раз за десять минут, система блокирует действие. Хранить такой слепок дешевле, а для рядового пользователя, который просто открыл сайт в режиме инкогнито, это не создаст проблем.

Magic link вместо паролей Отказ от паролей закрывает сразу несколько классов атак. Нет пароля — нечего украсть из базы при взломе, нечего подбирать брутфорсом, не нужно заставлять пользователей придумывать комбинации вроде Qwerty123!, которые они потом используют на банковских сайтах. Пользователь вводит email и получает ссылку с одноразовым токеном. Токен живёт 15–30 минут. Если письмо перехватят или аккаунт почты взломают постфактум, ссылка превратится в бесполезный набор символов.

Ловушка MD5 и хранение email С хранением email связана частая ошибка. API сторонних сервисов, например Gravatar, требует передавать MD5-хеш адреса для подбора аватара. Разработчики по привычке сохраняют этот же MD5 в свою базу данных как идентификатор. MD5 для хранения учётных данных неприемлем: современные видеокарты перебирают такие хеши за секунды, а радужные таблицы лежат в открытом доступе. Для собственной базы используйте SHA-256 с солью или bcrypt. MD5 вычисляйте на лету только как временный буфер перед отправкой запроса к Gravatar, и сразу удаляйте из памяти.

Белые списки в Markdown Чёрные списки запрещённых тегов в парсерах Markdown не работают. Вы заблокируете <script>, а злоумышленник воспользуется <img src="x"> или вставит вредоносный код внутрь атрибутов SVG. Новые векторы атак появляются быстрее, чем вы успеваете обновлять чёрный список. Единственный рабочий подход — белый список. Парсер должен знать, что разрешены только <b>, <i>, <a>, <code>. Всё, чего нет в списке, безжалостно вырезается или экранируется. Система по умолчанию не знает о новых уязвимостях, поэтому и не может их отрендерить.

Премодерация первого комментария На старте проекта нет смысла внедрять сложные ML-модели для поиска спама или платить за капчу. Самый дешёвый и эффективный метод — ручная премодерация первого комментария. Боты находят API эндпоинт и начинают спамить. Если первый пост от нового аккаунта уходит в очередь к модератору, а все последующие публикуются автоматически, вы отрезаете автоматизированные атаки. Один клик сотрудника модерации даёт пользователю доверие, и дальше он пишет свободно, не натыкаясь на искусственные барьеры.

Принцип: комьюнити не перебивает статью

Это не техническое требование — это продуктовый принцип. Пользователь пришёл читать. Комьюнити-элементы должны быть достаточно заметны, чтобы привлечь внимание при желании, и достаточно тихи, чтобы не мешать чтению.

Акцентный цвет комьюнити слабее, чем заголовки статьи. Анимации комьюнити не запускаются во время прокрутки статьи. Реакции на абзацы появляются в gutter, а не внутри текстовой колонки. Форма комментария находится после полного текста, а не между разделами.

Разница между обычным сайтом и выдающимся — не в наличии функций, а в плотности продуманных деталей на каждом уровне: от оси GRAD в шрифте до длительности анимации закрытия модального окна.

ссылка на оригинал статьи https://habr.com/ru/articles/1053162/