47 Модуль 10 Реальные проекты 20 мин урок 22 из 25

Проект: лендинг

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

Что ты научишься делать

  • Соберёшь лендинг из готовых компонентов, а не экран за экраном
  • Сделаешь липкую навигацию, которая не мешает якорям
  • Добавишь появление при скролле, уважающее спокойный режим

#Что собираем

Лендинг кофейни: пять экранов, одна цель — записаться на дегустацию. Всё на чистом CSS, ни одной библиотеки.

  • Липкая навигация, появляющаяся после первого экрана
  • Герой с диагональным низом и текстом поверх градиента
  • Три карточки преимуществ с ровными кнопками
  • Тарифы, где средний выделен
  • Вопросы и ответы без единой строки скрипта
  • Появление блоков при прокрутке

#Шаг 1. Токены и каркас

Начинаем не с шапки, а с системы. Двадцать строк, которые определят весь остальной код.

CSSТокены и контейнер
@layer reset, tokens, base, layout, components, utilities;@layer tokens {  :root {    color-scheme: light dark;    --color-bg: light-dark(#fdfcfa, #12131a);    --color-surface: light-dark(#fff, #1b1d27);    --color-text: light-dark(#16181d, #e8eaf2);    --color-muted: light-dark(#5b6070, #a7adc2);    --color-accent: light-dark(#6544e0, #b9a5ff);    --color-on-accent: light-dark(#fff, #16181d);    --space-1: 4px;  --space-2: 8px;  --space-3: 12px;    --space-4: 16px; --space-6: 24px; --space-8: 32px;    --space-12: 48px; --space-16: 64px;    --radius-md: 14px;    --radius-full: 999px;    --step-0: 1rem;    --step-1: 1.25rem;    --step-2: clamp(1.4rem, 1.2rem + 1vw, 1.9rem);    --step-3: clamp(2rem, 1.5rem + 2.6vw, 3.2rem);    --ease: cubic-bezier(.22, 1, .36, 1);  }}@layer layout {  /* Полноширинный прорыв из урока 29:     блок выходит на всю ширину одной строкой */  .page {    display: grid;    grid-template-columns:      [full-start] minmax(var(--space-4), 1fr)      [content-start] minmax(0, 1140px) [content-end]      minmax(var(--space-4), 1fr) [full-end];  }  .page > * { grid-column: content; }  .page > .full { grid-column: full; }}

#Шаг 2. Липкая навигация

Навигация прижата к верху и появляется, когда первый экран уехал. Главная тонкость — scroll-padding-top: без него якорные ссылки прячут заголовок под шапкой.

CSSnav.css
@layer layout {  html {    /* Якорь не заедет под липкую шапку */    scroll-padding-block-start: 76px;    scroll-behavior: smooth;  }  /* Кому движение мешает — прокрутка мгновенная */  @media (prefers-reduced-motion: reduce) {    html { scroll-behavior: auto; }  }  .nav {    position: sticky;    inset-block-start: 0;    z-index: 10;    display: flex;    align-items: center;    gap: var(--space-6);    padding-block: var(--space-3);    background: color-mix(in oklab, var(--color-bg) 82%, transparent);    backdrop-filter: blur(12px);    -webkit-backdrop-filter: blur(12px);    border-block-end: 1px solid transparent;    transition: border-color .25s var(--ease);  }  /* Граница появляется, только когда под шапкой что-то есть.     :has() смотрит вперёд по документу — скрипт не нужен */  .page:has(.hero:not(:hover)) .nav { border-color: inherit; }  .nav__list { display: flex; gap: var(--space-4); margin-inline-start: auto; }}

#Шаг 3. Герой с диагональю

Первый экран
В песочницу

#Шаг 4. Карточки с ровными кнопками

Три преимущества с текстом разной длины. Кнопки должны стоять на одной линии — это тот самый случай для subgrid из урока 29, с запасным вариантом на флексах.

Преимущества
В песочницу

#Шаг 5. Тарифы

Три карточки, средняя выделена. Выделение — модификатор, а не отдельный компонент: меняются только цвета и масштаб.

CSSpricing.css
@layer components {  .plan {    display: grid;    gap: var(--space-3);    padding: var(--space-6);    background: var(--color-surface);    border: 1px solid color-mix(in oklab, var(--color-text) 12%, transparent);    border-radius: var(--radius-md);    /* Точка настройки: модификатор поменяет одно значение */    --plan-accent: var(--color-text);  }  .plan__price {    font-size: var(--step-2);    font-weight: 700;    color: var(--plan-accent);  }  /* Выделенный тариф: три строки вместо копии компонента */  .plan--featured {    --plan-accent: var(--color-accent);    border-color: var(--color-accent);    box-shadow: 0 20px 44px -28px var(--color-accent);  }  /* Приподнимаем только там, где это уместно.     На узком экране карточки идут в столбик — подъём мешает */  @media (min-width: 900px) {    .plan--featured { transform: scale(1.04); }  }}

#Шаг 6. Вопросы без скриптов

FAQ на details
В песочницу

#Шаг 7. Мобильное меню

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

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

CSSМеню-лента на узком экране
@media (max-width: 720px) {  .nav__list {    display: flex;    gap: var(--space-4);    overflow-x: auto;    /* Прокрутка останавливается на пунктах, а не между ними */    scroll-snap-type: x proximity;    /* Края тают — видно, что список продолжается */    mask-image: linear-gradient(      to right, transparent, #000 16px, #000 calc(100% - 16px), transparent    );  }  .nav__item { flex: none; scroll-snap-align: start; }  /* Полосу прокрутки прячем, но саму прокрутку оставляем */  .nav__list::-webkit-scrollbar { block-size: 0; }}

#Шаг 8. Форма записи

Целевое действие всего лендинга. Здесь применяется всё из сорок четвёртого урока: подписи, отложенная проверка, подсказки для мобильной клавиатуры.

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

CSSbook.css
@layer components {  .book {    display: grid;    gap: var(--space-4);    max-inline-size: 420px;  }  .book__input:user-invalid {    border-color: var(--color-danger);  }  /* Кнопка блокируется, пока в форме есть ошибка —     без единой строки скрипта */  .book:has(:user-invalid) .book__submit {    opacity: .55;    pointer-events: none;  }}

#Шаг 9. Появление при скролле

База — рабочая страница без анимации. Улучшение — плавное появление. Порядок именно такой: если скрипт не выполнится, содержимое обязано остаться видимым.

CSSreveal.css
@layer utilities {  /* Прячем только когда скрипты работают.     Флаг ставит тот же скрипт, что и тему, — в head */  [data-js] [data-reveal] {    opacity: 0;    transform: translateY(12px);    transition: opacity .6s var(--ease), transform .6s var(--ease);  }  [data-js] [data-reveal].is-in {    opacity: 1;    transform: none;  }  /* Кому движение мешает — показываем сразу */  @media (prefers-reduced-motion: reduce) {    [data-js] [data-reveal] {      opacity: 1;      transform: none;      transition: none;    }  }}

#Порядок, в котором это собиралось

Обрати внимание: экраны шли не по порядку сверху вниз. Сначала токены, потом сетка страницы, потом карточка — самый частый компонент. Навигация появилась не первой, хотя на макете она сверху.

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

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

#Грабли этого проекта

Что случилосьПочемуРешение
Якорь прячет заголовок под шапкойЛипкая навигация перекрывает цель прокруткиscroll-padding-block-start на html
Секция наехала на навигациюУ липкого элемента не задан z-indexНебольшое значение z-index навигации
Диагональ съела тень герояclip-path режет элемент целикомТень у родителя через filter: drop-shadow()
Выделенный тариф вылез за экранscale на узком экранеМасштаб только от 900 пикселей
Страница пустая без скриптовПоявление спрятало содержимое насовсемПрятать только при флаге data-js
Заголовок висит одним словомПеренос по умолчаниюtext-wrap: balance

#Чек-лист сдачи

Перед отправкой заказчику

Где спотыкаются почти все

Секции обёрнуты в .container каждая

Почему Блок во всю ширину требует отрицательных отступов, которые ломаются при появлении полосы прокрутки.

Как надо Сетка на уровне страницы с именованными линиями — и grid-column: full для полноширинных блоков.

Анимация появления без запасного варианта

Почему Без скриптов страница остаётся пустой. Это ломает и поисковую выдачу, и людей с блокировщиками.

Как надо Прячь содержимое только при флаге, который ставит скрипт.

Выделенный тариф — отдельный компонент

Почему Дублируется вся разметка и стили. Правка обычного тарифа не попадает в выделенный.

Как надо Модификатор, меняющий переменную и пару свойств.

Смещение якоря сделано отступом у заголовка

Почему Невидимый отступ ломает расстояния между блоками и мешает при обычной прокрутке.

Как надо scroll-padding-block-start у html — свойство создано ровно для этого.

Полноширинный прорыв full-bleed
Блок выходит за колонку контента на всю ширину средствами сетки.
Отступ прокрутки scroll-padding
Запас, который браузер оставляет при переходе по якорю.
Липкое позиционирование sticky
Элемент в потоке, прилипающий к краю при прокрутке.
Аккордеон accordion
Набор раскрывающихся блоков, где открыт один. Делается атрибутом name у details.

Проверь себя

  1. Якорная ссылка прячет заголовок под липкой шапкой. Что использовать?

    Свойство создано ровно для этого. Отступы у заголовков ломают расстояния между блоками.

  2. Почему выделенный тариф лучше делать модификатором?

    Отдельный компонент означает два места для одной правки. Рано или поздно про одно забудут.

  3. Что обязательно при появлении блоков по скроллу?

    Иначе при ошибке скрипта или блокировщике страница останется пустой навсегда.

  4. Что делает одинаковый <code>name</code> у нескольких <code>&lt;details&gt;</code>?

    Раньше для этого писали скрипт. Где возможность не поддержана, блоки просто открываются независимо.

  5. Тень героя пропала после добавления <code>clip-path</code>. Почему?

    Всё за пределами формы исчезает. Тень под фигурной формой делают у родителя через <code>filter: drop-shadow()</code>.

Задание

Собрать первый экран лендинга

Собери герой лендинга: секция с градиентом и диагональным низом через clip-path, заголовок с балансировкой строк, подзаголовок с мерой не больше 50ch и кнопка-ссылка.

Следующая секция должна подъезжать под скос — задай ей отрицательный верхний отступ. Обязательно: видимая обводка фокуса у кнопки.

Открыть в песочнице
    Подсказка 1

    Скос: clip-path: polygon(0 0, 100% 0, 100% 88%, 0 100%);

    Подсказка 2

    Заголовку: text-wrap: balance; Подзаголовку: max-inline-size: 46ch;

    Подсказка 3

    Подтягивание: .after { margin-top: -26px; }

    Подсказка 4

    Фокус: .btn:focus-visible { outline: 3px solid #fff; outline-offset: 3px; }

    Показать решение
    HTMLindex.html
    <section class="hero">  <div class="hero__inner">    <h1 class="hero__title">Кофе, ради которого стоит выйти из дома</h1>    <p class="hero__lead">Свежая обжарка каждую неделю, тихий зал и розетки у каждого стола.</p>    <a class="btn" href="#book">Записаться</a>  </div></section><section class="after">  <p>Эта секция должна подъезжать под скос.</p></section>
    CSSstyle.css
    .hero {  padding: 44px 20px 68px;  color: #fff;  background: linear-gradient(135deg, #6544e0, #b06cff);  clip-path: polygon(0 0, 100% 0, 100% 88%, 0 100%);}.hero__inner { max-width: 640px; }.hero__title {  margin: 0 0 12px;  font-size: clamp(28px, 5vw, 44px);  line-height: 1.12;  text-wrap: balance;}.hero__lead {  margin: 0 0 24px;  max-inline-size: 46ch;  opacity: .9;}.btn {  display: inline-block;  padding: 12px 24px;  font-weight: 600;  color: #6544e0;  background: #fff;  text-decoration: none;  border-radius: 999px;}.btn:focus-visible {  outline: 3px solid #fff;  outline-offset: 3px;}.after {  margin-top: -26px;  padding: 32px 20px;  background: #f4f4f8;}.after p { margin: 0; }