Проект: лендинг
Собираем страницу целиком: герой с диагональю, липкая навигация, карточки тарифов, вопросы без скриптов и появление при скролле.
Что ты научишься делать
- Соберёшь лендинг из готовых компонентов, а не экран за экраном
- Сделаешь липкую навигацию, которая не мешает якорям
- Добавишь появление при скролле, уважающее спокойный режим
#Что собираем
Лендинг кофейни: пять экранов, одна цель — записаться на дегустацию. Всё на чистом CSS, ни одной библиотеки.
- Липкая навигация, появляющаяся после первого экрана
- Герой с диагональным низом и текстом поверх градиента
- Три карточки преимуществ с ровными кнопками
- Тарифы, где средний выделен
- Вопросы и ответы без единой строки скрипта
- Появление блоков при прокрутке
#Шаг 1. Токены и каркас
Начинаем не с шапки, а с системы. Двадцать строк, которые определят весь остальной код.
@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: без него якорные ссылки прячут заголовок под шапкой.
@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. Тарифы
Три карточки, средняя выделена. Выделение — модификатор, а не отдельный компонент: меняются только цвета и масштаб.
@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. Вопросы без скриптов
#Шаг 7. Мобильное меню
На узком экране навигация не помещается. Обычный путь — выезжающая панель по кнопке, и здесь легко потерять доступность: панель открывается, а фокус остаётся на странице под ней.
Самый честный вариант без скриптов — не прятать меню вовсе, а превратить его в горизонтальную ленту с прокруткой. Ссылки остаются видимыми и доступными, ничего не ломается.
@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. Форма записи
Целевое действие всего лендинга. Здесь применяется всё из сорок четвёртого урока: подписи, отложенная проверка, подсказки для мобильной клавиатуры.
Одна деталь именно для лендинга: полей должно быть минимально возможное число. Каждое лишнее поле заметно снижает долю тех, кто дойдёт до конца. Имя и телефон — обычно достаточно, остальное спрашивают при звонке.
@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. Появление при скролле
База — рабочая страница без анимации. Улучшение — плавное появление. Порядок именно такой: если скрипт не выполнится, содержимое обязано остаться видимым.
@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.
Проверь себя
Свойство создано ровно для этого. Отступы у заголовков ломают расстояния между блоками.
Отдельный компонент означает два места для одной правки. Рано или поздно про одно забудут.
Иначе при ошибке скрипта или блокировщике страница останется пустой навсегда.
Раньше для этого писали скрипт. Где возможность не поддержана, блоки просто открываются независимо.
Всё за пределами формы исчезает. Тень под фигурной формой делают у родителя через <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; }
Показать решение
<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>.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; }