Бизнес-логика
Концепт «Kinetic dark cinematic», типографика на Fraunces, разбор анимаций framer-motion, архитектура страницы, мобильная адаптация и стек.
TL;DR
Свадебное агентство в Грузии делало сайт под русскоязычный рынок: Россия, Израиль, Украина, Германия. Конкуренты сидят на Wix/Tilda/Weblium со светлой розовой палитрой и каллиграфическим скриптом — типовая «свадебная вёрстка». Я пошёл строго в обратную сторону: тёплый чёрный, винтажное золото, переменный Fraunces serif, кинематографическая плёнка-grain, framer-motion со SplitText, ParallaxImage и Ken Burns. Многостраничник на Next.js 16 + Tailwind 4 + framer-motion 12, статический экспорт, RU/EN i18n, галерея на 172 фото с лестницей srcSet. Деплой — собственный VPS, nginx, Cloudflare.
Контекст и аудитория
Грузия для русскоязычных пар стала «новым Вегасом» после 2022-го: документы оформляются за 1–2 дня, брак признаётся в 180+ странах, нет требований к гражданству, переводчик обходится в копейки. Целевой клиент — пара 25–35 лет из крупного города (Москва, Тель-Авив, Берлин, Киев), которая хочет камерную свадьбу в горах или у моря, без показухи, но с фото-историей и официальным свидетельством.
Чужие свадебные сайты в Грузии (Best Day, Wedding in Georgia, Romantic Tbilisi) сделаны на конструкторах. Они одинаковые: белый фон, мятный или розовый акцент, slick-слайдер, формы из шаблонов. Я разбирал их секциями параллельно с дизайном — и каждое решение принималось ровно как «не как у них».
Концепция: Kinetic dark cinematic
Заголовок концепции — мой внутренний рабочий ярлык, который потом перешёл в design-system заметки. «Kinetic» — движение по странице должно ощущаться как кадры в кино, не как карточки в каталоге. «Dark» — фон тёмный, не белый. «Cinematic» — поверх всего лёгкая плёнка, фото с медленным Ken Burns, типографика с italic-вставками как в журнальной вёрстке.
Три ключа концепта:
- Палитра-перевёртыш. Зелёного нет вообще — он занят конкурентами. Розового нет — слишком очевидно. Используются только тёплый чёрный (три уровня иерархии), ivory-текст и винтажное золото.
- Типографика как герой. Fraunces вытянут в crazy-big sizes (до 192 px на 4K). На hero нет фона-фото с заголовком поверх — заголовок занимает всю площадь сам, по гэдиэру вместо рассеянного «свадебный коллаж».
- Движение как монтаж. Анимации появляются один раз, не «дрожат», не «прыгают». Каждый эффект имеет уважение к
prefers-reduced-motion: reduce— fallback к плоскому fade.
Палитра
Из app/globals.css @theme — цветовые токены:
| Токен | HEX | Роль |
|---|---|---|
--color-base | #1A1714 | глубокий тёплый чёрный, фон body |
--color-surface | #242019 | слой поверх base — секции с альтернативным фоном |
--color-surface-elevated | #2E2822 | третий уровень — карточки внутри секций |
--color-warm-black | #0F0D0A | глубже base, для фокусных секций (FAQ, GalleryPreview) |
--color-text-primary | #F0E8DC | warm ivory — основной текст. Не чистый белый, чтобы не «жечь» глаза на больших экранах |
--color-text-secondary | #A09484 | вторичный текст (subtitle, description) |
--color-text-muted | #6B5F52 | капшен, мета, footnotes |
--color-gold | #C9A96E | signature accent — CTA, ссылки, чекмарки. Спокойное винтажное золото, не металлик |
--color-gold-light | #D9BE8A | hover state на gold кнопках |
--color-gold-dim | #8A7548 | dimmed gold — scrollbar, второстепенные акценты |
--color-wine | #8B3A3A | редкие эмоциональные акценты (wedding mood) |
Три уровня тёмного — base → surface → warm-black — дают иерархию и ритм при чередовании секций. Глаз читает страницу как фильм со сменой сцен.
Типографика
Два шрифта, оба грузятся через next/font/google без render-blocking и без FOUT:
- Fraunces — переменный serif с богатой italic-пластикой. Веса 300–800, стили normal + italic. Используется для всех заголовков (h1–h6) и для редких акцентных слов внутри текста.
- Work Sans — геометрический sans для body. Веса 300–600. Default font-weight
300(light) — благородство, не «технологическое».
Размеры — fluid через clamp(), без @media-запросов:
.text-hero { font-size: clamp(3rem, 12vw, 12rem); line-height: 0.9; }
.text-scene { font-size: clamp(2.5rem, 6vw, 6rem); line-height: 0.95; }
На 4K-десктопе hero — 192 px, на iPhone — 48 px. Никаких прыжков по медиа-точкам, плавный таяние от устройства к устройству. line-height: 0.95 на h1–h6 — заголовки прижаты, дают «зерно». На body — 1.7, щедрый воздух для долгого чтения.
Паттерн акцента в заголовке — font-light для основной массы + italic font-semibold на одном слове:
<h2 className="text-scene font-light">
Где проходят <span className="italic font-semibold">наши церемонии</span>
</h2>
Это даёт сразу две вещи: ритм внутри заголовка (вес меняется) и характер Fraunces (italic-вариант реально другая пластика, не просто наклон).
Кинематографические эффекты
Film grain
Постоянная плёночная фактура поверх всего сайта — через body::after:
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 40;
opacity: 0.025;
background-image: url("data:image/svg+xml,...turbulence baseFrequency=0.9 4 octaves...");
mix-blend-mode: overlay;
}
SVG-noise рендерится один раз, mix-blend-mode выполняется на GPU — никакого хита по performance. Зато с первого кадра ощущение фото-печати, а не «вёрстки».
Ken Burns
Медленный zoom 1.0 → 1.05 за 10s infinite alternate — на hero и фоновых фото локаций:
@keyframes kenBurns {
0% { transform: scale(1); }
100% { transform: scale(1.05); }
}
.ken-burns { animation: kenBurns 10s ease-in-out infinite alternate; }
@media (prefers-reduced-motion: reduce) {
.ken-burns { animation: none; }
}
10 секунд — медленнее обычного, чтобы движение не отвлекало от чтения. На странице видна одна фаза — пользователь не успевает понять, что фото движется. Но при возврате на главную после долгой сессии — обнаруживает, что фон «другой».
Custom scrollbar
6 px, gold-dim. Деталь, которая выдаёт уровень внимания:
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-thumb { background: var(--color-gold-dim); border-radius: 3px; }
Анимации (framer-motion v12)
Восемь motion-компонентов в components/MotionWrappers.tsx, components/SplitText.tsx, components/ParallaxImage.tsx, components/PageTransition.tsx. Все они держат три инварианта:
useHydratedhook — на server-render и до hydration возвращают плоский HTML без motion-обёртки. Исключает FOUC и hydration mismatch (React 19 гидрация строгая).useReducedMotion— для motion-sensitive пользователей всё сводится к простому opacity-fade без translate, scale, rotate. Не «никаких анимаций», а тонкий fade — пользователь не теряет контекст.viewport={{ once: true, margin: "-100px" }}— анимация срабатывает один раз на 100 px до полного попадания в viewport. Не «разанимируется» при прокрутке вверх.
| Компонент | Эффект | Длительность · Easing |
|---|---|---|
FadeIn | opacity + translateY(30px) | 0.7s · easeOut |
FadeInUp | opacity + translateY(60px) (драматичнее) | 0.8s · easeOut |
ScaleReveal | opacity + scale(0.6 → 1) (для галереи) | 1s · easeOut |
StaggerContainer / StaggerItem | staggerChildren: 0.15 + child translateY 30 | 0.6s child |
SplitText (splitBy="word") | per-word: opacity + translateY 40 + rotateX(-40° → 0°), stagger 0.06s | 0.5s per word · cubic-bezier(0.25, 0.46, 0.45, 0.94) |
SplitText (splitBy="letter") | то же по буквам, stagger 0.03s | как выше |
ParallaxImage | useScroll + useTransform → translateY ±10% от scrollYProgress | непрерывно |
PageTransition | AnimatePresence mode="wait" на pathname, opacity 0 → 1 | 0.3s · easeInOut |
| Hero scroll indicator | infinite y: [0, 8, 0] | 2s · easeInOut |
Самый узнаваемый эффект — SplitText на h1 hero. Слова падают снизу и поворачиваются из rotateX -40° (буквы как «листают» на корешке). Cubic-bezier для natural movement — ни bounce, ни linear:
const itemEase = [0.25, 0.46, 0.45, 0.94] as const;
const charVariants = {
hidden: { opacity: 0, y: 40, rotateX: -40 },
visible: { opacity: 1, y: 0, rotateX: 0,
transition: { duration: 0.5, ease: itemEase } },
};
Архитектура страницы
Главная — 9 контентных секций + Header + Footer. DOM-инспекция wedingeorgia.com подтверждает структуру:
- Header (fixed,
bg-base/60 backdrop-blur-md, z-50) — лого WedInGeorgia + EN switcher + бургер - Hero (
min-h-screen) — ParallaxImage с overlay-градиентом,<SplitText>h1 «Ваша идеальная свадьба в Грузии», subtitle FadeIn delay 0.5, CTA-кнопки FadeIn delay 0.7, scroll-indicator - TrustBar — статистика (свадеб / лет опыта / стран признают / рейтинг)
- Packages (
#packages) — 5 ценовых карточек от 390$ до 2990$ - WhyGeorgia — преимущества Грузии, 4 карточки с иконками
- Locations (
#locations) — 6 локаций сеткой с overlay-фото - GalleryPreview (
bg-warm-black) — 8 фото-превью + ссылка на /gallery - Reviews (
#reviews) — отзывы реальных пар, имя + город + дата - FAQ (
#faq,bg-warm-black) — 8 вопросов, аккордеон - Documents (
#documents) — таблица документов по статусам (первый брак / повторный / вдовец / иностранцы) - Contacts (
#contacts,min-h-screen) — форма + блок контактов - Footer
Чередование bg-base / bg-surface / bg-warm-black между секциями даёт ритм. Глаз читает страницу как главы — каждая со своим оттенком темноты.
Внутренние страницы
Помимо главной — 10 отдельных routes: /gallery, /blog, /contacts, /documents, /faq, /locations + /locations/{city} (городские страницы), /reviews, /services, /privacy, /en (полная локализация).
Самая сильная по показу — /gallery. Она показывает масштаб портфолио: 172 webp-фото с лестницей srcSet="...640w, ...1024w, ...1920w", masonry на CSS columns, lightbox через Swiper и плавным crossfade. Manifest со всеми фото и подписями лежит в public/images/portfolio/manifest.json — отдельный JSON, который грузится один раз при открытии галереи.
/locations/{city} — отдельные landing-странички под Тбилиси, Батуми, Казбеги. Та же структура: hero с фото города, описание, пакеты (те же 5), «как добраться», FAQ, контакты. Это даёт 6× SEO-входов (по числу городов) при том же базовом контенте, написанном один раз и переиспользованном через шаблон.
Mobile-адаптив
Fluid clamp() для всех заголовков (без @media-запросов) — сетка перестраивается через Tailwind утилиты:
| Элемент | Desktop | Mobile |
|---|---|---|
| Hero | min-h-screen, h1 192 px | min-h-screen, h1 ~48 px (clamp) |
| Packages | grid-cols-5 (5 в ряд) | snap-x scroll свайп карточек |
| WhyGeorgia | grid-cols-4 | grid-cols-1 стек |
| Locations | grid-cols-3 | grid-cols-2 |
| Gallery preview | masonry 4 col | masonry 2 col |
| Form fields | inline labels | full-width stacked |
Hero на мобиле сохраняет min-h-screen — это эффектный момент, фуллскрин и на iPhone. CTA-кнопки переходят из горизонтального ряда в вертикальный стек.
Стек и почему так
| Технология | Почему |
|---|---|
| Next.js 16 | Latest. App Router. output: "export" — чистая статика без Node-сервера на проде, экономия на хостинге. |
| TypeScript 6 | strict-mode. Типы для frontmatter, manifest, content props. |
| Tailwind CSS 4 | @theme tokens напрямую в globals.css, без отдельного config-файла. Быстрая сборка, меньше boilerplate. |
| framer-motion 12 | Стандарт для React-анимаций. Зрелый API, useReducedMotion, hydration-safe паттерны. |
| Swiper 12 | Свайп-карусели для отзывов и mobile-пакетов. Touch-friendly, momentum scrolling. |
| next/font/google | Fraunces + Work Sans — сабсетятся, никакого FOUT, нет render-blocking @import. |
output: "export" — критичное решение. Сайт-визитка для агентства, нет персонализации, нет CMS. Статика отдаётся nginx прямо, никакого серверного кода. Это даёт:
- Lighthouse-показатели 95+ из коробки
- Деплой одной командой
npm run build && rsync out/ vps:/var/www/wedingeorgia - Никакого Node-сервера → меньше attack surface, меньше затрат на VPS
- CDN-friendly (Cloudflare кеширует всё)
Что было сложным
1. Hydration без flicker
React 19 + framer-motion 12 в production build строго проверяют гидрацию. Если server-render отдал <motion.div style={{ y: -42 }}>, а client первый рендер — <motion.div style={{ y: 0 }}>, React выбрасывает hydration mismatch, и до пользователя долетает FOUC.
Решение — useHydrated хук. На server и в первый client-рендер возвращает false, после первого useEffect — true. Каждый motion-компонент рендерит:
const hydrated = useHydrated();
if (!hydrated) {
// Plain HTML — same on server and first client render
return <div className={className}>{children}</div>;
}
// Motion version
return <motion.div ...>{children}</motion.div>;
Это даёт идентичный markup на сервере и в первом клиент-рендере. После useEffect хук переключается, motion-обёртка появляется в requestAnimationFrame — пользователь не видит «прыжка».
2. Fluid типографика без media-queries
Стандартный путь — text-2xl md:text-4xl lg:text-6xl. Это рабочие, но прыгают на breakpoint-ах. На устройствах между breakpoint-ами (например 920 px) шрифт вдруг меняется на одном пикселе.
Решение — clamp(min, prefer, max) с vw-единицей в середине: clamp(3rem, 12vw, 12rem). На любой ширине шрифт плавно интерполируется. Утилитарные классы .text-hero, .text-scene лежат в globals.css, прокидываются через Tailwind @theme.
3. Performance + film-grain
SVG-noise через body::after теоретически может тормозить отрисовку. Решено двумя вещами:
position: fixed+inset: 0— фиксированный layer, не перерисовывается при scrollmix-blend-mode: overlay+pointer-events: none— выполняется на GPU compositor layer- SVG inline в data-URL — нет HTTP-запроса
В DevTools Performance tab — body::after не появляется в paint-event-ах при скролле.
4. i18n со static export
output: "export" несовместим с middleware и rewrite-rules — нет рантайма. Решение — генерировать routes физически:
app/
page.tsx → /
gallery/page.tsx → /gallery
en/
page.tsx → /en
gallery/page.tsx → /en/gallery
<LocaleContext> (React Context) держит текущий locale, утилита usePrefix() возвращает префикс для ссылок ("" для ru, "/en" для en). Шрифты — оба латиница + extended — покрывают и кириллицу, и английский.
5. 172 фото в галерее
Прямое решение «один компонент — массив src-ов» не масштабируется. Сделано:
public/images/portfolio/manifest.json— список с подписями- На каждое фото — три webp-варианта: 640w, 1024w, 1920w (генерация через
cwebp -q 85) - Component читает manifest, рендерит
<img srcSet sizes="100vw" loading="lazy"> - Lightbox — Swiper instance, инициализируется по
onClickпервой карточки
Total weight страницы /gallery — ~3 MB на пустом кеше при viewport 1440 × 900. С Cloudflare-кешем — повторные визиты загружаются из disk cache (~500 ms LCP).
Деплой
VPS, nginx, Let's Encrypt, Cloudflare. Никакого Node-сервера на проде. Конфиг nginx — простой:
server {
listen 443 ssl http2;
server_name wedingeorgia.com;
root /var/www/wedingeorgia/out;
index index.html;
# Brotli + gzip для статики
brotli on;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
# Cache headers
location ~* \.(webp|jpg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Cloudflare поверх — Brotli, image optimization (Polish), DDoS protection, аналитика. Cost — $0/мес.
Результат
Сайт выкачен и работает. Концепт получился достаточно отличающимся от 90% русскоязычных свадебных сайтов в Грузии, чтобы агентство выглядело «премиальнее» при том же ценнике пакетов. Свадебная фотография — вертикальный рынок, где визуал делает 80% решения о покупке: пара открывает 5 сайтов агентств, пролистывает hero и галерею, отправляет заявку в тот, который вызвал больше эмоций. Тёмный кинематографический язык вместо светлого «свадебного клише» — рабочая стратегия именно потому, что её не используют.
Стек выбран под максимальную долговечность: Next.js 16 + Tailwind 4 — последние мажорные версии (на момент сборки), не требующие переписывания через год; framer-motion 12 — стабильный, активно поддерживаемый; static export — деплой не зависит от рантайма Node, переезжает на любой VPS за 10 минут.