Я собрал интерактивный сайт своей студии на Next.js: кастомный курсор, клавиши-кнопки, игра в утку и магазин с оплатой. Рассказываю, как повторить:

Я собрал интерактивный сайт своей студии на Next.js: кастомный курсор, клавиши-кнопки, игра в утку и магазин с оплатой. Рассказываю, как повторить:

Лонгрид для тех, кто хочет сделать не «ещё один лендинг», а сайт, по которому хочется кликать. Внутри — весь стек, куски кода и грабли, на которые я наступил.

Зачем всё это

Я делаю дизайн-студию wh4tislove. В какой-то момент стало понятно: если ты продаёшь дизайн, твой собственный сайт — это и есть главное портфолио. Очередной шаблон на Tilda тут не работает: клиент должен зайти и сразу почувствовать, что внутри сидят люди, которые умеют в детали.

Поэтому я поставил себе задачу: каждый элемент интерфейса должен быть «живым». Курсор — не системный, а нарисованный. Логотип — не картинка, а настоящие клавиши клавиатуры, которые продавливаются под мышкой. Клик — со звуком. На странице 404 — играбельная игра. И при этом под капотом — полноценный интернет-магазин с оплатой, доставкой и админкой.

Ниже — разбор по слоям, с кодом. Это не туториал «сделай по шагам», а скорее карта: что из чего состоит и почему сделано именно так.

главная страница
главная страница

Стек: что и почему

Никакой магии, всё довольно стандартно — магия в деталях реализации.

  • Next.js 14.2.3 (App Router) — серверные и клиентские компоненты, ISR (инкрементальная регенерация), динамические метаданные. Это каркас всего.
  • React 18 + TypeScript 5 (strict) — строгая типизация спасала меня десятки раз, особенно в игровой логике.
  • Supabase 2.43 — Postgres + хранилище файлов + авторизация. По сути, весь бэкенд за меня.
  • Zustand 4.5 — состояние корзины с сохранением в localStorage.
  • Inter (кириллица + латиница, веса 500/700/800) — основной шрифт. Плюс кастомный OTF на странице 404.
  • PM2 — менеджер процессов на сервере (next start, лимит памяти 512 МБ).

Почему Next.js, а не чистый React + Vite? Из-за SEO и серверного рендера товаров. Магазину критично, чтобы карточки товаров отдавались уже отрендеренными и индексировались. App Router + revalidate: 60 дают мне статику, которая сама обновляется раз в минуту, без полного передеплоя.

// next.config.js — разрешаем картинки из Supabase Storage const nextConfig = { images: { remotePatterns: [ { protocol: 'https', hostname: '**.supabase.co' }, { protocol: 'https', hostname: '**.supabase.in' }, ], }, };

Фишка №1: курсор, который я нарисовал сам

Первое, что замечает человек — курсор. На сайте нет ни одного системного курсора: всё нарисовано в виде пиксельных .cur-файлов (стрелка, «рука», текстовый бим, песочные часы, «запрещено» и т.д.), как в старой Windows XP.

Как это работает:

  1. Глобально в CSS я прячу нативный курсор на вообще всём:
html, body, *, *::before, *::after { cursor: none !important; }
  1. Поверх страницы висит невидимый <div> с набором заранее загруженных <img> — по одному на каждый тип курсора. Никаких подмен src на лету (это даёт мигание) — я просто переключаю display между готовыми картинками.
  2. Какой курсор показать — определяю по CSS-переменной --cur. Это красивый трюк: кастомные свойства наследуются как обычно и не ломаются от cursor: none !important.
a, button, [role="button"] { --cur: pointer; } input[type="text"], textarea { --cur: text; } [disabled], [aria-disabled] { --cur: no; }
  1. JS на каждом кадре смотрит, над каким элементом мышь, и поднимается по дереву в поисках --cur:
function detect(el: EventTarget | null): CursorType { let node = el instanceof Element ? el : null; while (node && node !== document.documentElement) { const v = getComputedStyle(node).getPropertyValue('--cur').trim(); if (v in CURSORS) return v as CursorType; // нашли тип — отдаём node = node.parentElement; // нет — идём к родителю } return 'default'; }

Грабли, которые стоили мне нервов

Курсор начинал моргать на дефолтный во время движения. Причина оказалась хитрой: я запоминал event.target на mousemove, а реально применял курсор уже в следующем кадре через requestAnimationFrame. За эти миллисекунды React успевал перерисовать DOM (например, добавлял всплывающее сердечко-эмодзи), и сохранённая ссылка указывала уже «не туда».

Решение — не доверять старому таргету, а спрашивать DOM заново прямо в кадре отрисовки:

rafId = requestAnimationFrame(() => { frameGen++; // новое поколение кэша const el = document.elementFromPoint(pendingX, pendingY); // живой DOM applyType(detect(el), pendingX, pendingY); });

Плюс пришлось убить нативный драг картинок (dragstart → preventDefault), иначе ОС показывала свой курсор поверх моего при попытке «потащить» изображение.

Я собрал интерактивный сайт своей студии на Next.js: кастомный курсор, клавиши-кнопки, игра в утку и магазин с оплатой. Рассказываю, как повторить:

Фишка №2: логотип — это настоящие клавиши клавиатуры

На главной вместо обычного логотипа — две клавиши с надписями wh4tislove ⏎ и <3. Они выглядят объёмными и продавливаются при нажатии, как на механической клавиатуре.

Сначала я пробовал 3D на CSS-трансформах — выглядело пластиково и неправильно. В итоге пришёл к тому, что клавиша рисуется как плоский SVG с нарисованным объёмом: четыре трапециевидные «стенки» по краям + верхняя грань с градиентом + текстура шума.

const BL = 24, BR = 24, BT = 13, BB = 32; // толщина фасок: лево/право/верх/низ const W = BL + tw + BR; // полная ширина const H = BT + th + BB; // полная высота // четыре грани-полигона создают эффект объёма const top = `0,0 ${W},0 ${ix1},${iy0} ${ix0},${iy0}`; const right = `${W},0 ${W},${H} ${ix1},${iy1} ${ix1},${iy0}`; const bottom = `${W},${H} 0,${H} ${ix0},${iy1} ${ix1},${iy1}`; const left = `0,${H} 0,0 ${ix0},${iy0} ${ix0},${iy1}`;

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

<polygon points={top} fill="#f4f4f4" /> // верх — самый светлый <polygon points={left} fill="#dcdcdc" /> <polygon points={right} fill="#c4c4c4" /> <polygon points={bottom} fill="#a6a6a6" /> // низ — самый тёмный

Самая важная мелочь, которая делает клавишу «дорогой» — матовая текстура зерна. Это SVG-фильтр feTurbulence (фрактальный шум), обесцвеченный и наложенный с прозрачностью 9% в режиме multiply:

<filter id={grainId}> <feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch" seed={...} /> <feColorMatrix type="saturate" values="0" /> </filter>

Сам текст на клавише — это PNG-глиф, отрисованный заранее в нужном шрифте и вставленный через . Так я получил идеальный контроль над тем, как выглядит надпись, без возни с рендером шрифта в SVG.

Нажатие — чистый CSS:

.keycap:not(.keycap-static):active .keycap-svg { transform: translateY(5px) scale(0.985); /* клавиша «утопает» */ filter: drop-shadow(0 5px 6px rgba(0,0,0,0.22)); /* тень становится короче */ }

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

.kb-logo { --s: 0.72; } @media (max-width: 768px) { .kb-logo { --s: 0.5; } } @media (max-width: 420px) { .kb-logo { --s: 0.4; } }

Тот же компонент Keycap я переиспользовал на странице ссылок (клавиша @me) и на 404 (клавиши nice и try., сложенные по диагонали).

Я собрал интерактивный сайт своей студии на Next.js: кастомный курсор, клавиши-кнопки, игра в утку и магазин с оплатой. Рассказываю, как повторить:
Я собрал интерактивный сайт своей студии на Next.js: кастомный курсор, клавиши-кнопки, игра в утку и магазин с оплатой. Рассказываю, как повторить:

Бонус: реакции, как в инстаграме

При клике по клавише <3 снизу вылетает сердечко и улетает вверх — одно нажатие, одно сердечко. Вынес в крошечный хук:

export default function useFloatingEmoji(ttl = 1000) { const [items, setItems] = useState<{ id: number; x: number }[]>([]); const spawn = useCallback(() => { const id = _emojiId++; const x = (Math.random() - 0.5) * 36; // случайный разброс ±18px setItems(l => [...l, { id, x }]); setTimeout(() => setItems(l => l.filter(i => i.id !== id)), ttl); }, [ttl]); return { items, spawn }; }

На 404 тот же хук плюёт клоунами 🤡, на странице ссылок — жестом «сердечко руками» 🫶🏻. Анимация — @keyframes с подъёмом и затуханием.

Фишка №3: звук клика через Web Audio

У каждого клика — короткий механический «щелчок». Звучит просто, но за этим стоит несколько решений.

Обычный <audio>.play() тормозит и не справляется с быстрыми кликами. Поэтому — Web Audio API: я заранее скачиваю байты mp3, а декодирую в аудиобуфер только при первом жесте пользователя (до жеста браузер всё равно держит AudioContext в suspend):.play() тормозит и не справляется с быстрыми кликами. Поэтому — Web Audio API: я заранее скачиваю байты mp3, а декодирую в аудиобуфер только при первом жесте пользователя (до жеста браузер всё равно держит AudioContext в suspend):

// сразу качаем байты — AudioContext ещё не нужен fetch('/sounds/click.mp3').then(r => r.arrayBuffer()) .then(ab => { clickBytes.current = ab; }); // декодируем один раз, лениво, при первом клике async function decodeIfNeeded(ac: AudioContext) { if (!clickBuf.current && clickBytes.current) { clickBuf.current = await ac.decodeAudioData(clickBytes.current.slice(0)); } }

Десктоп vs мобайл

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

const isTouch = matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; const INTERACTIVE = 'a, button, [role="button"], input, .keycap, [data-click-sound]'; const onGesture = (e) => { const isPrimary = isTouch ? e.type === 'touchstart' : e.type === 'mousedown'; if (!isPrimary) return; // защита от двойного срабатывания const shouldClick = isTouch ? !!e.target.closest(INTERACTIVE) // мобайл: только по кнопкам : true; // десктоп: всегда if (shouldClick) playBuf(ac, clickBuf.current, 0.85); };

Отдельная история — звук появления cookie-попапа. Если AudioContext ещё «спит» (пользователь не кликал), звук откладывается и проигрывается на следующем жесте. Мелочь, а ощущается цельно.

Фишка №4: на странице 404 живёт игра про утку

Самое весёлое. Вместо скучного «страница не найдена» — играбельный клон динозаврика из Chrome, только с уткой. Появляется по нажатию пробела.

Это canvas-игра целиком на JS, 31 КБ кода. Никаких готовых спрайтов — утка, птицы, облака рисуются попиксельно прямо в коде, что позволяет на лету перекрашивать палитру (например, для ночного режима).

Базовая физика — обычный игровой цикл на 60 кадрах:

const GRAVITY = 0.58, JUMP_V = -13.5; const SPEED0 = 5, SPEED_MAX = 16; function updateState(s, W) { s.speed = Math.min(SPEED_MAX, SPEED0 + Math.pow(s.score, 0.75) * 0.18); // разгон if (!s.onGround) { s.dogVY += GRAVITY; // притягиваем вниз s.dogY += s.dogVY; if (s.dogY >= GROUND - DOG_H) { // приземлились s.dogY = GROUND - DOG_H; s.onGround = true; s.jumpsLeft = 2; // двойной прыжок } } }

Что я туда впихнул:

  • Двойной прыжок и приседание под летящими птицами.
  • Три жизни с мерцанием-неуязвимостью после удара.
  • Ночной режим: каждые 1500 очков на 500 очков палитра инвертируется (тёмный фон, светлая утка).
  • Таблица рекордов: счёт улетает в Supabase через /api/scores, топ-5 подтягивается обратно.
  • Управление и с клавиатуры, и пальцем: на мобильном вся область до нижней кнопки — одна большая зона тапа для прыжка.

Сложность растёт по степенному закону (Math.pow(score, 0.75)), а не линейно — так в начале комфортно, а к 1000+ очков уже пот градом.

Я собрал интерактивный сайт своей студии на Next.js: кастомный курсор, клавиши-кнопки, игра в утку и магазин с оплатой. Рассказываю, как повторить:

Пасхалки: то, что ищут гики

Раз уж сайт про «поиграть», я насыпал секретов.

Konami Code (↑↑↓↓←→←→BA) запускает дождь из уток по всему экрану с кряканьем:

const seq = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown', 'ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a']; let idx = 0; const onKey = (e) => { const key = e.key.length === 1 ? e.key.toLowerCase() : e.key; idx = key === seq[idx] ? idx + 1 : (key === seq[0] ? 1 : 0); if (idx === seq.length) { idx = 0; setDuckRain(true); } };

DVD-заставка: по сайту летает бьющееся сердечко <3, как логотип DVD на старых телевизорах. А когда оно попадает точно в угол — взрыв конфетти из 28 разлетающихся квадратиков:

if (bounceX && bounceY) { // одновременный отскок = угол setBursts(b => [...b, { id: ++burstId.current, x: cx, y: cy }]); }

Мелочь, но именно такие штуки люди скриншотят и кидают друзьям.

Бэкенд: магазин, который реально продаёт

За всей этой игровой обёрткой — полноценный интернет-магазин.

Supabase держит всё: товары, варианты (размеры/наличие), портфолио-кейсы, заявки на коллаборации, предзаказы, рекорды игры, настройки сайта. Например, таблица товаров:

CREATE TABLE products ( id BIGSERIAL PRIMARY KEY, slug TEXT UNIQUE, -- человекочитаемый URL name TEXT, price INT, image_url TEXT, material TEXT, preorder_mode BOOLEAN DEFAULT false );

Страницы товаров — серверные компоненты с revalidate: 60. Это значит: отредактировал товар в админке → через минуту сайт сам обновил статику, без передеплоя. После правок в админке я ещё руками дёргаю revalidatePath('/products'), чтобы изменения подхватились мгновенно.

Корзина на Zustand с интересным нюансом — она уважает cookie-согласие. Пока человек не принял cookies, корзина живёт только в памяти и не пишется в localStorage:

const cookieAwareStorage = { setItem: (name, value) => { if (localStorage.getItem('cookieConsent') === 'accepted') { localStorage.setItem(name, JSON.stringify(value)); } }, }; export const useCartStore = create()(persist(/* ... */, { name: 'whatislove-cart-storage', storage: createJSONStorage(() => cookieAwareStorage), }));

Оплата — через ЮKassa (/api/yookassa/create-payment), данные заказа едут в metadata платежа и читаются вебхуком. Доставка — CDEK и Яндекс, цена считается по городу (Москва/Питер дешевле, миллионники дороже, остальное — фикс). После оплаты Telegram-бот шлёт мне и клиенту опрятное подтверждение заказа.

Админка и хитрое сжатие картинок

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

Самое любопытное там — загрузка картинок с бинарным поиском по качеству. Пользователь грузит фото 12 МБ, а мне нужно уложиться в лимит. Вместо «сожми до 80% и надейся» я подбираю максимально возможное качество JPEG, которое влезает в заданный размер, за ≤8 итераций:

const step = (lo, hi, i) => { if (i > 8) return resolve(bestBlob); const mid = (lo + hi) / 2; canvas.toBlob(b => { if (b.size <= maxBytes) { bestBlob = b; lo = mid; } // влезло — поднимаем планку else hi = mid; // не влезло — опускаем if (hi - lo < 0.02) return resolve(bestBlob || b); step(lo, hi, i + 1); }, 'image/jpeg', mid); };

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

SEO: чтобы находили по нужным запросам

Магазин без SEO — деньги в трубу. Что сделано:

  • Динамические метаданные на каждый товар через generateMetadata.
  • Точечные SEO-оверрайды для конкретных товаров. Например, для кольца <3 я вручную прописал заголовок, описание и ключи под живые поисковые запросы:
const SEO_OVERRIDES = { 'ring-heart': { title: 'Кольцо <3 «меньше чем три» — сердечко из стали | WH4T!SLOV3', keywords: ['кольцо <3', 'меньше чем три', 'кольцо сердечко', /* ... */], }, };
  • Динамический sitemap.xml — собирается из Supabase, в него автоматически попадают все товары и кейсы.
  • robots.txt закрывает /api/, /checkout/, /order/.
  • Open Graph картинка 1200×630 для красивых превью в мессенджерах.
  • Яндекс.Метрика с ecommerce-трекингом.

Деплой: важный нюанс про «не вижу изменений»

Сайт крутится не на Vercel-автодеплое, а на своём сервере через PM2:

// ecosystem.config.js module.exports = { apps: [{ name: 'whatislove', script: 'node_modules/.bin/next', args: 'start', max_memory_restart: '512M', env: { NODE_ENV: 'production', PORT: 3000 }, }], };

Тут меня ждала очевидная задним числом ловушка: пуш в git сам по себе ничего не деплоит. Чтобы изменения появились вживую, на сервере нужно:

git pull && npm run build && pm2 reload whatislove

Если хочется автодеплой — вешается GitHub Action, который по пушу заходит на сервер по SSH и делает то же самое. Но для пет-проекта ручной reload меня полностью устраивает.

Что я понял по дороге

Несколько выводов, если соберётесь делать похожее:

Сайт получился не «лендингом», а маленьким аттракционом, который при этом продаёт. Если делать каждый элемент чуть живее, чем принято — суммарный эффект получается несоразмерно большим.

Статья полностью написана клод кодом по моему сайту. Чтобы воспользоваться этими "плюшками" —предлагаю вам скормить эту статью напрямую вашему ИИ другу, пишущему код. Там он найдёт множество уже налаженных механик, которые реализовать самому с первого раза достаточно проблематично.
моя студия — wh4tislove.ru
мой канал — t.me/whatislove_r
(там и познакомимся поближе!)