feat: luxury birthday landing page (Vite+React+TS+Tailwind+Framer Motion)

This commit is contained in:
jumageldinov 2026-05-19 15:22:36 +05:00
commit 243c2fd803
11 changed files with 3173 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
.DS_Store
*.log

19
index.html Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>С Днём Рождения — Luxury Birthday Experience</title>
<meta name="description" content="Премиальное digital-поздравление с днём рождения." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;500;600&family=Playfair+Display:wght@300;400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2736
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "birthday-luxury",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^11.11.17",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

315
src/App.tsx Normal file
View File

@ -0,0 +1,315 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { motion, AnimatePresence, useScroll, useTransform } from "framer-motion";
import { Play, RotateCcw, Share2, Sparkles } from "lucide-react";
const copy = {
intro: ["Еще один год.", "Еще один уровень.", "Еще одна глава."],
sectionTwoTitle: "Время — главный показатель ценности.",
sectionTwoSubtitle: "И сегодня его стало больше.",
hero: "С ДНЕМ РОЖДЕНИЯ",
heroSubtitle: "Пусть следующий этап будет громче, сильнее и красивее предыдущего.",
};
function Grain() {
return (
<div
className="pointer-events-none fixed inset-0 z-50 opacity-[0.075] mix-blend-soft-light"
style={{
backgroundImage:
"url('data:image/svg+xml,%3Csvg viewBox=%220 0 260 260%22 xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cfilter id=%22noiseFilter%22%3E%3CfeTurbulence type=%22fractalNoise%22 baseFrequency=%220.7%22 numOctaves=%223%22 stitchTiles=%22stitch%22/%3E%3C/filter%3E%3Crect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23noiseFilter)%22/%3E%3C/svg%3E')",
}}
/>
);
}
function LuxuryParticles() {
const particles = useMemo(
() =>
Array.from({ length: 72 }, (_, i) => ({
id: i,
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
size: Math.random() * 3.8 + 1.2,
delay: Math.random() * 8,
duration: Math.random() * 9 + 8,
opacity: Math.random() * 0.38 + 0.12,
})),
[]
);
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{particles.map((p) => (
<motion.span
key={p.id}
className="absolute rounded-full bg-[#d8b76d] shadow-[0_0_22px_rgba(216,183,109,0.55)]"
style={{ left: p.left, top: p.top, width: p.size, height: p.size, opacity: p.opacity }}
animate={{ y: [-20, -120, -20], x: [0, 18, -8, 0], scale: [1, 1.45, 0.9, 1] }}
transition={{ duration: p.duration, repeat: Infinity, delay: p.delay, ease: "easeInOut" }}
/>
))}
</div>
);
}
function AmbientLights({ mouse }: { mouse: { x: number; y: number } }) {
return (
<div className="pointer-events-none fixed inset-0 overflow-hidden bg-[#020202]">
<motion.div
className="absolute -top-48 left-1/2 h-[38rem] w-[38rem] -translate-x-1/2 rounded-full bg-[#b99445]/20 blur-[115px]"
animate={{ scale: [1, 1.14, 1], opacity: [0.45, 0.74, 0.45] }}
transition={{ duration: 9, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="absolute bottom-[-18rem] left-[-12rem] h-[40rem] w-[40rem] rounded-full bg-[#87621e]/20 blur-[130px]"
animate={{ x: mouse.x * 18, y: mouse.y * 12 }}
transition={{ type: "spring", stiffness: 34, damping: 20 }}
/>
<motion.div
className="absolute right-[-15rem] top-[20%] h-[34rem] w-[34rem] rounded-full bg-white/10 blur-[140px]"
animate={{ x: mouse.x * -14, y: mouse.y * -10 }}
transition={{ type: "spring", stiffness: 34, damping: 20 }}
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(255,231,171,0.12),transparent_28%),linear-gradient(180deg,rgba(0,0,0,0.05),rgba(0,0,0,0.94))]" />
</div>
);
}
function PremiumButton({
children,
onClick,
icon: Icon,
}: {
children: React.ReactNode;
onClick?: () => void;
icon?: React.ElementType;
}) {
return (
<button
onClick={onClick}
className="group inline-flex h-12 items-center rounded-full border border-white/15 bg-white/[0.055] px-6 text-sm font-light tracking-[0.22em] text-white/85 shadow-[0_12px_50px_rgba(0,0,0,0.35)] backdrop-blur-xl transition-all duration-500 hover:border-[#d8b76d]/60 hover:bg-[#d8b76d]/10 hover:text-white"
>
{Icon && <Icon className="mr-2 h-4 w-4 transition-transform duration-500 group-hover:scale-110" />}
{children}
</button>
);
}
export default function LuxuryBirthdayLanding() {
const [started, setStarted] = useState(false);
const [mouse, setMouse] = useState({ x: 0, y: 0 });
const pageRef = useRef<HTMLDivElement | null>(null);
const { scrollYProgress } = useScroll({ target: pageRef, offset: ["start start", "end end"] });
const scale = useTransform(scrollYProgress, [0.35, 0.68], [0.86, 1.08]);
const glow = useTransform(scrollYProgress, [0.5, 0.7], [0.2, 1]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
setMouse({ x: e.clientX / window.innerWidth - 0.5, y: e.clientY / window.innerHeight - 0.5 });
};
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
const scrollToNext = () => {
setStarted(true);
setTimeout(() => document.getElementById("time")?.scrollIntoView({ behavior: "smooth" }), 450);
};
const replay = () => {
setStarted(false);
window.scrollTo({ top: 0, behavior: "smooth" });
};
const share = async () => {
const data = {
title: "С Днем Рождения",
text: "Открой премиальное digital-поздравление.",
url: window.location.href,
};
if (navigator.share) await navigator.share(data);
else await navigator.clipboard.writeText(window.location.href);
};
return (
<main
ref={pageRef}
className="relative min-h-screen overflow-x-hidden bg-black text-white selection:bg-[#d8b76d]/30"
>
<AmbientLights mouse={mouse} />
<Grain />
<section className="relative flex min-h-screen items-center justify-center px-5 py-12">
<LuxuryParticles />
<div className="absolute inset-x-5 top-6 z-10 mx-auto flex max-w-6xl items-center justify-between rounded-full border border-white/10 bg-white/[0.035] px-5 py-3 backdrop-blur-2xl">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.34em] text-white/55">
<Sparkles className="h-3.5 w-3.5 text-[#d8b76d]" />
Luxury Birthday Experience
</div>
</div>
<div className="relative z-10 mx-auto max-w-5xl text-center">
<AnimatePresence mode="wait">
{!started ? (
<motion.div
key="intro"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, filter: "blur(18px)", scale: 0.98 }}
transition={{ duration: 1.2 }}
>
<div className="mb-8 text-[11px] uppercase tracking-[0.42em] text-[#d8b76d]/80">
Private celebration signal
</div>
<div className="space-y-5 md:space-y-8">
{copy.intro.map((line, i) => (
<motion.h1
key={line}
className="font-serif text-5xl font-light leading-[0.95] tracking-[-0.06em] text-white md:text-8xl lg:text-9xl"
initial={{ opacity: 0, y: 30, filter: "blur(18px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 1.35, delay: 0.35 + i * 0.58, ease: [0.16, 1, 0.3, 1] }}
>
{line}
</motion.h1>
))}
</div>
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 2.55, duration: 0.9 }}
className="mt-12"
>
<PremiumButton onClick={scrollToNext} icon={Play}>
Продолжить
</PremiumButton>
</motion.div>
</motion.div>
) : (
<motion.div
key="started"
initial={{ opacity: 0, scale: 0.96, filter: "blur(12px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
className="text-center"
>
<p className="text-sm uppercase tracking-[0.5em] text-[#d8b76d]/80">Experience started</p>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
<section id="time" className="relative flex min-h-screen items-center justify-center px-5 py-28">
<LuxuryParticles />
<motion.div
className="absolute inset-x-8 top-1/2 h-px bg-gradient-to-r from-transparent via-[#d8b76d]/45 to-transparent"
style={{ opacity: glow }}
/>
<div className="relative z-10 mx-auto max-w-5xl text-center">
<motion.p
className="mb-8 text-[11px] uppercase tracking-[0.42em] text-white/45"
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.5 }}
transition={{ duration: 1 }}
>
Limited edition moment
</motion.p>
<motion.h2
className="font-serif text-5xl font-light leading-[1.02] tracking-[-0.055em] md:text-8xl"
initial={{ opacity: 0, y: 38, filter: "blur(16px)" }}
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
viewport={{ once: false, amount: 0.45 }}
transition={{ duration: 1.35, ease: [0.16, 1, 0.3, 1] }}
>
{copy.sectionTwoTitle}
</motion.h2>
<motion.p
className="mx-auto mt-8 max-w-2xl text-xl font-light leading-relaxed text-white/60 md:text-2xl"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.45 }}
transition={{ duration: 1.1, delay: 0.24 }}
>
{copy.sectionTwoSubtitle}
</motion.p>
</div>
</section>
<section className="relative flex min-h-screen items-center justify-center overflow-hidden px-5 py-28">
<LuxuryParticles />
<motion.div
style={{ scale }}
className="absolute h-[34rem] w-[34rem] rounded-full border border-[#d8b76d]/20 bg-[#d8b76d]/[0.045] shadow-[0_0_160px_rgba(216,183,109,0.32)] blur-[1px]"
/>
<motion.div
className="absolute h-[1px] w-[90vw] bg-gradient-to-r from-transparent via-white/70 to-transparent"
initial={{ scaleX: 0, opacity: 0 }}
whileInView={{ scaleX: 1, opacity: [0, 1, 0.25] }}
viewport={{ once: false, amount: 0.5 }}
transition={{ duration: 1.7, ease: "easeOut" }}
/>
<div className="relative z-10 mx-auto max-w-6xl text-center">
<motion.div
className="mx-auto mb-8 h-20 w-20 rounded-full border border-[#d8b76d]/35 bg-white/[0.04] p-5 shadow-[0_0_90px_rgba(216,183,109,0.35)] backdrop-blur-xl"
initial={{ opacity: 0, scale: 0.4, rotate: -20 }}
whileInView={{ opacity: 1, scale: 1, rotate: 0 }}
viewport={{ once: false, amount: 0.55 }}
transition={{ duration: 1.05, ease: [0.16, 1, 0.3, 1] }}
>
<Sparkles className="h-full w-full text-[#d8b76d]" />
</motion.div>
<motion.h2
className="bg-gradient-to-b from-white via-white to-[#c8a65e] bg-clip-text font-serif text-6xl font-light leading-[0.9] tracking-[-0.075em] text-transparent md:text-9xl lg:text-[10rem]"
initial={{ opacity: 0, scale: 0.86, filter: "blur(22px)" }}
whileInView={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
viewport={{ once: false, amount: 0.42 }}
transition={{ duration: 1.55, ease: [0.16, 1, 0.3, 1] }}
>
{copy.hero}
</motion.h2>
<motion.p
className="mx-auto mt-9 max-w-3xl text-lg font-light leading-relaxed text-white/70 md:text-2xl"
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.5 }}
transition={{ duration: 1.2, delay: 0.26 }}
>
{copy.heroSubtitle}
</motion.p>
</div>
</section>
<section className="relative flex min-h-screen items-center justify-center px-5 py-28">
<div className="absolute inset-x-5 mx-auto max-w-4xl rounded-[3rem] border border-white/10 bg-white/[0.035] p-8 shadow-[0_30px_120px_rgba(0,0,0,0.48)] backdrop-blur-2xl md:p-14" />
<div className="relative z-10 mx-auto max-w-3xl text-center">
<motion.p
className="text-[11px] uppercase tracking-[0.48em] text-[#d8b76d]/80"
initial={{ opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.6 }}
transition={{ duration: 0.9 }}
>
The next chapter begins now
</motion.p>
<motion.h2
className="mt-8 font-serif text-4xl font-light leading-tight tracking-[-0.04em] text-white md:text-7xl"
initial={{ opacity: 0, y: 26, filter: "blur(14px)" }}
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
viewport={{ once: false, amount: 0.5 }}
transition={{ duration: 1.25, ease: [0.16, 1, 0.3, 1] }}
>
Пусть этот день звучит как начало чего-то большого.
</motion.h2>
<div className="mt-12 flex flex-col items-center justify-center gap-4 sm:flex-row">
<PremiumButton onClick={replay} icon={RotateCcw}>
Повторить
</PremiumButton>
<PremiumButton onClick={share} icon={Share2}>
Поделиться
</PremiumButton>
</div>
</div>
</section>
</main>
);
}

17
src/index.css Normal file
View File

@ -0,0 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
background: #000;
}
body {
margin: 0;
font-family: "Cormorant Garamond", "Playfair Display", Georgia, serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import LuxuryBirthdayLanding from "./App";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<LuxuryBirthdayLanding />
</React.StrictMode>
);

12
tailwind.config.js Normal file
View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
fontFamily: {
serif: ["'Cormorant Garamond'", "'Playfair Display'", "Georgia", "serif"],
},
},
},
plugins: [],
};

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: "./",
plugins: [react()],
});