
Задача
Одностраничник для магазина игрушек в «наклеечном» стиле: толстый чернильный контур, пастельные заливки, жёсткая тень вместо размытой. Чистая вёрстка на HTML, CSS и JS, без библиотек и без сборки.
Как делал
Графика нарисована стилями, без единой картинки: лица на карточках — два radial-gradient и ::after без верхней грани, горошек по фону — radial-gradient на position: fixed, разделители секций — повторяющийся radial-gradient. Кляксы в первом экране с неправильным радиусом 44% 56% 62% 38% / 50% 44% 56% 50%, из-за чего капли не выглядят кругами.
Покачивание сделано через @keyframes, где элемент девяносто процентов цикла неподвижен и дёргается только в конце. Постоянное движение утомило бы, а короткий рывок раз в несколько секунд читается как живой объект. Фазы соседних карточек разведены через инлайн-переменные calc(var(--wob) * .35s), а на :hover анимация ставится на паузу, чтобы не складываться с трансформацией.
.polka {
position: fixed;
inset: 0;
background-image: radial-gradient(rgba(58,47,76,.09) 2px, transparent 2.5px);
background-size: 42px 42px;
}
.wave--top {
background-image: radial-gradient(circle at 30px 30px, var(--cream) 30px, transparent 31px);
background-size: 120px 60px;
background-repeat: repeat-x;
}
.whycard__face {
background:
radial-gradient(circle at 20px 22px, var(--ink) 0 4px, transparent 4.5px),
radial-gradient(circle at 36px 22px, var(--ink) 0 4px, transparent 4.5px),
var(--paper);
}
.whycard__face::after { /* улыбка: рамка без верхней грани */
content: "";
width: 18px; height: 9px;
border: 3px solid var(--ink);
border-top: none;
border-radius: 0 0 18px 18px;
}
.cat {
animation: cat-wobble 5s ease-in-out infinite;
animation-delay: calc(var(--wob) * .35s); /* своя фаза у каждого */
}
.cat:hover { animation-play-state: paused; } /* иначе hover складывается с keyframes */
@keyframes cat-wobble {
0%, 100% { transform: rotate(0) }
90% { transform: rotate(0) } /* 4.5 секунды тишины на цикле в 5s */
92% { transform: rotate(-2deg) }
94% { transform: rotate(2deg) }
96% { transform: rotate(-1deg) }
}
.reveal {
opacity: 0;
transform: translateY(30px) scale(.96);
transition:
opacity .7s cubic-bezier(.16, 1, .3, 1), /* точный expo-out */
transform .7s cubic-bezier(.34, 1.56, .64, 1); /* пружина с перелётом */
transition-delay: var(--d, 0s);
}
const io = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("in-view");
io.unobserve(entry.target); // иначе перезапустится при возврате в зону
}
});
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
);
revealEls.forEach((el, i) => {
el.style.setProperty("--d", `${Math.min(i % 4, 3) * 0.08}s`);
io.observe(el);
});
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
.reveal { opacity: 1; transform: none; } /* иначе блок остался бы пустым */
}
Итог
Лендинг работает на трёх файлах без зависимостей. Всё оформление — градиенты, контуры и тени, интерактив — vanilla JS. Анимации не требуют внимания и корректно отключаются при prefers-reduced-motion, где контент возвращается в видимость.
