
Задача
Одностраничник для пекарни на трёх файлах. Тёплая кремово-шоколадная палитра, рукописный Marck Script для акцентов и Nunito для текста — и ощущение, будто страницу сняли на плёнку, а не отрисовали.
Как делал
Зернистая плёнка — не картинка и не готовый PNG, а SVG-фильтр feTurbulence с фрактальным шумом, вшитый в CSS как data:image/svg+xml. Он растянут на весь экран через position: fixed с pointer-events: none, лежит над контентом на z-index: 9999 и стоит на opacity: 0.06 — едва различимое зерно поверх всей вёрстки.
.grain {
position: fixed;
inset: 0;
z-index: 9999; /* поверх всего */
pointer-events: none;
opacity: 0.06;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}
Параллакс на фотографиях первого экрана сделан вручную и с разной скоростью: большое фото едет медленнее (0.06), маленькое — быстрее (0.1), за счёт чего они расходятся в глубину. Дальше 900 пикселей скролла обработчик выходит сразу и больше не пишет в стили.
window.addEventListener("scroll", () => {
const y = window.scrollY;
if (y > 900) return; // за фолдом фотографий уже не видно — не трогаем DOM
heroPhotos.forEach((p, i) => {
const shift = y * (i === 0 ? 0.06 : 0.1); // разные скорости = глубина
p.style.transform = `translateY(${shift}px)`;
});
}, { passive: true });
Бегущая строка с блюдами — два одинаковых набора в одном треке, сдвиг на translateX(-50%). Треку обязателен width: max-content, иначе он схлопнется по ширине контейнера и половина сдвига перестанет совпадать с шириной набора.
.marquee__track {
display: flex;
gap: 36px;
white-space: nowrap;
width: max-content; /* без этого translateX(-50%) не попадёт в шов */
animation: marquee 30s linear infinite;
}
@keyframes marquee {
to { transform: translateX(-50%); }
}
Маленькое фото налезает на большое с наклоном и рамкой в цвет фона: border: 6px solid var(--cream) работает как матовая паспартушка, благодаря ей снимок выглядит наклеенным поверх другого, а не вставленным в сетку.
.hero__photo--small {
width: 44%; height: 260px;
bottom: 0; right: 0;
border: 6px solid var(--cream); /* рамка-подложка, «наклейка» поверх большого фото */
transform: rotate(3deg);
}
Каскад появления блоков, как и в остальных лендингах, считается в JS по индексу — но с шагом в три элемента и задержкой 0.09 секунды, потому что сетка меню в четыре колонки и каскад на четыре позиции растягивал бы низ секции на полсекунды.
revealEls.forEach((el, i) => {
el.style.setProperty("--d", `${Math.min(i % 3, 2) * 0.09}s`);
io.observe(el);
});
Итог
Лендинг на трёх файлах без зависимостей: HTML, CSS и семьдесят три строки JS. Плёночное зерно, разводы карамели, рукописный шрифт и наклонные штампы — всё сделано стилями, внешних картинок в оформлении нет, фотографии только из Unsplash.