feat: luxury birthday landing page (Vite+React+TS+Tailwind+Framer Motion)
This commit is contained in:
commit
243c2fd803
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.log
|
||||
19
index.html
Normal file
19
index.html
Normal 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
2736
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
315
src/App.tsx
Normal file
315
src/App.tsx
Normal 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
17
src/index.css
Normal 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
10
src/main.tsx
Normal 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
12
tailwind.config.js
Normal 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
20
tsconfig.json
Normal 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
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user