гора.
WedInGeorgia

Бизнес-логика

Концепт «Kinetic dark cinematic», типографика на Fraunces, разбор анимаций framer-motion, архитектура страницы, мобильная адаптация и стек.

~12 мин чтения · 2469 слов

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-слайдер, формы из шаблонов. Я разбирал их секциями параллельно с дизайном — и каждое решение принималось ровно как «не как у них».

Hero (desktop) — Fraunces поверх warm-black, две CTA: золотая 'Бесплатная консультация' и outline 'Смотреть портфолио'.

Концепция: Kinetic dark cinematic

Заголовок концепции — мой внутренний рабочий ярлык, который потом перешёл в design-system заметки. «Kinetic» — движение по странице должно ощущаться как кадры в кино, не как карточки в каталоге. «Dark» — фон тёмный, не белый. «Cinematic» — поверх всего лёгкая плёнка, фото с медленным Ken Burns, типографика с italic-вставками как в журнальной вёрстке.

Три ключа концепта:

  1. Палитра-перевёртыш. Зелёного нет вообще — он занят конкурентами. Розового нет — слишком очевидно. Используются только тёплый чёрный (три уровня иерархии), ivory-текст и винтажное золото.
  2. Типографика как герой. Fraunces вытянут в crazy-big sizes (до 192 px на 4K). На hero нет фона-фото с заголовком поверх — заголовок занимает всю площадь сам, по гэдиэру вместо рассеянного «свадебный коллаж».
  3. Движение как монтаж. Анимации появляются один раз, не «дрожат», не «прыгают». Каждый эффект имеет уважение к 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#F0E8DCwarm ivory — основной текст. Не чистый белый, чтобы не «жечь» глаза на больших экранах
--color-text-secondary#A09484вторичный текст (subtitle, description)
--color-text-muted#6B5F52капшен, мета, footnotes
--color-gold#C9A96Esignature accent — CTA, ссылки, чекмарки. Спокойное винтажное золото, не металлик
--color-gold-light#D9BE8Ahover state на gold кнопках
--color-gold-dim#8A7548dimmed gold — scrollbar, второстепенные акценты
--color-wine#8B3A3Aредкие эмоциональные акценты (wedding mood)

Три уровня тёмного — basesurfacewarm-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. Все они держат три инварианта:

  1. useHydrated hook — на server-render и до hydration возвращают плоский HTML без motion-обёртки. Исключает FOUC и hydration mismatch (React 19 гидрация строгая).
  2. useReducedMotion — для motion-sensitive пользователей всё сводится к простому opacity-fade без translate, scale, rotate. Не «никаких анимаций», а тонкий fade — пользователь не теряет контекст.
  3. viewport={{ once: true, margin: "-100px" }} — анимация срабатывает один раз на 100 px до полного попадания в viewport. Не «разанимируется» при прокрутке вверх.
КомпонентЭффектДлительность · Easing
FadeInopacity + translateY(30px)0.7s · easeOut
FadeInUpopacity + translateY(60px) (драматичнее)0.8s · easeOut
ScaleRevealopacity + scale(0.6 → 1) (для галереи)1s · easeOut
StaggerContainer / StaggerItemstaggerChildren: 0.15 + child translateY 300.6s child
SplitText (splitBy="word")per-word: opacity + translateY 40 + rotateX(-40° → 0°), stagger 0.06s0.5s per word · cubic-bezier(0.25, 0.46, 0.45, 0.94)
SplitText (splitBy="letter")то же по буквам, stagger 0.03sкак выше
ParallaxImageuseScroll + useTransform → translateY ±10% от scrollYProgressнепрерывно
PageTransitionAnimatePresence mode="wait" на pathname, opacity 0 → 10.3s · easeInOut
Hero scroll indicatorinfinite 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 подтверждает структуру:

  1. Header (fixed, bg-base/60 backdrop-blur-md, z-50) — лого WedInGeorgia + EN switcher + бургер
  2. Hero (min-h-screen) — ParallaxImage с overlay-градиентом, <SplitText> h1 «Ваша идеальная свадьба в Грузии», subtitle FadeIn delay 0.5, CTA-кнопки FadeIn delay 0.7, scroll-indicator
  3. TrustBar — статистика (свадеб / лет опыта / стран признают / рейтинг)
  4. Packages (#packages) — 5 ценовых карточек от 390$ до 2990$
  5. WhyGeorgia — преимущества Грузии, 4 карточки с иконками
  6. Locations (#locations) — 6 локаций сеткой с overlay-фото
  7. GalleryPreview (bg-warm-black) — 8 фото-превью + ссылка на /gallery
  8. Reviews (#reviews) — отзывы реальных пар, имя + город + дата
  9. FAQ (#faq, bg-warm-black) — 8 вопросов, аккордеон
  10. Documents (#documents) — таблица документов по статусам (первый брак / повторный / вдовец / иностранцы)
  11. Contacts (#contacts, min-h-screen) — форма + блок контактов
  12. Footer

Чередование bg-base / bg-surface / bg-warm-black между секциями даёт ритм. Глаз читает страницу как главы — каждая со своим оттенком темноты.

Packages — слайдер ценовых пакетов с фоновым фото пары; gold-accent CTA, чек-лист услуг.
Locations — 6 локаций сеткой: Тбилиси, Батуми, Казбеги, Сигнахи, Мцхета, Кахетия. Overlay-фото с подписями.
GalleryPreview — masonry на главной, превью полного портфолио на 172 фото.
FAQ — 8 вопросов, accordion. Spacing щедрый — пар читает не отвлекаясь.
Documents — что нужно для регистрации. Таблица по статусам, gold-чекмарки.
Contacts — форма заявки + блок контактов и соцсетей.

Внутренние страницы

Помимо главной — 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, который грузится один раз при открытии галереи.

/gallery — отдельная страница с masonry на 172 фото и lightbox.

/locations/{city} — отдельные landing-странички под Тбилиси, Батуми, Казбеги. Та же структура: hero с фото города, описание, пакеты (те же 5), «как добраться», FAQ, контакты. Это даёт 6× SEO-входов (по числу городов) при том же базовом контенте, написанном один раз и переиспользованном через шаблон.

Mobile-адаптив

Fluid clamp() для всех заголовков (без @media-запросов) — сетка перестраивается через Tailwind утилиты:

ЭлементDesktopMobile
Heromin-h-screen, h1 192 pxmin-h-screen, h1 ~48 px (clamp)
Packagesgrid-cols-5 (5 в ряд)snap-x scroll свайп карточек
WhyGeorgiagrid-cols-4grid-cols-1 стек
Locationsgrid-cols-3grid-cols-2
Gallery previewmasonry 4 colmasonry 2 col
Form fieldsinline labelsfull-width stacked

Hero на мобиле сохраняет min-h-screen — это эффектный момент, фуллскрин и на iPhone. CTA-кнопки переходят из горизонтального ряда в вертикальный стек.

Hero (mobile) — fluid clamp() съедает h1 до удобных пропорций; CTA вертикальным стеком.
Packages (mobile) — snap-x свайп: одна карточка во весь экран, фоновое фото пары.
GalleryPreview (mobile) — 2-колоночный masonry, видны разные пропорции фото.
Reviews (mobile) — Swiper-карусель отзывов, точка-индикатор внизу, аватар + имя + дата.

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

ТехнологияПочему
Next.js 16Latest. App Router. output: "export" — чистая статика без Node-сервера на проде, экономия на хостинге.
TypeScript 6strict-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/googleFraunces + 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, после первого useEffecttrue. Каждый 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, не перерисовывается при scroll
  • mix-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 минут.