Initial commit

This commit is contained in:
dk
2026-03-22 07:11:56 +00:00
commit 02fc701e75
634 changed files with 80682 additions and 0 deletions

239
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,239 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import Link from "next/link";
import NavbarLayoutFloatingInline from "@/components/navbar/NavbarLayoutFloatingInline";
import AboutMetric from "@/components/sections/about/AboutMetric";
import FeatureCardTwentySeven from "@/components/sections/feature/FeatureCardTwentySeven";
import FaqSplitText from "@/components/sections/faq/FaqSplitText";
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
import { CheckCircle, Users, Zap, Shield, Award, HelpCircle } from "lucide-react";
export default function AboutPage() {
const navItems = [
{ name: "Home", id: "home" },
{ name: "Services", id: "services" },
{ name: "Why Steam", id: "why-steam" },
{ name: "Membership", id: "membership" },
{ name: "Franchise", id: "franchise" },
{ name: "About", id: "about" },
{ name: "Contact", id: "contact" },
];
const navLinks = [
{ name: "Home", href: "/" },
{ name: "Services", href: "/services" },
{ name: "Why Steam", href: "/why-steam-carwash" },
{ name: "Membership", href: "/membership" },
{ name: "Franchise", href: "/franchise" },
{ name: "About", href: "/about" },
{ name: "Contact", href: "/contact" },
];
return (
<ThemeProvider
defaultButtonVariant="shift-hover"
defaultTextAnimation="reveal-blur"
borderRadius="rounded"
contentWidth="smallMedium"
sizing="mediumSizeLargeTitles"
background="circleGradient"
cardStyle="inset"
primaryButtonStyle="diagonal-gradient"
secondaryButtonStyle="solid"
headingFontWeight="light"
>
<div id="nav" data-section="nav">
<NavbarLayoutFloatingInline
brandName="Ventian"
navItems={navItems}
button={{
text: "Book Now",
href: "/",
}}
/>
</div>
<div id="trust-indicators" data-section="trust-indicators" className="w-full py-16">
<div className="mx-auto px-4 md:px-6">
<AboutMetric
title="Trusted by Premium Car Owners Nationwide"
metrics={[
{
icon: CheckCircle,
label: "Years of Excellence",
value: "10+",
},
{
icon: Users,
label: "Satisfied Customers",
value: "50K+",
},
{
icon: Zap,
label: "Vehicles Cleaned",
value: "100K+",
},
{
icon: Shield,
label: "Eco-Friendly Washes",
value: "99%",
},
]}
metricsAnimation="reveal-blur"
useInvertedBackground={false}
ariaLabel="Trust metrics section"
/>
</div>
</div>
<div id="about-company" data-section="about-company" className="w-full py-20">
<div className="mx-auto px-4 md:px-6">
<FeatureCardTwentySeven
title="Our Story & Mission"
description="Founded with a vision to revolutionize automotive care, Ventian combines cutting-edge steam technology with eco-conscious practices. We believe every vehicle deserves premium treatment without compromising environmental responsibility."
tag="About Ventian"
tagIcon={Award}
tagAnimation="slide-up"
features={[
{
id: "mission",
title: "Our Mission",
descriptions: [
"Revolutionize automotive care through innovative steam technology",
"Deliver uncompromising hygiene and premium quality on every visit",
"Lead the industry in eco-conscious, sustainable practices",
],
imageSrc: "http://img.b2bpic.net/free-photo/man-gray-sweater-wipes-car-car-wash_1157-35957.jpg",
},
{
id: "vision",
title: "Our Vision",
descriptions: [
"Become the most trusted premium carwash brand in the Philippines",
"Expand to every major city with consistent quality and service",
"Set the standard for sustainable luxury automotive care",
],
imageSrc: "http://img.b2bpic.net/free-photo/man-washing-his-car-garage_1157-26053.jpg",
},
{
id: "values",
title: "Our Core Values",
descriptions: [
"Premium Quality: Excellence in every detail and interaction",
"Environmental Responsibility: Sustainable practices always",
"Customer-First Service: Your satisfaction is our priority",
],
imageSrc: "http://img.b2bpic.net/free-photo/south-asian-man-indian-male-washing-his-white-transportation-car-wash_627829-5016.jpg",
},
]}
gridVariant="three-columns-all-equal-width"
animationType="reveal-blur"
textboxLayout="default"
useInvertedBackground={true}
ariaLabel="About company mission and values section"
/>
</div>
</div>
<div id="faq-about" data-section="faq-about" className="w-full py-20">
<div className="mx-auto px-4 md:px-6">
<FaqSplitText
sideTitle="Learn More About Ventian"
sideDescription="Get answers to common questions about our company, values, and commitment to premium automotive care."
faqs={[
{
id: "faq-1",
title: "How did Ventian start?",
content: "Ventian was founded by automotive enthusiasts who recognized the need for premium, eco-friendly carwash services in the Philippines. We combined advanced steam technology with a commitment to sustainability and excellent customer service to create a brand that stands apart.",
},
{
id: "faq-2",
title: "What makes Ventian different from other carwashes?",
content: "We differentiate through three key pillars: advanced steam technology that cleans effectively without harsh chemicals, uncompromising hygiene standards that exceed industry requirements, and genuine environmental responsibility reflected in every operation.",
},
{
id: "faq-3",
title: "Where are Ventian locations?",
content: "We currently operate multiple premium facilities in Cebu with expansion plans throughout the Philippines. We also offer mobile steam carwash services for ultimate convenience. Check our locations page to find a Ventian near you.",
},
{
id: "faq-4",
title: "Does Ventian offer franchise opportunities?",
content: "Yes! We're expanding nationwide and actively seeking franchise partners who share our commitment to quality and sustainability. Our proven business model, comprehensive training, and dedicated support make Ventian franchises highly successful.",
},
{
id: "faq-5",
title: "What training and support do you provide?",
content: "We provide comprehensive training covering steam technology operation, customer service, hygiene protocols, and business management. Ongoing support includes regular facility consultations, marketing assistance, and access to our exclusive supplier network.",
},
{
id: "faq-6",
title: "How is Ventian committed to sustainability?",
content: "We use up to 80% less water than traditional carwashes, zero harmful chemicals, eco-friendly sanitization methods, and sustainable facility operations. Every decision is made with environmental impact in mind.",
},
]}
faqsAnimation="reveal-blur"
textPosition="left"
useInvertedBackground={true}
animationType="smooth"
showCard={true}
buttons={[
{
text: "More FAQs",
href: "/",
},
]}
buttonAnimation="slide-up"
ariaLabel="Frequently asked questions section"
/>
</div>
</div>
<div id="footer" data-section="footer" className="w-full">
<FooterLogoEmphasis
logoText="Ventian"
columns={[
{
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: "Why Steam", href: "/why-steam-carwash" },
{ label: "Membership", href: "/membership" },
{ label: "Blog", href: "/blog" },
],
},
{
items: [
{ label: "About Us", href: "/about" },
{ label: "Locations", href: "/locations" },
{ label: "Franchise", href: "/franchise" },
{ label: "Careers", href: "/careers" },
{ label: "Contact", href: "/contact" },
],
},
{
items: [
{ label: "Facebook", href: "https://facebook.com/ventian" },
{ label: "Instagram", href: "https://instagram.com/ventian" },
{ label: "TikTok", href: "https://tiktok.com/@ventian" },
{ label: "LinkedIn", href: "https://linkedin.com/company/ventian" },
{ label: "YouTube", href: "https://youtube.com/@ventian" },
],
},
{
items: [
{ label: "Privacy Policy", href: "#privacy" },
{ label: "Terms of Service", href: "#terms" },
{ label: "Cookie Policy", href: "#cookies" },
{ label: "Contact Support", href: "#support" },
{ label: "Feedback", href: "#feedback" },
],
},
]}
ariaLabel="Site footer with navigation and links"
/>
</div>
</ThemeProvider>
);
}

325
src/app/contact/page.tsx Normal file
View File

@@ -0,0 +1,325 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import Link from "next/link";
import NavbarLayoutFloatingInline from "@/components/navbar/NavbarLayoutFloatingInline";
import ContactCTA from "@/components/sections/contact/ContactCTA";
import FaqSplitText from "@/components/sections/faq/FaqSplitText";
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
import { Mail, Phone, MapPin, MessageSquare } from "lucide-react";
export default function ContactPage() {
const navItems = [
{ name: "Home", id: "home" },
{ name: "Services", id: "services" },
{ name: "Why Steam", id: "why-steam" },
{ name: "Membership", id: "membership" },
{ name: "Franchise", id: "franchise" },
{ name: "About", id: "about" },
{ name: "Contact", id: "contact" },
];
const navLinks = [
{ name: "Home", href: "/" },
{ name: "Services", href: "/services" },
{ name: "Why Steam", href: "/why-steam-carwash" },
{ name: "Membership", href: "/membership" },
{ name: "Franchise", href: "/franchise" },
{ name: "About", href: "/about" },
{ name: "Contact", href: "/contact" },
];
const footerColumns = [
{
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: "Why Steam", href: "/why-steam-carwash" },
{ label: "Membership", href: "/membership" },
{ label: "Blog", href: "/blog" },
],
},
{
items: [
{ label: "About Us", href: "/about" },
{ label: "Locations", href: "/locations" },
{ label: "Franchise", href: "/franchise" },
{ label: "Careers", href: "/careers" },
{ label: "Contact", href: "/contact" },
],
},
{
items: [
{ label: "Facebook", href: "https://facebook.com/ventian" },
{ label: "Instagram", href: "https://instagram.com/ventian" },
{ label: "TikTok", href: "https://tiktok.com/@ventian" },
{ label: "LinkedIn", href: "https://linkedin.com/company/ventian" },
{ label: "YouTube", href: "https://youtube.com/@ventian" },
],
},
{
items: [
{ label: "Privacy Policy", href: "#privacy" },
{ label: "Terms of Service", href: "#terms" },
{ label: "Cookie Policy", href: "#cookies" },
{ label: "Contact Support", href: "#support" },
{ label: "Feedback", href: "#feedback" },
],
},
];
return (
<ThemeProvider
defaultButtonVariant="shift-hover"
defaultTextAnimation="reveal-blur"
borderRadius="rounded"
contentWidth="smallMedium"
sizing="mediumSizeLargeTitles"
background="circleGradient"
cardStyle="inset"
primaryButtonStyle="diagonal-gradient"
secondaryButtonStyle="solid"
headingFontWeight="light"
>
<div id="nav" data-section="nav">
<NavbarLayoutFloatingInline
navItems={navItems}
brandName="Ventian"
button={{ text: "Book Now", href: "/contact" }}
animateOnLoad={true}
/>
</div>
<div id="contact-intro" data-section="contact-intro" className="w-full py-20">
<div className="mx-auto px-4 md:px-6 max-w-6xl">
<div className="text-center mb-16">
<h1 className="text-4xl md:text-5xl font-light mb-4">Get in Touch</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
Have questions about Ventian? We're here to help. Reach out using any method below and our team will respond promptly.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-teal-50 to-green-50 mb-4">
<Phone className="w-8 h-8 text-teal-600" />
</div>
<h3 className="text-xl font-semibold mb-2">Call Us</h3>
<p className="text-gray-600 mb-1">(+63) 32-XXX-XXXX</p>
<p className="text-sm text-gray-500">Mon-Sun, 7am-8pm</p>
</div>
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-teal-50 to-green-50 mb-4">
<Mail className="w-8 h-8 text-teal-600" />
</div>
<h3 className="text-xl font-semibold mb-2">Email Us</h3>
<p className="text-gray-600 mb-1">hello@ventian.com</p>
<p className="text-sm text-gray-500">We'll respond within 24 hours</p>
</div>
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-teal-50 to-green-50 mb-4">
<MapPin className="w-8 h-8 text-teal-600" />
</div>
<h3 className="text-xl font-semibold mb-2">Visit Us</h3>
<p className="text-gray-600 mb-1">Cebu, Philippines</p>
<p className="text-sm text-gray-500">Multiple locations available</p>
</div>
</div>
</div>
</div>
<div id="contact-form" data-section="contact-form" className="w-full py-20">
<div className="mx-auto px-4 md:px-6 max-w-3xl">
<div className="bg-white rounded-2xl p-8 md:p-12 shadow-lg border border-gray-100">
<h2 className="text-3xl font-light mb-2">Send us a Message</h2>
<p className="text-gray-600 mb-8">Fill out the form below and we'll get back to you soon.</p>
<form className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
First Name
</label>
<input
type="text"
placeholder="John"
className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Last Name
</label>
<input
type="text"
placeholder="Doe"
className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 transition"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
type="email"
placeholder="you@example.com"
className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Phone Number
</label>
<input
type="tel"
placeholder="(+63) XXX-XXXX"
className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 transition"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Subject
</label>
<select className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 transition">
<option>Service Inquiry</option>
<option>Membership Question</option>
<option>Franchise Interest</option>
<option>Feedback</option>
<option>Other</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Message
</label>
<textarea
rows={5}
placeholder="Tell us how we can help..."
className="w-full px-4 py-3 rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 transition resize-none"
></textarea>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="privacy"
className="w-4 h-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500 cursor-pointer"
/>
<label htmlFor="privacy" className="ml-2 text-sm text-gray-600">
I agree to the{" "}
<a href="#privacy" className="text-teal-600 hover:text-teal-700 font-medium">
Privacy Policy
</a>
</label>
</div>
<button
type="submit"
className="w-full px-6 py-3 bg-teal-600 hover:bg-teal-700 text-white font-semibold rounded-lg transition duration-300"
>
Send Message
</button>
</form>
</div>
</div>
</div>
<div id="faq" data-section="faq" className="w-full py-20">
<FaqSplitText
sideTitle="Common Questions"
sideDescription="Everything you need to know about Ventian's premium steam carwash services."
faqs={[
{
id: "faq-1",
title: "How does steam carwash differ from traditional washing?",
content:
"Steam carwash uses high-temperature steam to clean your vehicle without harsh chemicals. It's gentler on your car's surface, uses 80% less water, and delivers superior cleaning results. Perfect for all vehicle types and finishes.",
},
{
id: "faq-2",
title: "Is steam carwash safe for my vehicle's paint?",
content:
"Absolutely. Our steam technology is specifically designed to be gentle on all surfaces while being highly effective. We follow strict protocols to ensure your vehicle receives premium, safe care every time.",
},
{
id: "faq-3",
title: "How do I book a service?",
content:
"Booking is easy! Visit our website, select your service, choose your preferred date and time, and confirm. You can also call us directly or use our mobile app. Bookings are available 7 days a week.",
},
{
id: "faq-4",
title: "What areas does Ventian service?",
content:
"We operate multiple locations across Cebu and are expanding nationwide. We offer both shop-based services and mobile carwash. Check our locations page or contact us to see if we service your area.",
},
{
id: "faq-5",
title: "Are your memberships worth it?",
content:
"Yes! Our members save up to 40% on regular pricing plus enjoy priority scheduling, exclusive perks, and VIP treatment. Most members pay for their membership within 3-4 services.",
},
{
id: "faq-6",
title: "Do you offer fleet or corporate services?",
content:
"Absolutely. We specialize in corporate fleet management with flexible scheduling, competitive rates, and dedicated support. Contact our business team for customized corporate solutions.",
},
]}
faqsAnimation="reveal-blur"
textPosition="left"
useInvertedBackground={true}
animationType="smooth"
showCard={true}
buttons={[
{
text: "More FAQs",
href: "#all-faqs",
},
]}
buttonAnimation="slide-up"
ariaLabel="Frequently asked questions section"
/>
</div>
<div id="contact-cta" data-section="contact-cta" className="w-full py-16">
<ContactCTA
tag="Ready to Connect?"
tagIcon={MessageSquare}
tagAnimation="slide-up"
title="We're Here to Help"
description="Whether you have a quick question or want to learn more about our premium steam carwash services, our team is ready to assist you. Contact us today!"
buttons={[
{
text: "Call Now",
href: "tel:+6332XXXXXX",
},
{
text: "Email Us",
href: "mailto:hello@ventian.com",
},
]}
buttonAnimation="slide-up"
background={{ variant: "radial-gradient" }}
useInvertedBackground={false}
ariaLabel="Contact help section"
/>
</div>
<div id="footer" data-section="footer" className="w-full">
<FooterLogoEmphasis
logoText="Ventian"
columns={footerColumns}
ariaLabel="Site footer with navigation and links"
/>
</div>
</ThemeProvider>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

289
src/app/franchise/page.tsx Normal file
View File

@@ -0,0 +1,289 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import Link from "next/link";
import NavbarLayoutFloatingInline from "@/components/navbar/NavbarLayoutFloatingInline";
import FeatureCardTwentySeven from "@/components/sections/feature/FeatureCardTwentySeven";
import PricingCardNine from "@/components/sections/pricing/PricingCardNine";
import ContactCTA from "@/components/sections/contact/ContactCTA";
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
import { Zap, Award, TrendingUp } from "lucide-react";
export default function FranchisePage() {
const navItems = [
{ name: "Home", id: "home" },
{ name: "Services", id: "services" },
{ name: "Why Steam", id: "why-steam" },
{ name: "Membership", id: "membership" },
{ name: "Franchise", id: "franchise" },
{ name: "About", id: "about" },
{ name: "Contact", id: "contact" },
];
return (
<ThemeProvider
defaultButtonVariant="shift-hover"
defaultTextAnimation="reveal-blur"
borderRadius="rounded"
contentWidth="smallMedium"
sizing="mediumSizeLargeTitles"
background="circleGradient"
cardStyle="inset"
primaryButtonStyle="diagonal-gradient"
secondaryButtonStyle="solid"
headingFontWeight="light"
>
<div id="nav" data-section="nav">
<NavbarLayoutFloatingInline
brandName="Ventian"
navItems={navItems}
button={{
text: "Book Now",
href: "/",
}}
/>
</div>
<div id="franchise-benefits" data-section="franchise-benefits" className="w-full py-20">
<div className="mx-auto px-4 md:px-6">
<FeatureCardTwentySeven
title="Why Franchise With Ventian?"
description="Join a proven, rapidly expanding premium carwash brand. Ventian franchise partners benefit from comprehensive support, proven operational systems, and access to a market hungry for premium automotive care."
tag="Franchise Opportunity"
tagIcon={Award}
tagAnimation="slide-up"
features={[
{
id: "proven-model",
title: "Proven Business Model",
descriptions: [
"Successful operations across multiple locations",
"Tested systems for profitability and growth",
"Strong brand recognition and customer loyalty",
],
imageSrc: "http://img.b2bpic.net/free-photo/auto-mechanic-businesswoman-cooperating-while-checking-vehicle-hood-workshop_637285-7688.jpg",
},
{
id: "comprehensive-training",
title: "Comprehensive Training & Support",
descriptions: [
"In-depth training on steam technology and operations",
"Customer service and sales excellence programs",
"Ongoing management and marketing support",
],
imageSrc: "http://img.b2bpic.net/free-photo/man-washing-his-car-garage_1157-26070.jpg",
},
{
id: "market-demand",
title: "High Market Demand",
descriptions: [
"Growing demand for premium automotive care services",
"Eco-conscious consumers seeking sustainable options",
"Strong corporate and fleet management opportunities",
],
imageSrc: "http://img.b2bpic.net/free-photo/futuristic-mean-transportation-ultra-modern-city_23-2151065440.jpg",
},
{
id: "tech-support",
title: "Technology & Systems",
descriptions: [
"Premium booking and scheduling platform included",
"Business management tools and analytics",
"Marketing templates and digital resources",
],
imageSrc: "http://img.b2bpic.net/free-photo/mechanic-smiling-while-examining-car-engine-with-lamp_1170-1292.jpg",
},
{
id: "supply-chain",
title: "Supplier Network",
descriptions: [
"Exclusive partnerships for equipment and supplies",
"Competitive bulk pricing for all materials",
"Dedicated supplier relationship management",
],
imageSrc: "http://img.b2bpic.net/free-photo/happy-black-mechanic-taking-notes-while-talking-customer-auto-repair-shop_637285-11587.jpg",
},
{
id: "growth-path",
title: "Scalability & Growth",
descriptions: [
"Multi-unit expansion opportunities available",
"Clear path to building a regional carwash network",
"Support for mobile service expansion",
],
imageSrc: "http://img.b2bpic.net/free-photo/man-washing-his-car-garage_1157-26053.jpg",
},
]}
gridVariant="three-columns-all-equal-width"
animationType="reveal-blur"
textboxLayout="default"
useInvertedBackground={true}
buttons={[
{
text: "Learn More",
href: "/franchise",
},
]}
buttonAnimation="slide-up"
ariaLabel="Franchise benefits section"
/>
</div>
</div>
<div id="franchise-investment" data-section="franchise-investment" className="w-full py-20">
<div className="mx-auto px-4 md:px-6">
<PricingCardNine
title="Franchise Investment & Returns"
description="Flexible franchise packages designed to support entrepreneurs at different investment levels. All packages include comprehensive training, ongoing support, and access to Ventian's proven systems."
tag="Investment Plans"
tagIcon={TrendingUp}
tagAnimation="slide-up"
plans={[
{
id: "starter-franchise",
title: "Express",
price: "₱1.5M",
period: "Initial Investment",
features: [
"Mobile steam carwash service",
"Complete equipment package",
"2-week intensive training",
"Marketing startup kit",
"Year 1 operational support",
],
button: {
text: "Inquire Now",
href: "#franchise-inquiry",
},
imageSrc: "http://img.b2bpic.net/free-photo/man-washing-his-car-garage_1157-26053.jpg",
},
{
id: "standard-franchise",
title: "Premium",
price: "₱3.5M",
period: "Initial Investment",
features: [
"Full-service carwash facility",
"Premium equipment & tech systems",
"4-week comprehensive training",
"Complete branding package",
"24/7 operational support",
"Marketing & sales programs",
],
button: {
text: "Schedule Call",
href: "#franchise-call",
},
imageSrc: "http://img.b2bpic.net/free-photo/mechanic-smiling-while-examining-car-engine-with-lamp_1170-1292.jpg",
},
{
id: "elite-franchise",
title: "Elite",
price: "₱6M+",
period: "Initial Investment",
features: [
"Multi-location package opportunity",
"Flagship facility with VIP lounge",
"Advanced equipment & automation",
"Intensive executive training program",
"Dedicated franchise manager",
"Regional development rights",
"Priority access to new technology",
],
button: {
text: "Explore Opportunity",
href: "#elite-inquiry",
},
imageSrc: "http://img.b2bpic.net/free-photo/auto-mechanic-businesswoman-cooperating-while-checking-vehicle-hood-workshop_637285-7688.jpg",
},
]}
animationType="reveal-blur"
textboxLayout="default"
useInvertedBackground={false}
buttons={[
{
text: "View All Options",
href: "/franchise",
},
]}
buttonAnimation="slide-up"
ariaLabel="Franchise investment and returns section"
/>
</div>
</div>
<div id="franchise-cta" data-section="franchise-cta" className="w-full py-16">
<div className="mx-auto px-4 md:px-6">
<ContactCTA
tag="Next Steps"
tagIcon={Zap}
tagAnimation="slide-up"
title="Ready to Build Your Ventian Franchise?"
description="Join our rapidly expanding network of successful franchise partners. Our dedicated franchise development team is ready to discuss your business goals and find the perfect Ventian package for you."
buttons={[
{
text: "Request Franchise Info",
href: "#franchise-form",
},
{
text: "Schedule Consultation",
href: "#franchise-consultation",
},
]}
buttonAnimation="slide-up"
background={{
variant: "radial-gradient",
}}
useInvertedBackground={true}
ariaLabel="Franchise call-to-action section"
/>
</div>
</div>
<div id="footer" data-section="footer" className="w-full">
<FooterLogoEmphasis
logoText="Ventian"
columns={[
{
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: "Why Steam", href: "/why-steam-carwash" },
{ label: "Membership", href: "/membership" },
{ label: "Blog", href: "/blog" },
],
},
{
items: [
{ label: "About Us", href: "/about" },
{ label: "Locations", href: "/locations" },
{ label: "Franchise", href: "/franchise" },
{ label: "Careers", href: "/careers" },
{ label: "Contact", href: "/contact" },
],
},
{
items: [
{ label: "Facebook", href: "https://facebook.com/ventian" },
{ label: "Instagram", href: "https://instagram.com/ventian" },
{ label: "TikTok", href: "https://tiktok.com/@ventian" },
{ label: "LinkedIn", href: "https://linkedin.com/company/ventian" },
{ label: "YouTube", href: "https://youtube.com/@ventian" },
],
},
{
items: [
{ label: "Privacy Policy", href: "#privacy" },
{ label: "Terms of Service", href: "#terms" },
{ label: "Cookie Policy", href: "#cookies" },
{ label: "Contact Support", href: "#support" },
{ label: "Feedback", href: "#feedback" },
],
},
]}
ariaLabel="Site footer with navigation and links"
/>
</div>
</ThemeProvider>
);
}

5
src/app/globals.css Normal file
View File

@@ -0,0 +1,5 @@
@import "tailwindcss";
@import "./styles/variables.css";
@import "./styles/theme.css";
@import "./styles/utilities.css";
@import "./styles/base.css";

42
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,42 @@
import type { Metadata } from "next";
import { Halant } from "next/font/google";
import { Inter } from "next/font/google";
import "./globals.css";
import { ServiceWrapper } from "@/components/ServiceWrapper";
import Tag from "@/tag/Tag";
import { getVisualEditScript } from "@/utils/visual-edit-script";
import { Figtree } from "next/font/google";
export const metadata: Metadata = {
title: "Webild components 2",
description: "Generated by create next app",
};
const figtree = Figtree({
variable: "--font-figtree",
subsets: ["latin"],
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<ServiceWrapper>
<body className={`${figtree.variable} antialiased`}>
<Tag />
{children}
<script
dangerouslySetInnerHTML={{
__html: `${getVisualEditScript()}`
}}
/>
</body>
</ServiceWrapper>
</html>
);
}

265
src/app/membership/page.tsx Normal file
View File

@@ -0,0 +1,265 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import Link from "next/link";
import NavbarLayoutFloatingInline from "@/components/navbar/NavbarLayoutFloatingInline";
import PricingCardNine from "@/components/sections/pricing/PricingCardNine";
import TestimonialCardThirteen from "@/components/sections/testimonial/TestimonialCardThirteen";
import ContactCTA from "@/components/sections/contact/ContactCTA";
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
import { Sparkles, Star } from "lucide-react";
export default function MembershipPage() {
return (
<ThemeProvider
defaultButtonVariant="shift-hover"
defaultTextAnimation="reveal-blur"
borderRadius="rounded"
contentWidth="smallMedium"
sizing="mediumSizeLargeTitles"
background="circleGradient"
cardStyle="inset"
primaryButtonStyle="diagonal-gradient"
secondaryButtonStyle="solid"
headingFontWeight="light"
>
<div id="nav" data-section="nav" className="w-full">
<NavbarLayoutFloatingInline
brandName="Ventian"
navItems={[
{ name: "Home", id: "home" },
{ name: "Services", id: "services" },
{ name: "Why Steam", id: "why-steam" },
{ name: "Membership", id: "membership" },
{ name: "Franchise", id: "franchise" },
{ name: "About", id: "about" },
{ name: "Contact", id: "contact" },
]}
button={{
text: "Book Now",
href: "#book-now",
}}
/>
</div>
<div id="membership-hero" data-section="membership-hero" className="w-full py-20">
<div className="mx-auto px-4 md:px-6">
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 mb-6">
<Sparkles className="w-5 h-5 text-accent" />
<span className="text-sm font-medium text-accent">Subscription Plans</span>
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-light mb-6 text-foreground">
Premium Membership Plans
</h1>
<p className="text-lg md:text-xl text-foreground/80 max-w-3xl mx-auto">
Join the Ventian family and enjoy unlimited premium carwash benefits. Flexible plans designed for every lifestyle and budget.
</p>
</div>
</div>
</div>
<div id="membership-plans" data-section="membership-plans" className="w-full py-20">
<PricingCardNine
title="Choose Your Perfect Plan"
description="All membership plans include priority scheduling, exclusive discounts, and premium customer support. Upgrade or downgrade anytime."
tag="Subscription Plans"
tagIcon={Sparkles}
tagAnimation="slide-up"
plans={[
{
id: "starter",
title: "Starter",
price: "₱2,999",
period: "per month",
features: [
"4 premium washes per month",
"Priority scheduling",
"10% product discount",
"Email support",
],
button: {
text: "Start Now",
href: "#subscribe-starter",
},
imageSrc: "http://img.b2bpic.net/free-vector/flat-price-list-collection_23-2148092234.jpg",
imageAlt: "Starter membership plan",
},
{
id: "pro",
title: "Pro",
price: "₱5,999",
period: "per month",
features: [
"Unlimited premium washes",
"VIP scheduling priority",
"20% product discount",
"Free engine cleaning monthly",
"Phone & email support",
],
button: {
text: "Go Pro",
href: "#subscribe-pro",
},
imageSrc: "http://img.b2bpic.net/free-vector/landing-page-template-with-subscription-plans_23-2147782752.jpg",
imageAlt: "Pro membership plan",
},
{
id: "elite",
title: "Elite",
price: "₱9,999",
period: "per month",
features: [
"Unlimited premium washes",
"VIP lounge access",
"30% product discount",
"Monthly interior detail included",
"Concierge priority support",
"Mobile service included",
],
button: {
text: "Join Elite",
href: "#subscribe-elite",
},
imageSrc: "http://img.b2bpic.net/free-vector/golden-elegant-wedding-landing-page-template_23-2148221018.jpg",
imageAlt: "Elite membership plan",
},
]}
animationType="reveal-blur"
textboxLayout="default"
useInvertedBackground={true}
buttons={[
{
text: "Explore All Plans",
href: "#plans",
},
]}
buttonAnimation="slide-up"
ariaLabel="Membership pricing plans section"
/>
</div>
<div id="membership-testimonials" data-section="membership-testimonials" className="w-full py-20">
<TestimonialCardThirteen
title="Members Love Ventian"
description="Hear from our premium members about the value and experience they've gained from their Ventian membership."
tag="Member Stories"
tagIcon={Star}
tagAnimation="slide-up"
testimonials={[
{
id: "testimonial-pro",
name: "Carlos Tan",
handle: "Pro Member",
testimonial: "The Pro membership paid for itself in the first month. Unlimited washes, priority scheduling, and the customer service is exceptional. Best investment for my car.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/portrait-handsome-man-jacket-with-wrist-watch-his-hand_613910-11237.jpg",
imageAlt: "Pro member Carlos",
},
{
id: "testimonial-elite",
name: "Maria Santos",
handle: "Elite Member",
testimonial: "Elite membership is worth every peso. The VIP lounge, monthly interior detail, and mobile service make car care effortless. Highly recommended.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/portrait-happy-young-woman_23-2147823623.jpg",
imageAlt: "Elite member Maria",
},
{
id: "testimonial-starter",
name: "Juan dela Cruz",
handle: "Starter Member",
testimonial: "Started with the Starter plan and upgraded to Pro. The value is incredible. Every wash is perfect, and the team really cares about your vehicle.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/smiling-elegant-man-with-one-fist-raised_23-2147574146.jpg",
imageAlt: "Starter member Juan",
},
]}
showRating={true}
animationType="reveal-blur"
textboxLayout="default"
useInvertedBackground={false}
buttons={[
{
text: "Read More Reviews",
href: "#all-reviews",
},
]}
buttonAnimation="slide-up"
ariaLabel="Member testimonials section"
/>
</div>
<div id="membership-cta" data-section="membership-cta" className="w-full py-16">
<ContactCTA
tag="Limited Time Offer"
tagIcon={Sparkles}
tagAnimation="slide-up"
title="Join Premium Members Today"
description="Start your Ventian membership journey. Choose your plan, join our community, and experience the premium difference in every wash."
buttons={[
{
text: "Select Your Plan",
href: "#plans",
},
{
text: "Contact Sales",
href: "#contact",
},
]}
buttonAnimation="slide-up"
background={{
variant: "circleGradient",
}}
useInvertedBackground={false}
ariaLabel="Membership final CTA section"
/>
</div>
<div id="footer" data-section="footer" className="w-full">
<FooterLogoEmphasis
logoText="Ventian"
columns={[
{
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: "Why Steam", href: "/why-steam-carwash" },
{ label: "Membership", href: "/membership" },
{ label: "Blog", href: "/blog" },
],
},
{
items: [
{ label: "About Us", href: "/about" },
{ label: "Locations", href: "/locations" },
{ label: "Franchise", href: "/franchise" },
{ label: "Careers", href: "/careers" },
{ label: "Contact", href: "/contact" },
],
},
{
items: [
{ label: "Facebook", href: "https://facebook.com/ventian" },
{ label: "Instagram", href: "https://instagram.com/ventian" },
{ label: "TikTok", href: "https://tiktok.com/@ventian" },
{ label: "LinkedIn", href: "https://linkedin.com/company/ventian" },
{ label: "YouTube", href: "https://youtube.com/@ventian" },
],
},
{
items: [
{ label: "Privacy Policy", href: "#privacy" },
{ label: "Terms of Service", href: "#terms" },
{ label: "Cookie Policy", href: "#cookies" },
{ label: "Contact Support", href: "#support" },
{ label: "Feedback", href: "#feedback" },
],
},
]}
ariaLabel="Site footer with navigation and links"
/>
</div>
</ThemeProvider>
);
}

634
src/app/page.tsx Normal file
View File

@@ -0,0 +1,634 @@
"use client";
import Link from "next/link";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarLayoutFloatingInline from "@/components/navbar/NavbarLayoutFloatingInline";
import HeroBillboardRotatedCarousel from "@/components/sections/hero/HeroBillboardRotatedCarousel";
import AboutMetric from "@/components/sections/about/AboutMetric";
import FeatureCardTwentySeven from "@/components/sections/feature/FeatureCardTwentySeven";
import ProductCardOne from "@/components/sections/product/ProductCardOne";
import PricingCardNine from "@/components/sections/pricing/PricingCardNine";
import MetricCardTen from "@/components/sections/metrics/MetricCardTen";
import TestimonialCardThirteen from "@/components/sections/testimonial/TestimonialCardThirteen";
import ContactCTA from "@/components/sections/contact/ContactCTA";
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
import { Sparkles, CheckCircle, Users, Zap, Shield, Award, Star } from "lucide-react";
export default function HomePage() {
const navItems = [
{ name: "Home", id: "home" },
{ name: "Services", id: "services" },
{ name: "Why Steam", id: "why-steam" },
{ name: "Membership", id: "membership" },
{ name: "Franchise", id: "franchise" },
{ name: "About", id: "about" },
{ name: "Contact", id: "contact" },
];
const footerColumns = [
{
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: "Why Steam", href: "#why-steam" },
{ label: "Membership", href: "#membership" },
{ label: "Blog", href: "#blog" },
],
},
{
items: [
{ label: "About Us", href: "#about" },
{ label: "Locations", href: "#locations" },
{ label: "Franchise", href: "#franchise" },
{ label: "Careers", href: "#careers" },
{ label: "Contact", href: "#contact" },
],
},
{
items: [
{ label: "Facebook", href: "https://facebook.com/ventian" },
{ label: "Instagram", href: "https://instagram.com/ventian" },
{ label: "TikTok", href: "https://tiktok.com/@ventian" },
{ label: "LinkedIn", href: "https://linkedin.com/company/ventian" },
{ label: "YouTube", href: "https://youtube.com/@ventian" },
],
},
{
items: [
{ label: "Privacy Policy", href: "#privacy" },
{ label: "Terms of Service", href: "#terms" },
{ label: "Cookie Policy", href: "#cookies" },
{ label: "Contact Support", href: "#support" },
{ label: "Feedback", href: "#feedback" },
],
},
];
return (
<ThemeProvider
defaultButtonVariant="shift-hover"
defaultTextAnimation="reveal-blur"
borderRadius="rounded"
contentWidth="smallMedium"
sizing="mediumSizeLargeTitles"
background="circleGradient"
cardStyle="inset"
primaryButtonStyle="diagonal-gradient"
secondaryButtonStyle="solid"
headingFontWeight="light"
>
{/* Navbar */}
<div id="nav" data-section="nav">
<NavbarLayoutFloatingInline
brandName="Ventian"
navItems={navItems}
button={{
text: "Book Now",
href: "#book-now",
}}
/>
</div>
{/* Hero Section */}
<div id="hero" data-section="hero" className="w-full overflow-hidden">
<HeroBillboardRotatedCarousel
title="Premium Steam Car Care, Redefined"
description="Experience the future of automotive detailing. Ventian combines cutting-edge steam technology with eco-conscious practices to deliver the cleanest, safest car care experience in the Philippines. Clean. Premium. Convenient."
tag="Premium Steam Technology"
tagIcon={Sparkles}
tagAnimation="slide-up"
buttons={[
{
text: "Book Your Wash",
href: "#book-now",
},
{
text: "View Services",
href: "/services",
},
]}
buttonAnimation="slide-up"
carouselItems={[
{
id: "hero-1",
imageSrc: "http://img.b2bpic.net/free-photo/happy-brides-are-kissing-car_8353-9489.jpg",
imageAlt: "Premium steam carwash in progress with glossy vehicle finish",
},
{
id: "hero-2",
imageSrc: "http://img.b2bpic.net/free-photo/happy-black-mechanic-taking-notes-while-talking-customer-auto-repair-shop_637285-11587.jpg",
imageAlt: "Modern Ventian carwash facility with luxury interior",
},
{
id: "hero-3",
imageSrc: "http://img.b2bpic.net/free-photo/mechanic-smiling-while-examining-car-engine-with-lamp_1170-1292.jpg",
imageAlt: "Professional detailer using advanced steam technology",
},
{
id: "hero-4",
imageSrc: "http://img.b2bpic.net/free-photo/man-gray-sweater-wipes-car-car-wash_1157-35957.jpg",
imageAlt: "Perfectly detailed vehicle with premium shine and finish",
},
{
id: "hero-5",
imageSrc: "http://img.b2bpic.net/free-photo/man-washing-his-car-garage_1157-26053.jpg",
imageAlt: "Ventian mobile steam carwash service at customer location",
},
{
id: "hero-6",
imageSrc: "http://img.b2bpic.net/free-photo/south-asian-man-indian-male-washing-his-white-transportation-car-wash_627829-5016.jpg",
imageAlt: "Eco-friendly steam carwash process with water conservation",
},
]}
autoPlay={true}
autoPlayInterval={5000}
background={{
variant: "radial-gradient",
}}
ariaLabel="Ventian hero section - premium steam carwash showcase"
/>
</div>
{/* Trust Indicators */}
<div id="trust-indicators" data-section="trust-indicators" className="w-full py-16">
<AboutMetric
title="Trusted by Premium Car Owners"
metrics={[
{
icon: CheckCircle,
label: "Years of Excellence",
value: "10+",
},
{
icon: Users,
label: "Satisfied Customers",
value: "50K+",
},
{
icon: Zap,
label: "Vehicles Cleaned",
value: "100K+",
},
{
icon: Shield,
label: "Eco-Friendly Washes",
value: "99%",
},
]}
metricsAnimation="slide-up"
useInvertedBackground={false}
ariaLabel="Trust metrics section"
/>
</div>
{/* Why Ventian */}
<div id="why-ventian" data-section="why-ventian" className="w-full py-20">
<FeatureCardTwentySeven
title="Why Ventian Stands Apart"
description="We're not just a carwash. Ventian is a complete automotive care ecosystem built on precision, sustainability, and innovation. Every detail matters."
tag="Premium Advantage"
tagIcon={Award}
tagAnimation="slide-up"
features={[
{
id: "premium-tech",
title: "Advanced Steam Technology",
descriptions: [
"Precision cleaning without harsh chemicals",
"Gentle on all vehicle surfaces and finishes",
"Removes dirt, grime, and contaminants effectively",
],
imageSrc: "http://img.b2bpic.net/free-photo/front-view-clear-water-drops-surface_23-2148635097.jpg",
},
{
id: "eco-friendly",
title: "Eco-Conscious Process",
descriptions: [
"Up to 80% less water than traditional carwashes",
"Zero harmful chemical runoff",
"Sustainable practices throughout operations",
],
imageSrc: "http://img.b2bpic.net/free-vector/flat-world-water-day-background_23-2149296753.jpg",
},
{
id: "hygiene-safety",
title: "Uncompromising Hygiene",
descriptions: [
"Medical-grade sanitization protocols",
"Touchless technology where applicable",
"Premium care for you and your vehicle",
],
imageSrc: "http://img.b2bpic.net/free-photo/portrait-doctor-taking-medicine-samples-with-mask-gloves-protective-suit_176474-3613.jpg",
},
{
id: "professional-team",
title: "Expert Technicians",
descriptions: [
"Intensively trained steam car care specialists",
"Passionate about automotive excellence",
"Consistent quality on every visit",
],
imageSrc: "http://img.b2bpic.net/free-photo/man-washing-his-car-garage_1157-26070.jpg",
},
{
id: "tech-enabled",
title: "Technology-Driven",
descriptions: [
"Easy online booking and scheduling",
"Real-time service tracking",
"Premium customer experience platform",
],
imageSrc: "http://img.b2bpic.net/free-photo/futuristic-mean-transportation-ultra-modern-city_23-2151065440.jpg",
},
{
id: "convenience",
title: "Ultimate Convenience",
descriptions: [
"Shop and mobile service options",
"Flexible scheduling 7 days a week",
"Service your vehicle your way",
],
imageSrc: "http://img.b2bpic.net/free-photo/mid-section-businessman-using-smartwatch-while-travelling_107420-96246.jpg",
},
]}
gridVariant="three-columns-all-equal-width"
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={true}
buttons={[
{
text: "Learn More",
href: "#why-steam",
},
]}
buttonAnimation="slide-up"
ariaLabel="Why Ventian features section"
/>
</div>
{/* Services Overview */}
<div id="services-overview" data-section="services-overview" className="w-full py-16">
<ProductCardOne
title="Our Premium Services"
description="Choose the perfect service for your vehicle. From quick refreshes to complete detailing, we have the premium solution."
tag="Service Packages"
tagIcon={Zap}
tagAnimation="slide-up"
products={[
{
id: "premium-wash",
name: "Premium Steam Wash",
price: "₱1,299",
imageSrc: "http://img.b2bpic.net/free-photo/auto-mechanic-businesswoman-cooperating-while-checking-vehicle-hood-workshop_637285-7688.jpg",
imageAlt: "Premium complete steam wash package",
},
{
id: "basic-exterior",
name: "Exterior Steam Wash",
price: "₱799",
imageSrc: "http://img.b2bpic.net/free-photo/car-wash-detailing-station_1303-22299.jpg",
imageAlt: "Basic exterior steam carwash service",
},
{
id: "interior-detail",
name: "Interior Detailing",
price: "₱1,499",
imageSrc: "http://img.b2bpic.net/free-photo/medium-shot-workers-with-cleaning-cart_23-2149345519.jpg",
imageAlt: "Professional interior cleaning and detailing",
},
{
id: "engine-steam",
name: "Engine Steam Cleaning",
price: "₱599",
imageSrc: "http://img.b2bpic.net/free-photo/car-wash-detailing-station_1303-22305.jpg",
imageAlt: "Professional engine compartment steam cleaning",
},
]}
gridVariant="four-items-2x2-equal-grid"
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={false}
buttons={[
{
text: "View All Services",
href: "/services",
},
]}
buttonAnimation="slide-up"
ariaLabel="Services overview section"
/>
</div>
{/* Membership Plans */}
<div id="membership" data-section="membership" className="w-full py-20">
<PricingCardNine
title="Premium Membership Plans"
description="Join the Ventian family and enjoy unlimited premium carwash benefits. Flexible plans designed for every lifestyle."
tag="Subscription Plans"
tagIcon={Sparkles}
tagAnimation="slide-up"
plans={[
{
id: "starter",
title: "Starter",
price: "₱2,999",
period: "per month",
features: [
"4 premium washes per month",
"Priority scheduling",
"10% product discount",
"Email support",
],
button: {
text: "Start Now",
href: "#subscribe-starter",
},
imageSrc: "http://img.b2bpic.net/free-vector/flat-price-list-collection_23-2148092234.jpg",
},
{
id: "pro",
title: "Pro",
price: "₱5,999",
period: "per month",
features: [
"Unlimited premium washes",
"VIP scheduling priority",
"20% product discount",
"Free engine cleaning monthly",
"Phone & email support",
],
button: {
text: "Go Pro",
href: "#subscribe-pro",
},
imageSrc: "http://img.b2bpic.net/free-vector/landing-page-template-with-subscription-plans_23-2147782752.jpg",
},
{
id: "elite",
title: "Elite",
price: "₱9,999",
period: "per month",
features: [
"Unlimited premium washes",
"VIP lounge access",
"30% product discount",
"Monthly interior detail included",
"Concierge priority support",
"Mobile service included",
],
button: {
text: "Join Elite",
href: "#subscribe-elite",
},
imageSrc: "http://img.b2bpic.net/free-vector/golden-elegant-wedding-landing-page-template_23-2148221018.jpg",
},
]}
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={true}
buttons={[
{
text: "Compare Plans",
href: "#membership",
},
]}
buttonAnimation="slide-up"
ariaLabel="Membership plans section"
/>
</div>
{/* Hygiene & Safety Standards */}
<div id="hygiene-care" data-section="hygiene-care" className="w-full py-16">
<FeatureCardTwentySeven
title="Premium Hygiene & Safety Standards"
description="Your health matters. Our process prioritizes safety and cleanliness at every step with medical-grade protocols and eco-friendly solutions."
tag="Health & Safety"
tagIcon={Shield}
tagAnimation="slide-up"
features={[
{
id: "sanitization",
title: "Medical-Grade Sanitization",
descriptions: [
"All equipment sanitized between uses",
"EPA-approved cleaning agents",
"Exceeds industry hygiene standards",
],
imageSrc: "http://img.b2bpic.net/free-photo/woman-mask-stands-street_1157-35893.jpg",
},
{
id: "steam-power",
title: "Power of Steam Technology",
descriptions: [
"High-temperature steam eliminates bacteria",
"No toxic chemical residue",
"Gentle yet effective cleaning",
],
imageSrc: "http://img.b2bpic.net/free-photo/man-sitting-floor-with-suit_23-2148563364.jpg",
},
{
id: "water-quality",
title: "Purified Water Systems",
descriptions: [
"Multi-stage water filtration",
"Clean, safe water for every wash",
"Reduces environmental impact",
],
imageSrc: "http://img.b2bpic.net/free-photo/woman-with-water-soft-bed_23-2147765060.jpg",
},
]}
gridVariant="three-columns-all-equal-width"
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={false}
ariaLabel="Hygiene and safety standards section"
/>
</div>
{/* Results */}
<div id="results" data-section="results" className="w-full py-20">
<MetricCardTen
title="Real Results That Speak"
description="See the transformation. Before and after comparisons showcase the premium difference Ventian delivers."
tag="Results Gallery"
tagIcon={CheckCircle}
tagAnimation="slide-up"
metrics={[
{
id: "result-1",
title: "Sedan Transformation",
subtitle: "5-year-old family sedan",
category: "Premium Wash",
value: "Showroom Shine",
buttons: [
{
text: "View Details",
href: "#result-1",
},
],
},
{
id: "result-2",
title: "SUV Deep Detail",
subtitle: "Luxury 4x4 vehicle",
category: "Full Detail Package",
value: "Like-New Finish",
buttons: [
{
text: "View Details",
href: "#result-2",
},
],
},
{
id: "result-3",
title: "Sports Car Polish",
subtitle: "Premium performance vehicle",
category: "Elite Detailing",
value: "Pristine Condition",
buttons: [
{
text: "View Details",
href: "#result-3",
},
],
},
{
id: "result-4",
title: "Fleet Maintenance",
subtitle: "Corporate vehicle fleet",
category: "Scheduled Service",
value: "Professional Standard",
buttons: [
{
text: "View Details",
href: "#result-4",
},
],
},
]}
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={true}
buttons={[
{
text: "See Gallery",
href: "#results-gallery",
},
]}
buttonAnimation="slide-up"
ariaLabel="Results and before-after gallery section"
/>
</div>
{/* Testimonials */}
<div id="testimonials" data-section="testimonials" className="w-full py-16">
<TestimonialCardThirteen
title="Loved by Car Owners Across the Philippines"
description="Real stories from real customers who trust Ventian for their premium car care needs."
tag="Customer Reviews"
tagIcon={Star}
tagAnimation="slide-up"
testimonials={[
{
id: "testimonial-1",
name: "Maria Santos",
handle: "Car Enthusiast",
testimonial: "Ventian transformed my vehicle. The attention to detail and professionalism is unmatched. I've been a member for 2 years and wouldn't go anywhere else.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/portrait-happy-young-woman_23-2147823623.jpg",
},
{
id: "testimonial-2",
name: "Juan dela Cruz",
handle: "Business Owner",
testimonial: "As someone who values time, Ventian's mobile service is a game-changer. Professional, convenient, and the quality is premium. Highly recommended.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/smiling-elegant-man-with-one-fist-raised_23-2147574146.jpg",
},
{
id: "testimonial-3",
name: "Angela Reyes",
handle: "Luxury Vehicle Owner",
testimonial: "I trust Ventian with my luxury car because they understand premium. Every wash is perfect. Their expertise and care are exceptional.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/young-female-sitting-couch_23-2148415754.jpg",
},
{
id: "testimonial-4",
name: "Robert Garcia",
handle: "Fleet Manager",
testimonial: "Managing a company fleet is stressful, but Ventian makes it easy. Reliable, professional, and cost-effective. Our vehicles look pristine always.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/portrait-outdoors-business-man-smiles_23-2148763856.jpg",
},
{
id: "testimonial-5",
name: "Lisa Mercado",
handle: "Environmental Advocate",
testimonial: "Finally, a carwash that cares about the environment. Ventian's eco-friendly approach aligns with my values. Great service, clean conscience.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/pretty-woman-holding-sandtimer_1187-3665.jpg",
},
{
id: "testimonial-6",
name: "Carlos Tan",
handle: "Premium Member",
testimonial: "The membership is worth every peso. Unlimited washes, VIP treatment, and the elite lounge is fantastic. Ventian truly gets it.",
rating: 5,
imageSrc: "http://img.b2bpic.net/free-photo/portrait-handsome-man-jacket-with-wrist-watch-his-hand_613910-11237.jpg",
},
]}
showRating={true}
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={false}
buttons={[
{
text: "Read More Reviews",
href: "#all-reviews",
},
]}
buttonAnimation="slide-up"
ariaLabel="Customer testimonials section"
/>
</div>
{/* Franchise Teaser */}
<div id="franchise-teaser" data-section="franchise-teaser" className="w-full py-16">
<ContactCTA
tag="Franchise Opportunity"
tagIcon={Zap}
tagAnimation="slide-up"
title="Ready to Grow With Us?"
description="Ventian is expanding nationwide. Join a premium franchise network backed by proven systems, training, and support. Build your business with a brand trusted by thousands."
buttons={[
{
text: "Explore Franchise",
href: "#franchise",
},
{
text: "Contact Now",
href: "#contact-form",
},
]}
buttonAnimation="slide-up"
background={{
variant: "radial-gradient",
}}
useInvertedBackground={true}
ariaLabel="Franchise opportunity CTA section"
/>
</div>
{/* Footer */}
<div id="footer" data-section="footer" className="w-full">
<FooterLogoEmphasis
logoText="Ventian"
columns={footerColumns}
ariaLabel="Site footer with navigation and links"
/>
</div>
</ThemeProvider>
);
}

239
src/app/services/page.tsx Normal file
View File

@@ -0,0 +1,239 @@
"use client";
import Link from "next/link";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarLayoutFloatingInline from "@/components/navbar/NavbarLayoutFloatingInline";
import FeatureCardTwentySeven from "@/components/sections/feature/FeatureCardTwentySeven";
import ProductCardOne from "@/components/sections/product/ProductCardOne";
import ContactCTA from "@/components/sections/contact/ContactCTA";
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
import { Zap, Sparkles, CheckCircle } from "lucide-react";
export default function ServicesPage() {
const navItems = [
{ name: "Home", id: "home" },
{ name: "Services", id: "services" },
{ name: "Why Steam", id: "why-steam" },
{ name: "Membership", id: "membership" },
{ name: "Franchise", id: "franchise" },
{ name: "About", id: "about" },
{ name: "Contact", id: "contact" },
];
const footerColumns = [
{
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: "Why Steam", href: "#why-steam" },
{ label: "Membership", href: "#membership" },
{ label: "Blog", href: "#blog" },
],
},
{
items: [
{ label: "About Us", href: "#about" },
{ label: "Locations", href: "#locations" },
{ label: "Franchise", href: "#franchise" },
{ label: "Careers", href: "#careers" },
{ label: "Contact", href: "#contact" },
],
},
{
items: [
{ label: "Facebook", href: "https://facebook.com/ventian" },
{ label: "Instagram", href: "https://instagram.com/ventian" },
{ label: "TikTok", href: "https://tiktok.com/@ventian" },
{ label: "LinkedIn", href: "https://linkedin.com/company/ventian" },
{ label: "YouTube", href: "https://youtube.com/@ventian" },
],
},
{
items: [
{ label: "Privacy Policy", href: "#privacy" },
{ label: "Terms of Service", href: "#terms" },
{ label: "Cookie Policy", href: "#cookies" },
{ label: "Contact Support", href: "#support" },
{ label: "Feedback", href: "#feedback" },
],
},
];
return (
<ThemeProvider
defaultButtonVariant="shift-hover"
defaultTextAnimation="reveal-blur"
borderRadius="rounded"
contentWidth="smallMedium"
sizing="mediumSizeLargeTitles"
background="circleGradient"
cardStyle="inset"
primaryButtonStyle="diagonal-gradient"
secondaryButtonStyle="solid"
headingFontWeight="light"
>
{/* Navbar */}
<div id="nav" data-section="nav">
<NavbarLayoutFloatingInline
brandName="Ventian"
navItems={navItems}
button={{
text: "Book Now",
href: "#book-now",
}}
/>
</div>
{/* Services Detail - Extended Catalog */}
<div id="services-detail" data-section="services-detail" className="w-full py-20">
<ProductCardOne
title="Complete Service Catalog"
description="Explore our complete range of premium steam carwash and detailing services. Every service is tailored to deliver maximum protection and shine for your vehicle."
tag="Full Service Menu"
tagIcon={Zap}
tagAnimation="slide-up"
products={[
{
id: "premium-wash",
name: "Premium Steam Wash",
price: "₱1,299",
imageSrc: "http://img.b2bpic.net/free-photo/auto-mechanic-businesswoman-cooperating-while-checking-vehicle-hood-workshop_637285-7688.jpg",
imageAlt: "Premium complete steam wash package",
},
{
id: "basic-exterior",
name: "Exterior Steam Wash",
price: "₱799",
imageSrc: "http://img.b2bpic.net/free-photo/car-wash-detailing-station_1303-22299.jpg",
imageAlt: "Basic exterior steam carwash service",
},
{
id: "interior-detail",
name: "Interior Detailing",
price: "₱1,499",
imageSrc: "http://img.b2bpic.net/free-photo/medium-shot-workers-with-cleaning-cart_23-2149345519.jpg",
imageAlt: "Professional interior cleaning and detailing",
},
{
id: "engine-steam",
name: "Engine Steam Cleaning",
price: "₱599",
imageSrc: "http://img.b2bpic.net/free-photo/car-wash-detailing-station_1303-22305.jpg",
imageAlt: "Professional engine compartment steam cleaning",
},
{
id: "wheel-detail",
name: "Wheel & Tire Detail",
price: "₱499",
imageSrc: "http://img.b2bpic.net/free-photo/car-wash-detailing-station_1303-22299.jpg",
imageAlt: "Premium wheel and tire cleaning service",
},
{
id: "ceramic-coat",
name: "Ceramic Coating Application",
price: "₱2,999",
imageSrc: "http://img.b2bpic.net/free-photo/happy-black-mechanic-taking-notes-while-talking-customer-auto-repair-shop_637285-11587.jpg",
imageAlt: "Professional ceramic coating for long-lasting protection",
},
]}
gridVariant="three-columns-all-equal-width"
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={false}
buttons={[
{
text: "Book a Service",
href: "#book-service",
},
]}
buttonAnimation="slide-up"
ariaLabel="Complete services catalog"
/>
</div>
{/* Service Features & Benefits */}
<div id="service-benefits" data-section="service-benefits" className="w-full py-16">
<FeatureCardTwentySeven
title="What Makes Our Services Premium"
description="Each service is backed by our commitment to excellence, using only the finest equipment and techniques to protect your vehicle investment."
tag="Service Excellence"
tagIcon={CheckCircle}
tagAnimation="slide-up"
features={[
{
id: "quality-standards",
title: "Unmatched Quality Standards",
descriptions: [
"ISO-certified processes and equipment",
"Regular technician training and certification",
"100% customer satisfaction guarantee",
],
imageSrc: "http://img.b2bpic.net/free-photo/mechanic-smiling-while-examining-car-engine-with-lamp_1170-1292.jpg",
},
{
id: "eco-protection",
title: "Environmental Protection",
descriptions: [
"Water-safe processes protect surrounding ecosystems",
"Biodegradable cleaning solutions only",
"Sustainable waste management practices",
],
imageSrc: "http://img.b2bpic.net/free-vector/flat-world-water-day-background_23-2149296753.jpg",
},
{
id: "fast-results",
title: "Quick Turnaround",
descriptions: [
"Express services available same-day",
"Efficient scheduling maximizes your time",
"Premium results without the wait",
],
imageSrc: "http://img.b2bpic.net/free-photo/man-gray-sweater-wipes-car-car-wash_1157-35957.jpg",
},
]}
gridVariant="three-columns-all-equal-width"
animationType="slide-up"
textboxLayout="default"
useInvertedBackground={true}
ariaLabel="Service benefits and features"
/>
</div>
{/* Service Booking CTA */}
<div id="service-booking-cta" data-section="service-booking-cta" className="w-full py-16">
<ContactCTA
tag="Premium Services"
tagIcon={Sparkles}
tagAnimation="slide-up"
title="Ready to Elevate Your Vehicle?"
description="Choose the service that matches your needs and experience the Ventian difference. Professional care for every vehicle type and condition."
buttons={[
{
text: "Book Your Service",
href: "#book-now",
},
{
text: "Explore Membership",
href: "#membership",
},
]}
buttonAnimation="slide-up"
background={{
variant: "circleGradient",
}}
useInvertedBackground={false}
ariaLabel="Service booking CTA section"
/>
</div>
{/* Footer */}
<div id="footer" data-section="footer" className="w-full">
<FooterLogoEmphasis
logoText="Ventian"
columns={footerColumns}
ariaLabel="Site footer with navigation and links"
/>
</div>
</ThemeProvider>
);
}

28
src/app/styles/base.css Normal file
View File

@@ -0,0 +1,28 @@
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 1) rgba(255, 255, 255, 0);
}
html {
overscroll-behavior: none;
overscroll-behavior-y: none;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-figtree), sans-serif;
position: relative;
min-height: 100vh;
overscroll-behavior: none;
overscroll-behavior-y: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-figtree), sans-serif;
}

176
src/app/styles/theme.css Normal file
View File

@@ -0,0 +1,176 @@
@theme inline {
--color-background: var(--background);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-primary-cta: var(--primary-cta);
--color-primary-cta-text: var(--primary-cta-text);
--color-secondary-cta: var(--secondary-cta);
--color-secondary-cta-text: var(--secondary-cta-text);
--color-accent: var(--accent);
--color-background-accent: var(--background-accent);
/* theme border radius */
--radius-theme: var(--theme-border-radius);
--radius-theme-capped: var(--theme-border-radius-capped);
/* text */
--text-2xs: var(--text-2xs);
--text-xs: var(--text-xs);
--text-sm: var(--text-sm);
--text-base: var(--text-base);
--text-lg: var(--text-lg);
--text-xl: var(--text-xl);
--text-2xl: var(--text-2xl);
--text-3xl: var(--text-3xl);
--text-4xl: var(--text-4xl);
--text-5xl: var(--text-5xl);
--text-6xl: var(--text-6xl);
--text-7xl: var(--text-7xl);
--text-8xl: var(--text-8xl);
--text-9xl: var(--text-9xl);
/* height */
--height-4: var(--height-4);
--height-5: var(--height-5);
--height-6: var(--height-6);
--height-7: var(--height-7);
--height-8: var(--height-8);
--height-9: var(--height-9);
--height-11: var(--height-11);
--height-12: var(--height-12);
--height-10: var(--height-10);
--height-30: var(--height-30);
--height-90: var(--height-90);
--height-100: var(--height-100);
--height-110: var(--height-110);
--height-120: var(--height-120);
--height-130: var(--height-130);
--height-140: var(--height-140);
--height-150: var(--height-150);
--height-page-padding: calc(2.25rem+var(--vw-1_5)+var(--vw-1_5));
/* width */
--width-5: var(--width-5);
--width-7_5: var(--width-7_5);
--width-10: var(--width-10);
--width-12_5: var(--width-12_5);
--width-15: var(--width-15);
--width-17: var(--width-17);
--width-17_5: var(--width-17_5);
--width-20: var(--width-20);
--width-21: var(--width-21);
--width-22_5: var(--width-22_5);
--width-25: var(--width-25);
--width-26: var(--width-26);
--width-27_5: var(--width-27_5);
--width-30: var(--width-30);
--width-32_5: var(--width-32_5);
--width-35: var(--width-35);
--width-37_5: var(--width-37_5);
--width-40: var(--width-40);
--width-42_5: var(--width-42_5);
--width-45: var(--width-45);
--width-47_5: var(--width-47_5);
--width-50: var(--width-50);
--width-52_5: var(--width-52_5);
--width-55: var(--width-55);
--width-57_5: var(--width-57_5);
--width-60: var(--width-60);
--width-62_5: var(--width-62_5);
--width-65: var(--width-65);
--width-67_5: var(--width-67_5);
--width-70: var(--width-70);
--width-72_5: var(--width-72_5);
--width-75: var(--width-75);
--width-77_5: var(--width-77_5);
--width-80: var(--width-80);
--width-82_5: var(--width-82_5);
--width-85: var(--width-85);
--width-87_5: var(--width-87_5);
--width-90: var(--width-90);
--width-92_5: var(--width-92_5);
--width-95: var(--width-95);
--width-97_5: var(--width-97_5);
--width-100: var(--width-100);
--width-content-width: var(--width-content-width);
--width-carousel-padding: var(--width-carousel-padding);
--width-carousel-padding-controls: var(--width-carousel-padding-controls);
--width-carousel-padding-expanded: var(--width-carousel-padding-expanded);
--width-carousel-padding-controls-expanded: var(--width-carousel-padding-controls-expanded);
--width-carousel-item-3: var(--width-carousel-item-3);
--width-carousel-item-4: var(--width-carousel-item-4);
--width-x-padding-mask-fade: var(--width-x-padding-mask-fade);
--width-content-width-expanded: var(--width-content-width-expanded);
/* gap */
--spacing-1: var(--vw-0_25);
--spacing-2: var(--vw-0_5);
--spacing-3: var(--vw-0_75);
--spacing-4: var(--vw-1);
--spacing-5: var(--vw-1_25);
--spacing-6: var(--vw-1_5);
--spacing-7: var(--vw-1_75);
--spacing-8: var(--vw-2);
--spacing-x-1: var(--vw-0_25);
--spacing-x-2: var(--vw-0_5);
--spacing-x-3: var(--vw-0_75);
--spacing-x-4: var(--vw-1);
--spacing-x-5: var(--vw-1_25);
--spacing-x-6: var(--vw-1_5);
/* border radius */
--radius-none: 0;
--radius-sm: var(--vw-0_5);
--radius: var(--vw-0_75);
--radius-md: var(--vw-1);
--radius-lg: var(--vw-1_25);
--radius-xl: var(--vw-1_75);
--radius-full: 999px;
/* padding */
--padding-1: var(--vw-0_25);
--padding-2: var(--vw-0_5);
--padding-2.5: var(--vw-0_625);
--padding-3: var(--vw-0_75);
--padding-4: var(--vw-1);
--padding-5: var(--vw-1_25);
--padding-6: var(--vw-1_5);
--padding-7: var(--vw-1_75);
--padding-8: var(--vw-2);
--padding-x-1: var(--vw-0_25);
--padding-x-2: var(--vw-0_5);
--padding-x-3: var(--vw-0_75);
--padding-x-4: var(--vw-1);
--padding-x-5: var(--vw-1_25);
--padding-x-6: var(--vw-1_5);
--padding-x-7: var(--vw-1_75);
--padding-x-8: var(--vw-2);
--padding-hero-page-padding-half: var(--padding-hero-page-padding-half);
--padding-hero-page-padding: var(--padding-hero-page-padding);
--padding-hero-page-padding-1_5: var(--padding-hero-page-padding-1_5);
--padding-hero-page-padding-double: var(--padding-hero-page-padding-double);
/* margin */
--margin-1: var(--vw-0_25);
--margin-2: var(--vw-0_5);
--margin-3: var(--vw-0_75);
--margin-4: var(--vw-1);
--margin-5: var(--vw-1_25);
--margin-6: var(--vw-1_5);
--margin-7: var(--vw-1_75);
--margin-8: var(--vw-2);
--margin-x-1: var(--vw-0_25);
--margin-x-2: var(--vw-0_5);
--margin-x-3: var(--vw-0_75);
--margin-x-4: var(--vw-1);
--margin-x-5: var(--vw-1_25);
--margin-x-6: var(--vw-1_5);
--margin-x-7: var(--vw-1_75);
--margin-x-8: var(--vw-2);
}

View File

@@ -0,0 +1,228 @@
@layer components {}
@layer utilities {
/* Card, primary-button, and secondary-button styles are now dynamically injected via ThemeProvider */
/* .card {
@apply backdrop-blur-sm bg-gradient-to-br from-card/80 to-card/40 shadow-sm border border-card;
}
.primary-button {
@apply bg-gradient-to-b from-primary-cta/83 to-primary-cta;
box-shadow:
color-mix(in srgb, var(--color-background) 25%, transparent) 0px 1px 1px 0px inset,
color-mix(in srgb, var(--color-primary-cta) 15%, transparent) 3px 3px 3px 0px;
}
.secondary-button {
@apply backdrop-blur-sm bg-gradient-to-br from-secondary-cta/80 to-secondary-cta shadow-sm border border-secondary-cta;
} */
.tag-card {
@apply backdrop-blur-sm bg-gradient-to-br from-card/80 to-card/40 shadow-sm border border-card;
}
.inset-glow-border {
@apply relative;
}
.inset-glow-border::before {
content: "";
@apply absolute pointer-events-none inset-0 p-[1px];
border-radius: inherit;
background: linear-gradient(
0deg,
color-mix(in srgb, var(--color-primary-cta) 20%, var(--color-background)) 0%,
color-mix(in srgb, var(--color-primary-cta) 40%, var(--color-background)) 27%,
color-mix(in srgb, var(--color-primary-cta) 60%, var(--color-foreground)) 62%,
color-mix(in srgb, var(--color-primary-cta) 80%, var(--color-foreground)) 100%
);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
}
.mask-fade-x {
-webkit-mask-image: linear-gradient(to right, transparent 0%, transparent calc((100vw - var(--width-content-width)) / 4), black calc((100vw - var(--width-content-width)) / 2 + 5vw), black calc(100% - (100vw - var(--width-content-width)) / 2 - 5vw), transparent calc(100% - (100vw - var(--width-content-width)) / 4), transparent 100%);
mask-image: linear-gradient(to right, transparent 0%, transparent calc((100vw - var(--width-content-width)) / 4), black calc((100vw - var(--width-content-width)) / 2 + 5vw), black calc(100% - (100vw - var(--width-content-width)) / 2 - 5vw), transparent calc(100% - (100vw - var(--width-content-width)) / 4), transparent 100%);
}
.mask-padding-x {
-webkit-mask-image: linear-gradient(to right, transparent 0%, black var(--width-x-padding-mask-fade), black calc(100% - var(--width-x-padding-mask-fade)), transparent 100%);
mask-image: linear-gradient(to right, transparent 0%, black var(--width-x-padding-mask-fade), black calc(100% - var(--width-x-padding-mask-fade)), transparent 100%);
}
.mask-fade-bottom {
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 100%);
}
.mask-fade-y {
mask-image: linear-gradient(to bottom,
transparent 0%,
black var(--vw-1_5),
black calc(100% - var(--vw-1_5)),
transparent 100%);
}
.mask-fade-y {
mask-image: linear-gradient(to bottom,
transparent 0%,
black var(--vw-1_5),
black calc(100% - var(--vw-1_5)),
transparent 100%);
}
.mask-fade-y-medium {
mask-image: linear-gradient(to bottom,
transparent 0%,
black 20%,
black 80%,
transparent 100%);
}
.mask-fade-bottom-large {
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 75%, transparent 100%);
mask-image: linear-gradient(to bottom, black 0%, black 50%, transparent 75%, transparent 100%);
}
.mask-fade-bottom-long {
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 5%, transparent 100%);
mask-image: linear-gradient(to bottom, black 0%, black 5%, transparent 100%);
}
.mask-fade-top-long {
-webkit-mask-image: linear-gradient(to top, black 0%, black 5%, transparent 100%);
mask-image: linear-gradient(to top, black 0%, black 5%, transparent 100%);
}
.mask-fade-xy {
-webkit-mask-image:
linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%),
linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
mask-image:
linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%),
linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
-webkit-mask-composite: source-in;
mask-composite: intersect;
}
/* ANIMATION */
.animation-container {
animation:
fadeInOpacity 0.8s ease-in-out forwards,
fadeInTranslate 0.6s forwards;
}
.animation-container-fade {
animation: fadeInOpacity 0.8s ease-in-out forwards;
}
@keyframes fadeInOpacity {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeInTranslate {
from {
transform: translateY(0.75vh);
}
to {
transform: translateY(0vh);
}
}
@keyframes aurora {
from {
background-position: 50% 50%, 50% 50%;
}
to {
background-position: 350% 50%, 350% 50%;
}
}
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-reverse {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
.animate-spin-slow {
animation: spin-slow 15s linear infinite;
}
.animate-spin-reverse {
animation: spin-reverse 10s linear infinite;
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
.animate-marquee-vertical {
animation: marquee-vertical 40s linear infinite;
}
@keyframes marquee-vertical-reverse {
from {
transform: translateY(-50%);
}
to {
transform: translateY(0);
}
}
.animate-marquee-vertical-reverse {
animation: marquee-vertical-reverse 40s linear infinite;
}
@keyframes orbit {
from {
transform: rotate(var(--initial-position, 0deg)) translateX(var(--translate-position, 120px)) rotate(calc(-1 * var(--initial-position, 0deg)));
}
to {
transform: rotate(calc(var(--initial-position, 0deg) + 360deg)) translateX(var(--translate-position, 120px)) rotate(calc(-1 * (var(--initial-position, 0deg) + 360deg)));
}
}
@keyframes map-dot-pulse {
0%, 100% {
transform: scale(0.4);
opacity: 0.6;
}
50% {
transform: scale(1.4);
opacity: 1;
}
}
}

View File

@@ -0,0 +1,217 @@
:root {
/* Base units */
/* --vw is set by ThemeProvider */
/* --background: #f5f4ef;
--card: #dad6cd;
--foreground: #2a2928;
--primary-cta: #2a2928;
--secondary-cta: #ecebea;
--accent: #ffffff;
--background-accent: #ffffff; */
--background: #f5f5f5;
--card: #ffffff;
--foreground: #1c1c1c;
--primary-cta: #1f514c;
--primary-cta-text: #f5f5f5;
--secondary-cta: #ffffff;
--secondary-cta-text: #1c1c1c;
--accent: #159c49;
--background-accent: #a8e8ba;
/* text sizing - set by ThemeProvider */
/* --text-2xs: clamp(0.465rem, 0.62vw, 0.62rem);
--text-xs: clamp(0.54rem, 0.72vw, 0.72rem);
--text-sm: clamp(0.615rem, 0.82vw, 0.82rem);
--text-base: clamp(0.69rem, 0.92vw, 0.92rem);
--text-lg: clamp(0.75rem, 1vw, 1rem);
--text-xl: clamp(0.825rem, 1.1vw, 1.1rem);
--text-2xl: clamp(0.975rem, 1.3vw, 1.3rem);
--text-3xl: clamp(1.2rem, 1.6vw, 1.6rem);
--text-4xl: clamp(1.5rem, 2vw, 2rem);
--text-5xl: clamp(2.025rem, 2.75vw, 2.75rem);
--text-6xl: clamp(2.475rem, 3.3vw, 3.3rem);
--text-7xl: clamp(3rem, 4vw, 4rem);
--text-8xl: clamp(3.5rem, 4.5vw, 4.5rem);
--text-9xl: clamp(5.25rem, 7vw, 7rem); */
/* Base spacing units */
--vw-0_25: calc(var(--vw) * 0.25);
--vw-0_5: calc(var(--vw) * 0.5);
--vw-0_625: calc(var(--vw) * 0.625);
--vw-0_75: calc(var(--vw) * 0.75);
--vw-1: calc(var(--vw) * 1);
--vw-1_25: calc(var(--vw) * 1.25);
--vw-1_5: calc(var(--vw) * 1.5);
--vw-1_75: calc(var(--vw) * 1.75);
--vw-2: calc(var(--vw) * 2);
--vw-2_25: calc(var(--vw) * 2.25);
--vw-2_5: calc(var(--vw) * 2.5);
--vw-2_75: calc(var(--vw) * 2.75);
--vw-3: calc(var(--vw) * 3);
/* width */
--width-5: clamp(4rem, 5vw, 6rem);
--width-7_5: clamp(5.625rem, 7.5vw, 7.5rem);
--width-10: clamp(7.5rem, 10vw, 10rem);
--width-12_5: clamp(9.375rem, 12.5vw, 12.5rem);
--width-15: clamp(11.25rem, 15vw, 15rem);
--width-17: clamp(12.75rem, 17vw, 17rem);
--width-17_5: clamp(13.125rem, 17.5vw, 17.5rem);
--width-20: clamp(15rem, 20vw, 20rem);
--width-21: clamp(15.75rem, 21vw, 21rem);
--width-22_5: clamp(16.875rem, 22.5vw, 22.5rem);
--width-25: clamp(18.75rem, 25vw, 25rem);
--width-26: clamp(19.5rem, 26vw, 26rem);
--width-27_5: clamp(20.625rem, 27.5vw, 27.5rem);
--width-30: clamp(22.5rem, 30vw, 30rem);
--width-32_5: clamp(24.375rem, 32.5vw, 32.5rem);
--width-35: clamp(26.25rem, 35vw, 35rem);
--width-37_5: clamp(28.125rem, 37.5vw, 37.5rem);
--width-40: clamp(30rem, 40vw, 40rem);
--width-42_5: clamp(31.875rem, 42.5vw, 42.5rem);
--width-45: clamp(33.75rem, 45vw, 45rem);
--width-47_5: clamp(35.625rem, 47.5vw, 47.5rem);
--width-50: clamp(37.5rem, 50vw, 50rem);
--width-52_5: clamp(39.375rem, 52.5vw, 52.5rem);
--width-55: clamp(41.25rem, 55vw, 55rem);
--width-57_5: clamp(43.125rem, 57.5vw, 57.5rem);
--width-60: clamp(45rem, 60vw, 60rem);
--width-62_5: clamp(46.875rem, 62.5vw, 62.5rem);
--width-65: clamp(48.75rem, 65vw, 65rem);
--width-67_5: clamp(50.625rem, 67.5vw, 67.5rem);
--width-70: clamp(52.5rem, 70vw, 70rem);
--width-72_5: clamp(54.375rem, 72.5vw, 72.5rem);
--width-75: clamp(56.25rem, 75vw, 75rem);
--width-77_5: clamp(58.125rem, 77.5vw, 77.5rem);
--width-80: clamp(60rem, 80vw, 80rem);
--width-82_5: clamp(61.875rem, 82.5vw, 82.5rem);
--width-85: clamp(63.75rem, 85vw, 85rem);
--width-87_5: clamp(65.625rem, 87.5vw, 87.5rem);
--width-90: clamp(67.5rem, 90vw, 90rem);
--width-92_5: clamp(69.375rem, 92.5vw, 92.5rem);
--width-95: clamp(71.25rem, 95vw, 95rem);
--width-97_5: clamp(73.125rem, 97.5vw, 97.5rem);
--width-100: clamp(75rem, 100vw, 100rem);
/* --width-content-width and --width-content-width-expanded are set by ThemeProvider */
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
--width-carousel-padding-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
--width-carousel-padding-controls-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px);
--width-carousel-item-3: calc(var(--width-content-width) / 3 - var(--vw-1_5) / 3 * 2);
--width-carousel-item-4: calc(var(--width-content-width) / 4 - var(--vw-1_5) / 4 * 3);
--width-x-padding-mask-fade: clamp(1.5rem, 4vw, 4rem);
--height-4: 1rem;
--height-5: 1.25rem;
--height-6: 1.5rem;
--height-7: 1.75rem;
--height-8: 2rem;
--height-9: 2.25rem;
--height-10: 2.5rem;
--height-11: 2.75rem;
--height-12: 3rem;
--height-30: 7.5rem;
--height-90: 22.5rem;
--height-100: 25rem;
--height-110: 27.5rem;
--height-120: 30rem;
--height-130: 32.5rem;
--height-140: 35rem;
--height-150: 37.5rem;
/* hero page padding */
--padding-hero-page-padding-half: calc((var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)) / 2);
--padding-hero-page-padding: calc(var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10));
--padding-hero-page-padding-1_5: calc(1.5 * (var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)));
--padding-hero-page-padding-double: calc(2 * (var(--height-10) + var(--vw-1_5) + var(--vw-1_5) + var(--height-10)));
}
@media (max-width: 767px) {
:root {
/* --vw and text sizing are set by ThemeProvider */
/* --vw: 3vw;
--text-2xs: 2.5vw;
--text-xs: 2.75vw;
--text-sm: 3vw;
--text-base: 3.25vw;
--text-lg: 3.5vw;
--text-xl: 4.25vw;
--text-2xl: 5vw;
--text-3xl: 6vw;
--text-4xl: 7vw;
--text-5xl: 7.5vw;
--text-6xl: 8.5vw;
--text-7xl: 10vw;
--text-8xl: 12vw;
--text-9xl: 14vw; */
--width-5: 5vw;
--width-7_5: 7.5vw;
--width-10: 10vw;
--width-12_5: 12.5vw;
--width-15: 15vw;
--width-17_5: 17.5vw;
--width-20: 20vw;
--width-22_5: 22.5vw;
--width-25: 25vw;
--width-27_5: 27.5vw;
--width-30: 30vw;
--width-32_5: 32.5vw;
--width-35: 35vw;
--width-37_5: 37.5vw;
--width-40: 40vw;
--width-42_5: 42.5vw;
--width-45: 45vw;
--width-47_5: 47.5vw;
--width-50: 50vw;
--width-52_5: 52.5vw;
--width-55: 55vw;
--width-57_5: 57.5vw;
--width-60: 60vw;
--width-62_5: 62.5vw;
--width-65: 65vw;
--width-67_5: 67.5vw;
--width-70: 70vw;
--width-72_5: 72.5vw;
--width-75: 75vw;
--width-77_5: 77.5vw;
--width-80: 80vw;
--width-82_5: 82.5vw;
--width-85: 85vw;
--width-87_5: 87.5vw;
--width-90: 90vw;
--width-92_5: 92.5vw;
--width-95: 95vw;
--width-97_5: 97.5vw;
--width-100: 100vw;
/* --width-content-width and --width-content-width-expanded are set by ThemeProvider */
--width-carousel-padding: calc((100vw - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
--width-carousel-padding-controls: calc((100vw - var(--width-content-width)) / 2 + 1px);
--width-carousel-padding-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px - var(--vw-1_5));
--width-carousel-padding-controls-expanded: calc((var(--width-content-width-expanded) - var(--width-content-width)) / 2 + 1px);
--width-carousel-item-3: var(--width-content-width);
--width-carousel-item-4: var(--width-content-width);
--width-x-padding-mask-fade: 10vw;
--height-4: 3.5vw;
--height-5: 4.5vw;
--height-6: 5.5vw;
--height-7: 6.5vw;
--height-8: 7.5vw;
--height-9: 8.5vw;
--height-10: 9vw;
--height-11: 10vw;
--height-12: 11vw;
--height-30: 25vw;
--height-90: 81vw;
--height-100: 90vw;
--height-110: 99vw;
--height-120: 108vw;
--height-130: 117vw;
--height-140: 126vw;
--height-150: 135vw;
}
}

View File

@@ -0,0 +1,222 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import Link from "next/link";
import NavbarLayoutFloatingInline from "@/components/navbar/NavbarLayoutFloatingInline";
import FeatureCardTwentySeven from "@/components/sections/feature/FeatureCardTwentySeven";
import AboutMetric from "@/components/sections/about/AboutMetric";
import ContactCTA from "@/components/sections/contact/ContactCTA";
import FooterLogoEmphasis from "@/components/sections/footer/FooterLogoEmphasis";
import { Sparkles, Shield, Zap, CheckCircle, Users, Award } from "lucide-react";
export default function WhySteamCarwashPage() {
return (
<ThemeProvider
defaultButtonVariant="shift-hover"
defaultTextAnimation="reveal-blur"
borderRadius="rounded"
contentWidth="smallMedium"
sizing="mediumSizeLargeTitles"
background="circleGradient"
cardStyle="inset"
primaryButtonStyle="diagonal-gradient"
secondaryButtonStyle="solid"
headingFontWeight="light"
>
<div id="nav" data-section="nav" className="w-full">
<NavbarLayoutFloatingInline
brandName="Ventian"
navItems={[
{ name: "Home", id: "home" },
{ name: "Services", id: "services" },
{ name: "Why Steam", id: "why-steam" },
{ name: "Membership", id: "membership" },
{ name: "Franchise", id: "franchise" },
{ name: "About", id: "about" },
{ name: "Contact", id: "contact" },
]}
button={{
text: "Book Now",
href: "#book-now",
}}
/>
</div>
<div id="why-steam-hero" data-section="why-steam-hero" className="w-full py-20">
<div className="mx-auto px-4 md:px-6">
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 mb-6">
<Sparkles className="w-5 h-5 text-accent" />
<span className="text-sm font-medium text-accent">Science Behind Steam</span>
</div>
<h1 className="text-4xl md:text-5xl lg:text-6xl font-light mb-6 text-foreground">
Why Steam Technology Transforms Car Care
</h1>
<p className="text-lg md:text-xl text-foreground/80 max-w-3xl mx-auto">
Steam isn't just water. It's a scientifically-proven cleaning method that outperforms traditional carwash techniques while protecting your vehicle and the environment.
</p>
</div>
</div>
</div>
<div id="steam-advantages" data-section="steam-advantages" className="w-full py-20">
<FeatureCardTwentySeven
title="The Science of Superior Cleaning"
description="Steam technology delivers cleaning power that traditional methods simply cannot match. High-temperature steam penetrates surfaces, breaks down contaminants, and eliminates bacteria—all without harsh chemicals."
tag="Technical Innovation"
tagIcon={Award}
tagAnimation="slide-up"
features={[
{
id: "steam-cleaning",
title: "Heat-Powered Molecular Breakdown",
descriptions: [
"High-temperature steam molecules penetrate microscopic pores in vehicle surfaces",
"Thermal energy breaks molecular bonds holding dirt and grime",
"Eliminates 99.9% of bacteria and pathogens without chemicals",
],
imageSrc: "http://img.b2bpic.net/free-photo/front-view-clear-water-drops-surface_23-2148635097.jpg",
imageAlt: "Water droplets showing steam technology efficiency",
},
{
id: "paint-protection",
title: "Paint-Safe Deep Cleaning",
descriptions: [
"No abrasive scrubbing or harsh detergents that damage clear coat",
"Gentle pressure settings adjust for delicate surfaces",
"Maintains paint integrity while removing embedded contaminants",
],
imageSrc: "http://img.b2bpic.net/free-photo/man-gray-sweater-wipes-car-car-wash_1157-35957.jpg",
imageAlt: "Professional detailing process showing paint care",
},
{
id: "chemical-free",
title: "100% Chemical-Free Process",
descriptions: [
"Pure purified water transformed into cleaning steam",
"Eliminates toxic runoff that harms ecosystems",
"Safe for allergy sufferers and environmentally conscious owners",
],
imageSrc: "http://img.b2bpic.net/free-vector/flat-world-water-day-background_23-2149296753.jpg",
imageAlt: "Environmental sustainability and water conservation",
},
]}
gridVariant="three-columns-all-equal-width"
animationType="reveal-blur"
textboxLayout="default"
useInvertedBackground={true}
buttons={[
{
text: "Book Your Steam Wash",
href: "/membership",
},
]}
buttonAnimation="slide-up"
ariaLabel="Why steam carwash features section"
/>
</div>
<div id="steam-benefits" data-section="steam-benefits" className="w-full py-20">
<AboutMetric
title="Proven Results That Outperform Traditional Methods"
metrics={[
{
icon: Zap,
label: "Faster Drying Time",
value: "80% Less",
},
{
icon: Shield,
label: "Chemical Exposure",
value: "Zero",
},
{
icon: Users,
label: "Customer Satisfaction",
value: "98%",
},
{
icon: CheckCircle,
label: "Water Usage Reduction",
value: "Up to 80%",
},
]}
metricsAnimation="reveal-blur"
useInvertedBackground={false}
ariaLabel="Steam carwash benefits and metrics"
/>
</div>
<div id="steam-cta" data-section="steam-cta" className="w-full py-16">
<ContactCTA
tag="Experience the Difference"
tagIcon={Sparkles}
tagAnimation="slide-up"
title="Ready to Experience Premium Steam Care?"
description="Join thousands of satisfied vehicle owners who've made the switch to Ventian's advanced steam technology. Feel the difference in cleanliness, convenience, and care."
buttons={[
{
text: "Book Your First Wash",
href: "#book-now",
},
{
text: "View Membership Plans",
href: "/membership",
},
]}
buttonAnimation="slide-up"
background={{
variant: "radial-gradient",
}}
useInvertedBackground={true}
ariaLabel="Steam carwash final CTA section"
/>
</div>
<div id="footer" data-section="footer" className="w-full">
<FooterLogoEmphasis
logoText="Ventian"
columns={[
{
items: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
{ label: "Why Steam", href: "/why-steam-carwash" },
{ label: "Membership", href: "/membership" },
{ label: "Blog", href: "/blog" },
],
},
{
items: [
{ label: "About Us", href: "/about" },
{ label: "Locations", href: "/locations" },
{ label: "Franchise", href: "/franchise" },
{ label: "Careers", href: "/careers" },
{ label: "Contact", href: "/contact" },
],
},
{
items: [
{ label: "Facebook", href: "https://facebook.com/ventian" },
{ label: "Instagram", href: "https://instagram.com/ventian" },
{ label: "TikTok", href: "https://tiktok.com/@ventian" },
{ label: "LinkedIn", href: "https://linkedin.com/company/ventian" },
{ label: "YouTube", href: "https://youtube.com/@ventian" },
],
},
{
items: [
{ label: "Privacy Policy", href: "#privacy" },
{ label: "Terms of Service", href: "#terms" },
{ label: "Cookie Policy", href: "#cookies" },
{ label: "Contact Support", href: "#support" },
{ label: "Feedback", href: "#feedback" },
],
},
]}
ariaLabel="Site footer with navigation and links"
/>
</div>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useEffect, useRef, useState, useCallback, memo } from "react";
import { Plus } from "lucide-react";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
interface AccordionProps {
index: number;
isActive?: boolean;
onToggle?: (index: number) => void;
title: string;
content: string;
animationType?: "smooth" | "instant";
showCard?: boolean;
useInvertedBackground?: boolean;
className?: string;
titleClassName?: string;
iconContainerClassName?: string;
iconClassName?: string;
contentClassName?: string;
}
const Accordion = ({
index,
isActive: controlledIsActive,
onToggle,
title,
content,
animationType = "smooth",
showCard = true,
useInvertedBackground,
className = "",
titleClassName = "",
iconContainerClassName = "",
iconClassName = "",
contentClassName = "",
}: AccordionProps) => {
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState("0px");
const [internalIsActive, setInternalIsActive] = useState(false);
const isActive = controlledIsActive !== undefined ? controlledIsActive : internalIsActive;
const theme = useTheme();
const shouldUseLightText = showCard
? shouldUseInvertedText(useInvertedBackground, theme.cardStyle)
: useInvertedBackground;
useEffect(() => {
if (animationType === "smooth") {
setHeight(isActive ? `${contentRef.current?.scrollHeight}px` : "0px");
}
}, [isActive, animationType]);
const handleClick = useCallback(() => {
if (controlledIsActive === undefined) {
setInternalIsActive(!internalIsActive);
}
if (onToggle) {
onToggle(index);
}
}, [controlledIsActive, internalIsActive, onToggle, index]);
const headerContent = (
<div className="flex flex-row items-center justify-between w-full">
<h2
className={cls(
"text-base md:text-xl font-medium",
shouldUseLightText ? "text-background" : "text-foreground",
animationType === "instant" && "text-left",
titleClassName
)}
>
{title}
</h2>
<div
className={cls(
"h-8 aspect-square flex items-center justify-center rounded-theme primary-button transition-all duration-300",
iconContainerClassName
)}
>
<Plus
className={cls(
"w-4/10 aspect-square text-primary-cta-text",
animationType === "smooth" ? "transition-transform duration-500" : "transition-transform duration-300",
isActive && "rotate-45",
iconClassName
)}
/>
</div>
</div>
);
const contentElement = (
<div
className={cls(
"text-base",
shouldUseLightText ? "text-background" : "text-foreground",
animationType === "smooth" && "pt-2",
contentClassName
)}
dangerouslySetInnerHTML={{ __html: content }}
/>
);
if (animationType === "instant") {
return (
<div className={cls(showCard && "card rounded-theme", className)}>
<button
className={cls("cursor-pointer flex flex-row items-center justify-between w-full transition-all duration-300 group", showCard && "p-4")}
onClick={handleClick}
aria-expanded={isActive}
>
{headerContent}
</button>
{isActive && <div className={cls(showCard && "px-4 pb-4")}>{contentElement}</div>}
</div>
);
}
return (
<div
className={cls(
showCard ? "card p-4 rounded-theme-capped" : "",
"cursor-pointer flex flex-col items-center justify-between transition-all duration-500 group",
className
)}
onClick={handleClick}
aria-expanded={isActive}
>
{headerContent}
<div
ref={contentRef}
style={{ maxHeight: height }}
className="overflow-hidden transition-[max-height] duration-500 w-full flex flex-col"
>
{contentElement}
</div>
</div>
);
};
Accordion.displayName = "Accordion";
export default memo(Accordion);

View File

@@ -0,0 +1,22 @@
'use client';
import Script from 'next/script';
export function ServiceWrapper({ children }: { children: React.ReactNode }) {
const websiteId = process.env.NEXT_PUBLIC_WEBSITE_ANALYTICS_ID;
return (
<>
{websiteId && (
<Script
async
defer
data-website-id={websiteId}
src="https://analytics.webild.io/script.js"
strategy="afterInteractive"
/>
)}
{children}
</>
);
}

312
src/components/Textbox.tsx Normal file
View File

@@ -0,0 +1,312 @@
"use client";
import { memo, useMemo } from "react";
import Image from "next/image";
import TextAnimation from "./text/TextAnimation";
import Button from "./button/Button";
import Tag from "./shared/Tag";
import AvatarGroup from "./shared/AvatarGroup";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { LucideIcon } from "lucide-react";
import type { AnimationType } from "./text/types";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig, ButtonAnimationType } from "@/types/button";
import type { Avatar } from "./shared/AvatarGroup";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
import { useButtonAnimation } from "./hooks/useButtonAnimation";
type TitleSegment =
| { type: "text"; content: string }
| { type: "image"; src: string; alt?: string };
interface TextBoxProps {
title: string;
titleSegments?: TitleSegment[];
description: string;
type?: AnimationType;
textboxLayout?: TextboxLayout;
useInvertedBackground?: InvertedBackground;
className?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
duration?: number;
start?: string;
end?: string;
gradientColors?: {
from: string;
to: string;
};
children?: React.ReactNode;
center?: boolean;
tag?: string;
tagIcon?: LucideIcon;
tagClassName?: string;
tagAnimation?: ButtonAnimationType;
buttons?: ButtonConfig[];
buttonAnimation?: ButtonAnimationType;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
avatars?: Avatar[];
avatarText?: string;
avatarGroupClassName?: string;
avatarsAbove?: boolean;
}
const TextBox = ({
title,
titleSegments,
description,
type,
textboxLayout = "default",
useInvertedBackground,
className = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
duration = 1,
start = "top 80%",
end = "top 20%",
gradientColors,
children,
center = false,
tag,
tagIcon: TagIcon,
tagClassName = "",
tagAnimation = "none",
buttons,
buttonAnimation = "none",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
avatars,
avatarText,
avatarGroupClassName = "",
avatarsAbove = false,
}: TextBoxProps) => {
const theme = useTheme();
const { containerRef: tagContainerRef } = useButtonAnimation({
animationType: tagAnimation
});
const { containerRef: buttonContainerRef } = useButtonAnimation({
animationType: buttonAnimation
});
// Shared tag component
const tagElement = tag ? (
<div ref={tagContainerRef}>
<Tag
text={tag}
icon={TagIcon}
useInvertedBackground={useInvertedBackground}
className={cls(textboxLayout === "default" && "mb-3", tagClassName)}
/>
</div>
) : null;
// Shared title component
const titleElement = useMemo(() => (
<TextAnimation
type={type || theme.defaultTextAnimation}
text={title}
variant="trigger"
as="h2"
className={cls(
textboxLayout === "split" || textboxLayout === "split-actions" || textboxLayout === "split-description" ? "text-7xl font-medium text-balance" : "text-6xl font-medium",
center && textboxLayout === "default" && "text-center",
useInvertedBackground && "text-background",
titleClassName
)}
duration={duration}
start={start}
end={end}
gradientColors={gradientColors}
/>
), [type, theme.defaultTextAnimation, title, textboxLayout, center, useInvertedBackground, titleClassName, duration, start, end, gradientColors]);
// Inline image title component (used when textboxLayout === "inline-image")
const inlineImageTitleElement = useMemo(() => titleSegments && titleSegments.length > 0 ? (
<h2
className={cls(
"text-4xl md:text-5xl font-medium text-center leading-[1.15] text-balance",
useInvertedBackground && "text-background",
titleClassName
)}
>
{titleSegments.map((segment, index) => {
const imageIndex = titleSegments
.slice(0, index + 1)
.filter(s => s.type === "image").length - 1;
const element = segment.type === "text" ? (
<span key={index}>{segment.content}</span>
) : (
<span
key={index}
className={cls(
"inline-block relative primary-button -mt-[0.2em] h-[1.1em] w-auto aspect-square align-middle mx-1 p-0.5 rounded-theme",
imageIndex % 2 === 0 ? "-rotate-12" : "rotate-12",
titleImageWrapperClassName
)}
>
<div className="relative w-full h-full">
<Image
src={segment.src}
alt={segment.alt || ""}
width={24}
height={24}
className={cls(
"absolute inset-0 m-auto h-full w-full rounded-theme",
titleImageClassName
)}
unoptimized={segment.src.startsWith("http") || segment.src.startsWith("//")}
aria-hidden={!segment.alt || segment.alt === ""}
/>
</div>
</span>
);
return (
<span key={index}>
{index > 0 && " "}
{element}
</span>
);
})}
</h2>
) : null, [titleSegments, useInvertedBackground, titleClassName, titleImageWrapperClassName, titleImageClassName]);
// Shared description component
const descriptionElement = useMemo(() => (
<TextAnimation
type={type || theme.defaultTextAnimation}
text={description}
variant="words-trigger"
as="p"
className={cls(
"text-lg leading-[1.2]",
center && textboxLayout === "default" && "text-center",
(textboxLayout === "split" || textboxLayout === "split-description") && "text-balance",
useInvertedBackground && "text-background",
descriptionClassName
)}
duration={duration}
start={start}
end={end}
gradientColors={gradientColors}
/>
), [type, theme.defaultTextAnimation, description, center, textboxLayout, useInvertedBackground, descriptionClassName, duration, start, end, gradientColors]);
// Shared avatars component
const avatarsElement = useMemo(() => avatars && avatars.length > 0 ? (
<AvatarGroup
avatars={avatars}
text={avatarText}
className={cls(
textboxLayout === "default" && !avatarsAbove && "mt-3",
textboxLayout === "default" && avatarsAbove && "mb-3",
center && textboxLayout === "default" && "justify-center",
avatarGroupClassName
)}
/>
) : null, [avatars, avatarText, textboxLayout, center, avatarGroupClassName, avatarsAbove]);
// Shared buttons/children component
const actionsElement = buttons && buttons.length > 0 ? (
<div
ref={buttonContainerRef}
className={cls(
"flex flex-wrap gap-4 max-md:justify-center",
textboxLayout === "default" && "w-full mt-3",
(textboxLayout === "split" || textboxLayout === "split-actions") && "w-fit",
center && textboxLayout === "default" && "justify-center",
buttonContainerClassName
)}
>
{/* Limit to 2 buttons for optimal layout */}
{buttons.slice(0, 2).map((button, index) => (
<Button key={`${button.text}-${index}`} {...getButtonProps(button, index, theme.defaultButtonVariant, buttonClassName, buttonTextClassName)} />
))}
</div>
) : (
children
);
// Split layout
if (textboxLayout === "split") {
return (
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
<div className="w-full md:w-6/10 flex flex-col gap-3">
{tagElement}
{titleElement}
{descriptionElement}
</div>
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
{actionsElement}
</div>
</div>
);
}
// Split actions layout - tag and buttons required, no description
if (textboxLayout === "split-actions") {
return (
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
<div className="w-full md:w-6/10 flex flex-col gap-3">
{tagElement}
{titleElement}
</div>
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
{actionsElement}
</div>
</div>
);
}
// Split description layout - tag + title left, description only right (no buttons)
if (textboxLayout === "split-description") {
return (
<div className={cls("flex flex-col md:flex-row gap-3 md:gap-15 md:items-end", className)}>
<div className="w-full md:w-6/10 flex flex-col gap-3">
{tagElement}
{titleElement}
</div>
<div className="w-full md:w-4/10 flex flex-col gap-3 md:items-end">
{descriptionElement}
</div>
</div>
);
}
// Inline image layout - centered heading with inline images and optional buttons
if (textboxLayout === "inline-image") {
return (
<div className={cls("flex flex-col gap-3 md:gap-1", center && "items-center text-center", className)}>
{tagElement}
{inlineImageTitleElement || titleElement}
{descriptionElement}
{actionsElement}
</div>
);
}
// Default layout
return (
<div className={cls("flex flex-col gap-3 md:gap-1", center && "items-center text-center", className)}>
{avatarsAbove && avatarsElement}
{tagElement}
{titleElement}
{descriptionElement}
{actionsElement}
{!avatarsAbove && avatarsElement}
</div>
);
};
TextBox.displayName = "TextBox";
export default memo(TextBox);

View File

@@ -0,0 +1,44 @@
"use client";
import React, { memo } from "react";
import { cls } from "@/lib/utils";
interface AnimatedAuroraBackgroundProps {
className?: string;
showRadialGradient?: boolean;
/**
* Inverts the aurora colors for better visibility.
* Use `true` for light backgrounds (makes aurora darker/inverted)
* Use `false` for dark backgrounds (keeps aurora colors vibrant)
*/
invertColors: boolean;
}
const AnimatedAuroraBackground = ({
className,
showRadialGradient = true,
invertColors,
}: AnimatedAuroraBackgroundProps) => {
return (
<div
className={cls(
"fixed inset-0 -z-10 bg-background",
className
)}
aria-hidden="true"
>
<div className="absolute inset-0 overflow-hidden opacity-30">
<div
className={cls(
"[--base-gradient:repeating-linear-gradient(100deg,var(--background)_0%,var(--background)_7%,transparent_10%,transparent_12%,var(--background)_16%)] [--aurora:repeating-linear-gradient(100deg,var(--color-primary-cta)_10%,var(--color-accent)_15%,var(--color-secondary-cta)_20%,var(--color-accent)_25%,var(--color-primary-cta)_30%)] [background-image:var(--base-gradient),var(--aurora)] [background-size:300%,_200%] [background-position:50%_50%,50%_50%] filter blur-[10px] after:content-[''] after:absolute after:inset-0 after:[background-image:var(--base-gradient),var(--aurora)] after:[background-size:200%,_100%] after:[animation:aurora_60s_linear_infinite] after:[background-attachment:fixed] after:mix-blend-difference pointer-events-none absolute -inset-[10px] opacity-30 will-change-transform",
invertColors && "invert",
showRadialGradient && "[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,var(--transparent)_70%)]"
)}
></div>
</div>
</div>
);
};
AnimatedAuroraBackground.displayName = "AnimatedAuroraBackground";
export default memo(AnimatedAuroraBackground);

View File

@@ -0,0 +1,112 @@
"use client";
import { memo, useEffect, useId, useRef, useState } from "react";
import { motion } from "framer-motion";
import { cls } from "@/lib/utils";
interface AnimatedGridBackgroundProps {
className?: string;
squareSize?: number;
numSquares?: number;
maxOpacity?: number;
}
const AnimatedGridBackground = ({
className = "",
squareSize = 100,
numSquares = 50,
maxOpacity = 0.15,
}: AnimatedGridBackgroundProps) => {
const id = useId();
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [squares, setSquares] = useState<Array<{ id: number; pos: [number, number] }>>([]);
useEffect(() => {
if (containerRef.current) {
const { width, height } = containerRef.current.getBoundingClientRect();
setDimensions({ width, height });
}
}, []);
useEffect(() => {
if (dimensions.width && dimensions.height) {
const cols = Math.ceil(dimensions.width / squareSize);
const rows = Math.ceil(dimensions.height / squareSize);
const newSquares = Array.from({ length: numSquares }, (_, i) => ({
id: i,
pos: [
Math.floor(Math.random() * cols),
Math.floor(Math.random() * rows),
] as [number, number],
}));
setSquares(newSquares);
}
}, [dimensions, squareSize, numSquares]);
return (
<div
ref={containerRef}
className={cls(
"absolute inset-0 z-0 pointer-events-none select-none overflow-hidden inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
className
)}
style={{
mask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
WebkitMask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
} as React.CSSProperties}
aria-hidden="true"
>
<svg
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern
id={`grid-${id}`}
width={squareSize}
height={squareSize}
patternUnits="userSpaceOnUse"
>
<path
d={`M ${squareSize} 0 L 0 0 0 ${squareSize}`}
fill="none"
stroke="currentColor"
strokeWidth="1"
className="text-background-accent/50"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#grid-${id})`} />
{squares.map(({ id, pos: [x, y] }) => (
<motion.rect
key={id}
initial={{ opacity: 0 }}
animate={{
opacity: [0, maxOpacity, 0],
}}
transition={{
duration: Math.random() * 2 + 2,
repeat: Infinity,
delay: Math.random() * 2,
ease: "easeInOut",
}}
x={x * squareSize}
y={y * squareSize}
width={squareSize}
height={squareSize}
fill="var(--color-background-accent)"
strokeWidth="0"
/>
))}
</svg>
</div>
);
};
AnimatedGridBackground.displayName = "AnimatedGridBackground";
export default memo(AnimatedGridBackground);

View File

@@ -0,0 +1,32 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface AuroraBackgroundProps {
className?: string;
}
const AuroraBackground = ({
className = "",
}: AuroraBackgroundProps) => {
return (
<div className={cls("fixed inset-0 z-0 w-full h-full bg-background", className)}>
<div className="absolute top-0 left-0 w-full h-full z-10 backdrop-blur-3xl" ></div>
{/* top center */}
<div className="absolute top-0 left-1/2 -translate-y-1/2 -translate-x-[120%] w-[9vw] h-[110vh] bg-background-accent/15 -rotate-[52.5deg] rounded-[100%]" />
{/* top right */}
<div className="absolute top-[-20vh] right-[2.5vw] -translate-x-[0%] w-[12.5vw] h-[100vh] bg-background-accent/15 -rotate-[60deg] rounded-[100%]" />
{/* center left */}
<div className="absolute top-[-20vh] left-[2vw] -translate-x-[0%] w-[15vw] h-[150vh] bg-background-accent/20 -rotate-[45deg] rounded-[100%]" />
{/* top left */}
<div className="absolute top-[-30vh] left-0 -translate-x-[0%] w-[10vw] h-[70vh] bg-background-accent/15 -rotate-[45deg] rounded-[100%]" />
{/* bottom center */}
<div className="absolute bottom-[-40vh] left-0 -translate-x-[0%] w-[120vw] h-[50vh] bg-background-accent/10 -rotate-[20deg] rounded-[100%]" />
</div>
);
};
AuroraBackground.displayName = "AuroraBackground";
export default memo(AuroraBackground);

View File

@@ -0,0 +1,58 @@
"use client";
import { memo, useState, useEffect, useCallback } from "react";
import { cls } from "@/lib/utils";
const MASK_GRADIENT = "linear-gradient(to bottom, transparent, black 60%)";
const BOTTOM_THRESHOLD = 50;
const TOP_THRESHOLD = 50;
interface BlurBottomBackgroundProps {
className?: string;
}
const BlurBottomBackground = ({
className = ""
}: BlurBottomBackgroundProps) => {
const [isAtBottom, setIsAtBottom] = useState(false);
const [isAtTop, setIsAtTop] = useState(true);
const handleScroll = useCallback(() => {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const distanceFromBottom = documentHeight - (scrollTop + windowHeight);
setIsAtTop(scrollTop <= TOP_THRESHOLD);
setIsAtBottom(distanceFromBottom <= BOTTOM_THRESHOLD);
}, []);
useEffect(() => {
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleScroll);
};
}, [handleScroll]);
return (
<div
className={cls(
"fixed pointer-events-none backdrop-blur-xl w-full h-30 left-0 bottom-0 z-[500] transition-opacity duration-500 ease-out",
isAtTop || isAtBottom ? "opacity-0" : "opacity-100",
className
)}
style={{ maskImage: MASK_GRADIENT }}
aria-hidden="true"
/>
);
};
BlurBottomBackground.displayName = "BlurBottomBackground";
export default memo(BlurBottomBackground);

View File

@@ -0,0 +1,74 @@
'use client';
import { memo, useState, useEffect } from 'react';
import { cls } from '@/lib/utils';
import CanvasRevealEffect from './CanvasRevealEffect';
interface CanvasRevealBackgroundProps {
className?: string;
animationSpeed?: number;
dotSize?: number;
height?: string;
}
const hexToRgb = (hex: string): number[] => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
: [0, 255, 255];
};
const CanvasRevealBackground = ({
className = "",
animationSpeed = 5,
dotSize = 3,
height = "30%",
}: CanvasRevealBackgroundProps) => {
const [colors, setColors] = useState<number[][]>([[0, 255, 255]]);
useEffect(() => {
const primaryCta = getComputedStyle(document.documentElement)
.getPropertyValue('--color-background-accent')
.trim();
if (primaryCta) {
setColors([hexToRgb(primaryCta)]);
}
}, []);
return (
<div
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
aria-hidden="true"
>
<div
className="absolute inset-x-0 top-0 w-full"
style={{
height: height,
mask: `
radial-gradient(ellipse 60% 120% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 80%),
linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 10%, rgb(0, 0, 0) 25%, rgb(0, 0, 0) 75%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0) 100%)
`,
maskComposite: 'intersect',
WebkitMask: `
radial-gradient(ellipse 60% 120% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 80%),
linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 10%, rgb(0, 0, 0) 25%, rgb(0, 0, 0) 75%, rgba(0, 0, 0, 0) 90%, rgba(0, 0, 0, 0) 100%)
`,
WebkitMaskComposite: 'source-in',
}}
>
<CanvasRevealEffect
animationSpeed={animationSpeed}
colors={colors}
dotSize={dotSize}
showGradient={false}
containerClassName="bg-transparent"
/>
</div>
</div>
);
};
CanvasRevealBackground.displayName = 'CanvasRevealBackground';
export default memo(CanvasRevealBackground);

View File

@@ -0,0 +1,304 @@
'use client';
import { cls } from '@/lib/utils';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { useMemo, useRef, useCallback, memo } from 'react';
import * as THREE from 'three';
interface CanvasRevealEffectProps {
animationSpeed?: number;
opacities?: number[];
colors?: number[][];
containerClassName?: string;
dotSize?: number;
showGradient?: boolean;
}
const CanvasRevealEffect = ({
animationSpeed = 0.4,
opacities = [0.2, 0.2, 0.2, 0.4, 0.4, 0.4, 0.7, 0.6, 0.6, 0.9],
colors = [[0, 255, 255]],
containerClassName = "",
dotSize = 3,
showGradient = true,
}: CanvasRevealEffectProps) => {
return (
<div className={cls('h-full relative bg-white w-full', containerClassName)}>
<div className="h-full w-full">
<DotMatrix
colors={colors}
dotSize={dotSize}
opacities={opacities}
shader={`
float animation_speed_factor = ${animationSpeed.toFixed(1)};
float intro_offset = distance(u_resolution / 2.0 / u_total_size, st2) * 0.01 + (random(st2) * 0.15);
opacity *= step(intro_offset, u_time * animation_speed_factor);
opacity *= clamp((1.0 - step(intro_offset + 0.1, u_time * animation_speed_factor)) * 1.25, 1.0, 1.25);
`}
center={['x', 'y']}
/>
</div>
{showGradient && (
<div className="absolute inset-0 bg-gradient-to-t from-gray-950 to-[84%]" />
)}
</div>
);
};
interface DotMatrixProps {
colors?: number[][];
opacities?: number[];
totalSize?: number;
dotSize?: number;
shader?: string;
center?: ('x' | 'y')[];
}
const DotMatrix = ({
colors = [[0, 0, 0]],
opacities = [0.04, 0.04, 0.04, 0.04, 0.04, 0.08, 0.08, 0.08, 0.08, 0.14],
totalSize = 4,
dotSize = 2,
shader = '',
center = ['x', 'y'],
}: DotMatrixProps) => {
const uniforms = useMemo(() => {
let colorsArray = [
colors[0],
colors[0],
colors[0],
colors[0],
colors[0],
colors[0],
];
if (colors.length === 2) {
colorsArray = [
colors[0],
colors[0],
colors[0],
colors[1],
colors[1],
colors[1],
];
} else if (colors.length === 3) {
colorsArray = [
colors[0],
colors[0],
colors[1],
colors[1],
colors[2],
colors[2],
];
}
return {
u_colors: {
value: colorsArray.map((color) => [
color[0] / 255,
color[1] / 255,
color[2] / 255,
]),
type: 'uniform3fv',
},
u_opacities: {
value: opacities,
type: 'uniform1fv',
},
u_total_size: {
value: totalSize,
type: 'uniform1f',
},
u_dot_size: {
value: dotSize,
type: 'uniform1f',
},
};
}, [colors, opacities, totalSize, dotSize]);
return (
<Shader
source={`
precision mediump float;
in vec2 fragCoord;
uniform float u_time;
uniform float u_opacities[10];
uniform vec3 u_colors[6];
uniform float u_total_size;
uniform float u_dot_size;
uniform vec2 u_resolution;
out vec4 fragColor;
float PHI = 1.61803398874989484820459;
float random(vec2 xy) {
return fract(tan(distance(xy * PHI, xy) * 0.5) * xy.x);
}
float map(float value, float min1, float max1, float min2, float max2) {
return min2 + (value - min1) * (max2 - min2) / (max1 - min1);
}
void main() {
vec2 st = fragCoord.xy;
${
center.includes('x')
? 'st.x -= abs(floor((mod(u_resolution.x, u_total_size) - u_dot_size) * 0.5));'
: ''
}
${
center.includes('y')
? 'st.y -= abs(floor((mod(u_resolution.y, u_total_size) - u_dot_size) * 0.5));'
: ''
}
float opacity = step(0.0, st.x);
opacity *= step(0.0, st.y);
vec2 st2 = vec2(int(st.x / u_total_size), int(st.y / u_total_size));
float frequency = 5.0;
float show_offset = random(st2);
float rand = random(st2 * floor((u_time / frequency) + show_offset + frequency) + 1.0);
opacity *= u_opacities[int(rand * 10.0)];
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.x / u_total_size));
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.y / u_total_size));
vec3 color = u_colors[int(show_offset * 6.0)];
${shader}
fragColor = vec4(color, opacity);
fragColor.rgb *= fragColor.a;
}`}
uniforms={uniforms}
maxFps={60}
/>
);
};
type Uniforms = {
[key: string]: {
value: number[] | number[][] | number;
type: string;
};
};
const ShaderMaterial = ({
source,
uniforms,
maxFps = 60,
}: {
source: string;
maxFps?: number;
uniforms: Uniforms;
}) => {
const { size } = useThree();
const ref = useRef<THREE.Mesh>(null);
let lastFrameTime = 0;
useFrame(({ clock }) => {
if (!ref.current) return;
const timestamp = clock.getElapsedTime();
if (timestamp - lastFrameTime < 1 / maxFps) {
return;
}
lastFrameTime = timestamp;
const material = ref.current.material as THREE.ShaderMaterial;
const timeLocation = material.uniforms.u_time;
timeLocation.value = timestamp;
});
const getUniforms = useCallback(() => {
const preparedUniforms: Record<string, { value: unknown; type?: string }> = {};
for (const uniformName in uniforms) {
const uniform = uniforms[uniformName] as { type: string; value: number | number[] | number[][] };
switch (uniform.type) {
case 'uniform1f':
preparedUniforms[uniformName] = { value: uniform.value, type: '1f' };
break;
case 'uniform3f':
preparedUniforms[uniformName] = {
value: new THREE.Vector3().fromArray(uniform.value as number[]),
type: '3f',
};
break;
case 'uniform1fv':
preparedUniforms[uniformName] = { value: uniform.value, type: '1fv' };
break;
case 'uniform3fv':
preparedUniforms[uniformName] = {
value: (uniform.value as number[][]).map((v: number[]) =>
new THREE.Vector3().fromArray(v)
),
type: '3fv',
};
break;
case 'uniform2f':
preparedUniforms[uniformName] = {
value: new THREE.Vector2().fromArray(uniform.value as number[]),
type: '2f',
};
break;
default:
console.error(`Invalid uniform type for '${uniformName}'.`);
break;
}
}
preparedUniforms['u_time'] = { value: 0, type: '1f' };
preparedUniforms['u_resolution'] = {
value: new THREE.Vector2(size.width * 2, size.height * 2),
};
return preparedUniforms;
}, [uniforms, size.width, size.height]);
const material = useMemo(() => {
const materialObject = new THREE.ShaderMaterial({
vertexShader: `
precision mediump float;
in vec2 coordinates;
uniform vec2 u_resolution;
out vec2 fragCoord;
void main(){
float x = position.x;
float y = position.y;
gl_Position = vec4(x, y, 0.0, 1.0);
fragCoord = (position.xy + vec2(1.0)) * 0.5 * u_resolution;
fragCoord.y = u_resolution.y - fragCoord.y;
}
`,
fragmentShader: source,
uniforms: getUniforms(),
glslVersion: THREE.GLSL3,
blending: THREE.CustomBlending,
blendSrc: THREE.SrcAlphaFactor,
blendDst: THREE.OneFactor,
});
return materialObject;
}, [source, getUniforms]);
return (
<mesh ref={ref as React.Ref<THREE.Mesh>}>
<planeGeometry args={[2, 2]} />
<primitive object={material} attach="material" />
</mesh>
);
};
interface ShaderProps {
source: string;
uniforms: Uniforms;
maxFps?: number;
}
const Shader = ({ source, uniforms, maxFps = 60 }: ShaderProps) => {
return (
<Canvas className="absolute inset-0 h-full w-full">
<ShaderMaterial source={source} uniforms={uniforms} maxFps={maxFps} />
</Canvas>
);
};
CanvasRevealEffect.displayName = 'CanvasRevealEffect';
export default memo(CanvasRevealEffect);

View File

@@ -0,0 +1,56 @@
"use client";
import { memo, useMemo } from "react";
import { motion, useMotionTemplate, type MotionValue } from "framer-motion";
const GRADIENT_SIZE = 250;
interface CardPatternProps {
mouseX: MotionValue<number>;
mouseY: MotionValue<number>;
randomString: string;
isActive?: boolean;
gradientClassName?: string;
}
function CardPatternComponent({
mouseX,
mouseY,
randomString,
isActive = false,
gradientClassName,
}: CardPatternProps) {
const maskImage = useMotionTemplate`radial-gradient(${GRADIENT_SIZE}px at ${mouseX}px ${mouseY}px, white, transparent)`;
const style = useMemo(
() => ({
maskImage,
WebkitMaskImage: maskImage,
}),
[maskImage]
);
return (
<div className="pointer-events-none">
<div
className={`absolute inset-0 rounded-theme-capped [mask-image:linear-gradient(white,transparent)] ${isActive ? "opacity-50" : "group-hover/primary-button:opacity-50"}`}
/>
<motion.div
className={`absolute inset-0 rounded-theme-capped ${gradientClassName} backdrop-blur-xl transition duration-500 ${isActive ? "opacity-100" : "opacity-0 group-hover/primary-button:opacity-100"}`}
style={style}
/>
<motion.div
className={`absolute inset-0 rounded-theme-capped mix-blend-overlay ${isActive ? "opacity-100" : "opacity-0 group-hover/primary-button:opacity-100"}`}
style={style}
>
<p className="absolute inset-x-0 text-xs h-full break-words whitespace-pre-wrap text-white font-mono font-bold transition duration-500">
{randomString}
</p>
</motion.div>
</div>
);
}
CardPatternComponent.displayName = "CardPattern";
export const CardPattern = memo(CardPatternComponent);

View File

@@ -0,0 +1,103 @@
'use client';
import { memo, useEffect, useRef } from 'react';
import { gsap } from 'gsap';
import { cls } from '@/lib/utils';
interface CellWaveBackgroundProps {
columns?: number;
rows?: number;
cellColor?: string;
duration?: number;
delay?: number;
className?: string;
}
const CellWaveBackground = ({
columns = 5,
rows = 24,
cellColor = 'var(--color-background-accent)',
duration = 0.25,
delay = 1.25,
className = ''
}: CellWaveBackgroundProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const cellRefs = useRef<(HTMLDivElement | null)[][]>([]);
const timelinesRef = useRef<gsap.core.Timeline[]>([]);
const setCellRef = (colIndex: number, cellIndex: number) => (el: HTMLDivElement | null) => {
if (!cellRefs.current[colIndex]) {
cellRefs.current[colIndex] = [];
}
cellRefs.current[colIndex][cellIndex] = el;
};
const cellStyles = {
backgroundColor: cellColor,
boxShadow: `0px 0px 50px 16px color-mix(in srgb, ${cellColor} 12%, transparent), 0px 0px 7px 1px color-mix(in srgb, ${cellColor} 31%, transparent)`
};
useEffect(() => {
timelinesRef.current.forEach(tl => tl.kill());
timelinesRef.current = [];
cellRefs.current.forEach((column, colIndex) => {
const cells = [...column].filter(Boolean).reverse();
const timeline = gsap.timeline({
delay: delay * colIndex,
repeat: -1,
repeatDelay: 2
});
cells.forEach((cell, cellIndex) => {
if (cell) {
timeline.to(cell, {
keyframes: [
{ opacity: 0, duration: 0 },
{ opacity: 0.05, duration: duration },
{ opacity: 0.15, duration: duration },
{ opacity: 0.25, duration: duration },
{ opacity: 0.5, duration: duration },
{ opacity: 0.25, duration: duration },
{ opacity: 0.15, duration: duration },
{ opacity: 0.05, duration: duration },
{ opacity: 0, duration: duration }
],
ease: 'none'
}, cellIndex * duration);
}
});
timelinesRef.current.push(timeline);
});
return () => {
timelinesRef.current.forEach(tl => tl.kill());
};
}, [duration, delay, columns, rows]);
return (
<div
ref={containerRef}
className={cls("absolute inset-0 z-0 flex items-end justify-between pointer-events-none select-none", className)}
aria-hidden="true"
>
{Array.from({ length: columns }).map((_, colIndex) => (
<div className="relative flex flex-col gap-1 h-full" key={colIndex}>
{Array.from({ length: rows }).map((_, cellIndex) => (
<div
ref={setCellRef(colIndex, cellIndex)}
className="opacity-0 h-8 w-2"
key={cellIndex}
style={cellStyles}
/>
))}
</div>
))}
</div>
);
};
CellWaveBackground.displayName = 'CellWaveBackground';
export default memo(CellWaveBackground);

View File

@@ -0,0 +1,48 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
type DiagonalVariant = "primary" | "secondary";
interface CircleGradientBackgroundProps {
className?: string;
diagonal?: DiagonalVariant;
}
const CircleGradientBackground = ({
className = "",
diagonal = "primary",
}: CircleGradientBackgroundProps) => {
const isPrimary = diagonal === "primary";
return (
<div
className={cls("fixed top-0 left-0 right-0 bottom-0 h-screen w-full -z-10 overflow-hidden", className)}
aria-hidden="true"
>
<div
className={cls(
"fixed w-100 md:w-70 h-auto aspect-square rounded-full opacity-10",
isPrimary ? "top-0 right-0 translate-x-1/2 -translate-y-1/2" : "top-0 left-0 -translate-x-1/2 -translate-y-1/2"
)}
style={{
background: `radial-gradient(circle at center, var(--color-background-accent) 35%, transparent 70%)`,
}}
/>
<div
className={cls(
"fixed w-100 md:w-70 h-auto aspect-square rounded-full opacity-10",
isPrimary ? "bottom-0 left-0 -translate-x-1/2 translate-y-1/2" : "bottom-0 right-0 translate-x-1/2 translate-y-1/2"
)}
style={{
background: `radial-gradient(circle at center, var(--color-background-accent) 35%, transparent 70%)`,
}}
/>
</div>
);
};
CircleGradientBackground.displayName = "CircleGradientBackground";
export default memo(CircleGradientBackground);

View File

@@ -0,0 +1,45 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
type GridSize = "small" | "medium" | "large";
interface DotGridBackgroundProps {
size?: GridSize;
className?: string;
perspectiveThreeD?: boolean;
}
const GRID_SIZES: Record<GridSize, string> = {
small: "1vw 1vw",
medium: "2vw 2vw",
large: "4vw 4vw",
};
const DotGridBackground = ({
size = "medium",
className = "",
perspectiveThreeD = false
}: DotGridBackgroundProps) => {
return (
<div
className={cls(
"fixed inset-0 -z-10 bg-background [mask-image:radial-gradient(circle_at_center,white_0%,transparent_90%)]",
perspectiveThreeD && "inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
className
)}
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in srgb, var(--background-accent) 30%, transparent) 1px, transparent 1px)",
backgroundSize: GRID_SIZES[size],
backgroundRepeat: "repeat",
}}
aria-hidden="true"
/>
);
};
DotGridBackground.displayName = "DotGridBackground";
export default memo(DotGridBackground);

View File

@@ -0,0 +1,130 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface RayConfig {
width: number;
opacity: number;
rotation: number;
scale?: number;
animationDuration: number;
animationDelay: number;
}
interface LightSourceConfig {
width: number;
height?: number;
opacity: number;
top: number;
}
interface DownwardRaysBackgroundProps {
animated: boolean;
showGrid: boolean;
className?: string;
containerClassName?: string;
}
const rays: RayConfig[] = [
{ width: 35, opacity: 1, rotation: -20, animationDuration: 4, animationDelay: 0 },
{ width: 35, opacity: 0.6, rotation: -12, animationDuration: 3.5, animationDelay: 0.5 },
{ width: 20, opacity: 0.45, rotation: -5, scale: 0.90, animationDuration: 5, animationDelay: 1.2 },
{ width: 15, opacity: 0.625, rotation: -3, animationDuration: 3, animationDelay: 0.3 },
{ width: 40, opacity: 0.1, rotation: 0, scale: 0.79, animationDuration: 4.5, animationDelay: 0.8 },
{ width: 20, opacity: 0.525, rotation: 3, animationDuration: 3.2, animationDelay: 1.5 },
{ width: 15, opacity: 0.725, rotation: 5, scale: 0.90, animationDuration: 4.2, animationDelay: 0.2 },
{ width: 35, opacity: 0.6, rotation: 12, animationDuration: 3.8, animationDelay: 1 },
{ width: 35, opacity: 1, rotation: 20, animationDuration: 4, animationDelay: 0.7 },
];
const lightSources: LightSourceConfig[] = [
{ width: 1198, opacity: 0.025, top: -352 },
{ width: 865, height: 929, opacity: 0.1, top: -252 },
{ width: 865, height: 929, opacity: 0.1, top: -252 },
];
const DownwardRaysBackground = ({
animated,
showGrid,
className = "",
containerClassName = "",
}: DownwardRaysBackgroundProps) => {
return (
<div
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
aria-hidden="true"
>
{animated && (
<style>
{`
@keyframes rayPulse {
0%, 100% { opacity: 0; }
50% { opacity: var(--target-opacity); }
}
`}
</style>
)}
{showGrid && (
<div
className="absolute inset-0 -z-10 bg-background [mask-image:radial-gradient(50%_50%_at_50%_0%,white_0%,transparent_100%)]"
style={{
backgroundImage:
"linear-gradient(to right, color-mix(in srgb, var(--color-background-accent) 20%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--color-background-accent) 10%, transparent) 1px, transparent 1px)",
backgroundSize: "10vw 10vw",
backgroundRepeat: "repeat",
}}
/>
)}
<div
className={cls(
"absolute overflow-hidden w-[1142px] h-[129vh] -top-[400px] left-1/2 -translate-x-1/2",
"blur-[16px]",
"[mask:radial-gradient(50%_109%,#000_0%,#000000f6_0%,transparent_96%)]",
containerClassName
)}
>
{rays.map((ray, index) => (
<div
key={`ray-${index}`}
className="absolute overflow-hidden origin-top -top-[352px] -bottom-[920px] [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
style={{
width: `${ray.width}px`,
left: `calc(50% - ${ray.width / 2}px)`,
transform: `${ray.scale ? `scale(${ray.scale})` : ""} rotate(${ray.rotation}deg)`,
...(animated
? {
"--target-opacity": ray.opacity,
animation: `rayPulse ${ray.animationDuration}s ease-in-out ${ray.animationDelay}s infinite both`,
}
: {
opacity: ray.opacity,
}),
} as React.CSSProperties}
/>
))}
{lightSources.map((source, index) => (
<div
key={`light-source-${index}`}
className="absolute overflow-hidden [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
style={{
width: `${source.width}px`,
height: source.height ? `${source.height}px` : undefined,
top: `${source.top}px`,
bottom: source.height ? undefined : "-46px",
left: `calc(50% - ${source.width / 2}px)`,
opacity: source.opacity,
}}
/>
))}
</div>
</div>
);
};
DownwardRaysBackground.displayName = "DownwardRaysBackground";
export default memo(DownwardRaysBackground);

View File

@@ -0,0 +1,277 @@
'use client';
import React, { useRef, useMemo, memo, useEffect, useState } from 'react';
import { Canvas, useFrame, extend, useThree } from '@react-three/fiber';
import { shaderMaterial } from '@react-three/drei';
import * as THREE from 'three';
import { cls } from '@/lib/utils';
const getComputedColor = (varName: string): THREE.Color => {
if (typeof window === 'undefined') return new THREE.Color(0x000000);
const styles = getComputedStyle(document.documentElement);
const colorString = styles.getPropertyValue(varName).trim();
return new THREE.Color(colorString || '#000000');
};
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
#ifdef GL_ES
precision lowp float;
#endif
uniform float iTime;
uniform vec2 iResolution;
uniform vec3 uBackgroundColor;
uniform vec3 uPrimaryCta;
uniform vec3 uAccent;
uniform vec3 uSecondaryCta;
varying vec2 vUv;
vec4 buf[8];
vec4 sigmoid(vec4 x) { return 1. / (1. + exp(-x)); }
vec4 cppn_fn(vec2 coordinate, float in0, float in1, float in2) {
buf[6] = vec4(coordinate.x, coordinate.y, 0.3948333106474662 + in0, 0.36 + in1);
buf[7] = vec4(0.14 + in2, sqrt(coordinate.x * coordinate.x + coordinate.y * coordinate.y), 0., 0.);
buf[0] = mat4(vec4(6.5404263, -3.6126034, 0.7590882, -1.13613), vec4(2.4582713, 3.1660357, 1.2219609, 0.06276096), vec4(-5.478085, -6.159632, 1.8701609, -4.7742867), vec4(6.039214, -5.542865, -0.90925294, 3.251348))
* buf[6]
+ mat4(vec4(0.8473259, -5.722911, 3.975766, 1.6522468), vec4(-0.24321538, 0.5839259, -1.7661959, -5.350116), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
* buf[7]
+ vec4(0.21808943, 1.1243913, -1.7969975, 5.0294676);
buf[1] = mat4(vec4(-3.3522482, -6.0612736, 0.55641043, -4.4719114), vec4(0.8631464, 1.7432913, 5.643898, 1.6106541), vec4(2.4941394, -3.5012043, 1.7184316, 6.357333), vec4(3.310376, 8.209261, 1.1355612, -1.165539))
* buf[6]
+ mat4(vec4(5.24046, -13.034365, 0.009859298, 15.870829), vec4(2.987511, 3.129433, -0.89023495, -1.6822904), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
* buf[7]
+ vec4(-5.9457836, -6.573602, -0.8812491, 1.5436668);
buf[0] = sigmoid(buf[0]);
buf[1] = sigmoid(buf[1]);
buf[2] = mat4(vec4(-15.219568, 8.095543, -2.429353, -1.9381982), vec4(-5.951362, 4.3115187, 2.6393783, 1.274315), vec4(-7.3145227, 6.7297835, 5.2473326, 5.9411426), vec4(5.0796127, 8.979051, -1.7278991, -1.158976))
* buf[6]
+ mat4(vec4(-11.967154, -11.608155, 6.1486754, 11.237008), vec4(2.124141, -6.263192, -1.7050359, -0.7021966), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
* buf[7]
+ vec4(-4.17164, -3.2281182, -4.576417, -3.6401186);
buf[3] = mat4(vec4(3.1832156, -13.738922, 1.879223, 3.233465), vec4(0.64300746, 12.768129, 1.9141049, 0.50990224), vec4(-0.049295485, 4.4807224, 1.4733979, 1.801449), vec4(5.0039253, 13.000481, 3.3991797, -4.5561905))
* buf[6]
+ mat4(vec4(-0.1285731, 7.720628, -3.1425676, 4.742367), vec4(0.6393625, 3.714393, -0.8108378, -0.39174938), vec4(0.0, 0.0, 0.0, 0.0), vec4(0.0, 0.0, 0.0, 0.0))
* buf[7]
+ vec4(-1.1811101, -21.621881, 0.7851888, 1.2329718);
buf[2] = sigmoid(buf[2]);
buf[3] = sigmoid(buf[3]);
buf[4] = mat4(vec4(5.214916, -7.183024, 2.7228765, 2.6592617), vec4(-5.601878, -25.3591, 4.067988, 0.4602802), vec4(-10.57759, 24.286327, 21.102104, 37.546658), vec4(4.3024497, -1.9625226, 2.3458803, -1.372816))
* buf[0]
+ mat4(vec4(-17.6526, -10.507558, 2.2587414, 12.462782), vec4(6.265566, -502.75443, -12.642513, 0.9112289), vec4(-10.983244, 20.741234, -9.701768, -0.7635988), vec4(5.383626, 1.4819539, -4.1911616, -4.8444734))
* buf[1]
+ mat4(vec4(12.785233, -16.345072, -0.39901125, 1.7955981), vec4(-30.48365, -1.8345358, 1.4542528, -1.1118771), vec4(19.872723, -7.337935, -42.941723, -98.52709), vec4(8.337645, -2.7312303, -2.2927687, -36.142323))
* buf[2]
+ mat4(vec4(-16.298317, 3.5471997, -0.44300047, -9.444417), vec4(57.5077, -35.609753, 16.163465, -4.1534753), vec4(-0.07470326, -3.8656476, -7.0901804, 3.1523974), vec4(-12.559385, -7.077619, 1.490437, -0.8211543))
* buf[3]
+ vec4(-7.67914, 15.927437, 1.3207729, -1.6686112);
buf[5] = mat4(vec4(-1.4109162, -0.372762, -3.770383, -21.367174), vec4(-6.2103205, -9.35908, 0.92529047, 8.82561), vec4(11.460242, -22.348068, 13.625772, -18.693201), vec4(-0.3429052, -3.9905605, -2.4626114, -0.45033523))
* buf[0]
+ mat4(vec4(7.3481627, -4.3661838, -6.3037653, -3.868115), vec4(1.5462853, 6.5488915, 1.9701879, -0.58291394), vec4(6.5858274, -2.2180402, 3.7127688, -1.3730392), vec4(-5.7973905, 10.134961, -2.3395722, -5.965605))
* buf[1]
+ mat4(vec4(-2.5132585, -6.6685553, -1.4029363, -0.16285264), vec4(-0.37908727, 0.53738135, 4.389061, -1.3024765), vec4(-0.70647055, 2.0111287, -5.1659346, -3.728635), vec4(-13.562562, 10.487719, -0.9173751, -2.6487076))
* buf[2]
+ mat4(vec4(-8.645013, 6.5546675, -6.3944063, -5.5933375), vec4(-0.57783127, -1.077275, 36.91025, 5.736769), vec4(14.283112, 3.7146652, 7.1452246, -4.5958776), vec4(2.7192075, 3.6021907, -4.366337, -2.3653464))
* buf[3]
+ vec4(-5.9000807, -4.329569, 1.2427121, 8.59503);
buf[4] = sigmoid(buf[4]);
buf[5] = sigmoid(buf[5]);
buf[6] = mat4(vec4(-1.61102, 0.7970257, 1.4675229, 0.20917463), vec4(-28.793737, -7.1390953, 1.5025433, 4.656581), vec4(-10.94861, 39.66238, 0.74318546, -10.095605), vec4(-0.7229728, -1.5483948, 0.7301322, 2.1687684))
* buf[0]
+ mat4(vec4(3.2547753, 21.489103, -1.0194173, -3.3100595), vec4(-3.7316632, -3.3792162, -7.223193, -0.23685838), vec4(13.1804495, 0.7916005, 5.338587, 5.687114), vec4(-4.167605, -17.798311, -6.815736, -1.6451967))
* buf[1]
+ mat4(vec4(0.604885, -7.800309, -7.213122, -2.741014), vec4(-3.522382, -0.12359311, -0.5258442, 0.43852118), vec4(9.6752825, -22.853785, 2.062431, 0.099892326), vec4(-4.3196306, -17.730087, 2.5184598, 5.30267))
* buf[2]
+ mat4(vec4(-6.545563, -15.790176, -6.0438633, -5.415399), vec4(-43.591583, 28.551912, -16.00161, 18.84728), vec4(4.212382, 8.394307, 3.0958717, 8.657522), vec4(-5.0237565, -4.450633, -4.4768, -5.5010443))
* buf[3]
+ mat4(vec4(1.6985557, -67.05806, 6.897715, 1.9004834), vec4(1.8680354, 2.3915145, 2.5231109, 4.081538), vec4(11.158006, 1.7294737, 2.0738268, 7.386411), vec4(-4.256034, -306.24686, 8.258898, -17.132736))
* buf[4]
+ mat4(vec4(1.6889864, -4.5852966, 3.8534803, -6.3482175), vec4(1.3543309, -1.2640043, 9.932754, 2.9079645), vec4(-5.2770967, 0.07150358, -0.13962056, 3.3269649), vec4(28.34703, -4.918278, 6.1044083, 4.085355))
* buf[5]
+ vec4(6.6818056, 12.522166, -3.7075126, -4.104386);
buf[7] = mat4(vec4(-8.265602, -4.7027016, 5.098234, 0.7509808), vec4(8.6507845, -17.15949, 16.51939, -8.884479), vec4(-4.036479, -2.3946867, -2.6055532, -1.9866527), vec4(-2.2167742, -1.8135649, -5.9759874, 4.8846445))
* buf[0]
+ mat4(vec4(6.7790847, 3.5076547, -2.8191125, -2.7028968), vec4(-5.743024, -0.27844876, 1.4958696, -5.0517144), vec4(13.122226, 15.735168, -2.9397483, -4.101023), vec4(-14.375265, -5.030483, -6.2599335, 2.9848232))
* buf[1]
+ mat4(vec4(4.0950394, -0.94011575, -5.674733, 4.755022), vec4(4.3809423, 4.8310084, 1.7425908, -3.437416), vec4(2.117492, 0.16342592, -104.56341, 16.949184), vec4(-5.22543, -2.994248, 3.8350096, -1.9364246))
* buf[2]
+ mat4(vec4(-5.900337, 1.7946124, -13.604192, -3.8060522), vec4(6.6583457, 31.911177, 25.164474, 91.81147), vec4(11.840538, 4.1503043, -0.7314397, 6.768467), vec4(-6.3967767, 4.034772, 6.1714606, -0.32874924))
* buf[3]
+ mat4(vec4(3.4992442, -196.91893, -8.923708, 2.8142626), vec4(3.4806502, -3.1846354, 5.1725626, 5.1804223), vec4(-2.4009497, 15.585794, 1.2863957, 2.0252278), vec4(-71.25271, -62.441242, -8.138444, 0.50670296))
* buf[4]
+ mat4(vec4(-12.291733, -11.176166, -7.3474145, 4.390294), vec4(10.805477, 5.6337385, -0.9385842, -4.7348723), vec4(-12.869276, -7.039391, 5.3029537, 7.5436664), vec4(1.4593618, 8.91898, 3.5101583, 5.840625))
* buf[5]
+ vec4(2.2415268, -6.705987, -0.98861027, -2.117676);
buf[6] = sigmoid(buf[6]);
buf[7] = sigmoid(buf[7]);
buf[0] = mat4(vec4(1.6794263, 1.3817469, 2.9625452, 0.0), vec4(-1.8834411, -1.4806935, -3.5924516, 0.0), vec4(-1.3279216, -1.0918057, -2.3124623, 0.0), vec4(0.2662234, 0.23235129, 0.44178495, 0.0))
* buf[0]
+ mat4(vec4(-0.6299101, -0.5945583, -0.9125601, 0.0), vec4(0.17828953, 0.18300213, 0.18182953, 0.0), vec4(-2.96544, -2.5819945, -4.9001055, 0.0), vec4(1.4195864, 1.1868085, 2.5176322, 0.0))
* buf[1]
+ mat4(vec4(-1.2584374, -1.0552157, -2.1688404, 0.0), vec4(-0.7200217, -0.52666044, -1.438251, 0.0), vec4(0.15345335, 0.15196142, 0.272854, 0.0), vec4(0.945728, 0.8861938, 1.2766753, 0.0))
* buf[2]
+ mat4(vec4(-2.4218085, -1.968602, -4.35166, 0.0), vec4(-22.683098, -18.0544, -41.954372, 0.0), vec4(0.63792, 0.5470648, 1.1078634, 0.0), vec4(-1.5489894, -1.3075932, -2.6444845, 0.0))
* buf[3]
+ mat4(vec4(-0.49252132, -0.39877754, -0.91366625, 0.0), vec4(0.95609266, 0.7923952, 1.640221, 0.0), vec4(0.30616966, 0.15693925, 0.8639857, 0.0), vec4(1.1825981, 0.94504964, 2.176963, 0.0))
* buf[4]
+ mat4(vec4(0.35446745, 0.3293795, 0.59547555, 0.0), vec4(-0.58784515, -0.48177817, -1.0614829, 0.0), vec4(2.5271258, 1.9991658, 4.6846647, 0.0), vec4(0.13042648, 0.08864098, 0.30187556, 0.0))
* buf[5]
+ mat4(vec4(-1.7718065, -1.4033192, -3.3355875, 0.0), vec4(3.1664357, 2.638297, 5.378702, 0.0), vec4(-3.1724713, -2.6107926, -5.549295, 0.0), vec4(-2.851368, -2.249092, -5.3013067, 0.0))
* buf[6]
+ mat4(vec4(1.5203838, 1.2212278, 2.8404984, 0.0), vec4(1.5210563, 1.2651345, 2.683903, 0.0), vec4(2.9789467, 2.4364579, 5.2347264, 0.0), vec4(2.2270417, 1.8825914, 3.8028636, 0.0))
* buf[7]
+ vec4(-1.5468478, -3.6171484, 0.24762098, 0.0);
buf[0] = sigmoid(buf[0]);
return vec4(buf[0].x , buf[0].y , buf[0].z, 1.0);
}
void main() {
vec2 uv = vUv * 2.0 - 1.0; uv.y *= -1.0;
vec4 pattern = cppn_fn(uv, 0.1 * sin(0.3 * iTime), 0.1 * sin(0.69 * iTime), 0.1 * sin(0.44 * iTime));
vec3 color1 = mix(uBackgroundColor, uPrimaryCta, pattern.x);
vec3 color2 = mix(uBackgroundColor, uAccent, pattern.y);
vec3 color3 = mix(uBackgroundColor, uSecondaryCta, pattern.z);
vec3 finalColor = (color1 + color2 + color3) / 3.0;
gl_FragColor = vec4(finalColor, 1.0);
}
`;
const CPPNShaderMaterial = shaderMaterial(
{
iTime: 0,
iResolution: new THREE.Vector2(1, 1),
uBackgroundColor: new THREE.Color(0x000000),
uPrimaryCta: new THREE.Color(0xff0000),
uAccent: new THREE.Color(0x00ff00),
uSecondaryCta: new THREE.Color(0x0000ff),
},
vertexShader,
fragmentShader
);
extend({ CPPNShaderMaterial });
interface ShaderPlaneProps {
backgroundColor: THREE.Color;
primaryCta: THREE.Color;
accent: THREE.Color;
secondaryCta: THREE.Color;
}
const ShaderPlane = memo(({ backgroundColor, primaryCta, accent, secondaryCta }: ShaderPlaneProps) => {
const meshRef = useRef<THREE.Mesh>(null!);
const materialRef = useRef<THREE.ShaderMaterial & {
iTime: number;
iResolution: THREE.Vector2;
uBackgroundColor: THREE.Color;
uPrimaryCta: THREE.Color;
uAccent: THREE.Color;
uSecondaryCta: THREE.Color;
}>(null!);
const { viewport } = useThree();
useFrame((state) => {
if (!materialRef.current) return;
materialRef.current.iTime = state.clock.elapsedTime;
const { width, height } = state.size;
materialRef.current.iResolution.set(width, height);
});
useEffect(() => {
if (!materialRef.current) return;
materialRef.current.uBackgroundColor = backgroundColor;
materialRef.current.uPrimaryCta = primaryCta;
materialRef.current.uAccent = accent;
materialRef.current.uSecondaryCta = secondaryCta;
}, [backgroundColor, primaryCta, accent, secondaryCta]);
return (
<mesh ref={meshRef} position={[0, 0, 0]}>
<planeGeometry args={[viewport.width, viewport.height]} />
<cPPNShaderMaterial ref={materialRef} side={THREE.DoubleSide} />
</mesh>
);
});
ShaderPlane.displayName = 'ShaderPlane';
interface FluidBackgroundProps {
className?: string;
}
const FluidBackground = ({ className = "" }: FluidBackgroundProps) => {
const camera = useMemo(() => ({ position: [0, 0, 5] as [number, number, number], fov: 75, near: 0.1, far: 1000 }), []);
const [colors, setColors] = useState({
background: new THREE.Color(0x000000),
primaryCta: new THREE.Color(0xff0000),
accent: new THREE.Color(0x00ff00),
secondaryCta: new THREE.Color(0x0000ff),
});
useEffect(() => {
const updateColors = () => {
setColors({
background: getComputedColor('--background'),
primaryCta: getComputedColor('--color-background-accent'),
accent: getComputedColor('--color-background-accent'),
secondaryCta: getComputedColor('--color-background-accent'),
});
};
updateColors();
}, []);
return (
<div className={cls("bg-background fixed inset-0 -z-10 w-full h-full", className)} aria-hidden="true">
<Canvas
camera={camera}
gl={{ antialias: true, alpha: false }}
dpr={[1, 2]}
style={{ width: '100%', height: '100%' }}
>
<ShaderPlane
backgroundColor={colors.background}
primaryCta={colors.primaryCta}
accent={colors.accent}
secondaryCta={colors.secondaryCta}
/>
</Canvas>
</div>
);
};
FluidBackground.displayName = 'FluidBackground';
export default memo(FluidBackground);
declare module '@react-three/fiber' {
interface ThreeElements {
cPPNShaderMaterial: unknown;
}
}

View File

@@ -0,0 +1,272 @@
"use client";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cls } from "@/lib/utils";
import { animate } from "motion/react";
const MOBILE_BREAKPOINT = 768;
const INACTIVE_ZONE_MULTIPLIER = 0.5;
const CENTER_MULTIPLIER = 0.5;
const ANGLE_CONVERSION_FACTOR = 180 / Math.PI;
const ANGLE_OFFSET = 90;
const ANGLE_NORMALIZATION = 180;
const FULL_CIRCLE = 360;
const REPEATING_GRADIENT_TIMES = 5;
const GRADIENT_DIVISION = 25;
const ANIMATION_EASING = [0.16, 1, 0.3, 1] as const;
interface GlowingEffectProps {
blur?: number;
inactiveZone?: number;
proximity?: number;
spread?: number;
glow?: boolean;
className?: string;
disabled?: boolean;
movementDuration?: number;
borderWidth?: number;
}
interface Position {
x: number;
y: number;
}
type MouseEventLike = MouseEvent | Position;
const getIsSSR = () => typeof window === "undefined";
const getViewportCenter = (): Position => {
if (getIsSSR()) return { x: 0, y: 0 };
return {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
};
};
const getIsMobileDevice = (): boolean => {
if (getIsSSR()) return false;
return window.innerWidth < MOBILE_BREAKPOINT;
};
const calculateAngleDiff = (current: number, target: number): number => {
return ((target - current + ANGLE_NORMALIZATION) % FULL_CIRCLE) - ANGLE_NORMALIZATION;
};
const GlowingEffect = memo(
({
blur = 0,
inactiveZone = 0.7,
proximity = 0,
spread = 20,
glow = false,
className,
movementDuration = 2,
borderWidth = 1,
disabled = true,
}: GlowingEffectProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const lastPosition = useRef<Position>({ x: 0, y: 0 });
const animationFrameRef = useRef<number>(0);
const [isMobile, setIsMobile] = useState(() => getIsMobileDevice());
const updateElementStyles = useCallback(
(element: HTMLElement, property: string, value: string) => {
element.style.setProperty(property, value);
},
[]
);
const calculateMousePosition = useCallback(
(e?: MouseEventLike): Position => {
if (isMobile) {
return getViewportCenter();
}
return {
x: e?.x ?? lastPosition.current.x,
y: e?.y ?? lastPosition.current.y,
};
},
[isMobile]
);
const animateAngleTransition = useCallback(
(element: HTMLElement, currentAngle: number, targetAngle: number) => {
const angleDiff = calculateAngleDiff(currentAngle, targetAngle);
const newAngle = currentAngle + angleDiff;
animate(currentAngle, newAngle, {
duration: movementDuration,
ease: ANIMATION_EASING,
onUpdate: (value) => {
updateElementStyles(element, "--start", String(value));
},
});
},
[movementDuration, updateElementStyles]
);
const handleMove = useCallback(
(e?: MouseEventLike) => {
if (!containerRef.current) return;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
animationFrameRef.current = requestAnimationFrame(() => {
const element = containerRef.current;
if (!element) return;
const { left, top, width, height } = element.getBoundingClientRect();
const mousePosition = calculateMousePosition(e);
if (e) {
lastPosition.current = mousePosition;
}
const centerX = left + width * CENTER_MULTIPLIER;
const centerY = top + height * CENTER_MULTIPLIER;
const distanceFromCenter = Math.hypot(
mousePosition.x - centerX,
mousePosition.y - centerY
);
const inactiveRadius = INACTIVE_ZONE_MULTIPLIER * Math.min(width, height) * inactiveZone;
if (distanceFromCenter < inactiveRadius) {
updateElementStyles(element, "--active", "0");
return;
}
const isActive =
mousePosition.x > left - proximity &&
mousePosition.x < left + width + proximity &&
mousePosition.y > top - proximity &&
mousePosition.y < top + height + proximity;
updateElementStyles(element, "--active", isActive ? "1" : "0");
if (!isActive) return;
const currentAngle =
parseFloat(element.style.getPropertyValue("--start")) || 0;
const targetAngle =
ANGLE_CONVERSION_FACTOR * Math.atan2(mousePosition.y - centerY, mousePosition.x - centerX) +
ANGLE_OFFSET;
animateAngleTransition(element, currentAngle, targetAngle);
});
},
[inactiveZone, proximity, calculateMousePosition, updateElementStyles, animateAngleTransition]
);
useEffect(() => {
if (getIsSSR()) return;
const checkMobile = () => {
setIsMobile(getIsMobileDevice());
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []);
useEffect(() => {
if (disabled || getIsSSR()) return;
const handleScroll = () => handleMove();
const handlePointerMove = (e: PointerEvent) => {
if (!isMobile) {
handleMove(e);
}
};
if (isMobile) {
handleMove();
}
window.addEventListener("scroll", handleScroll, { passive: true });
document.body.addEventListener("pointermove", handlePointerMove, {
passive: true,
});
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
window.removeEventListener("scroll", handleScroll);
document.body.removeEventListener("pointermove", handlePointerMove);
};
}, [handleMove, disabled, isMobile]);
const gradient = useMemo(
() => `radial-gradient(circle, var(--accent) 10%, transparent 20%),
radial-gradient(circle at 40% 40%, var(--background-accent) 5%, transparent 15%),
repeating-conic-gradient(
from 236.84deg at 50% 50%,
var(--accent) 0%,
var(--background-accent) calc(${GRADIENT_DIVISION}% / var(--repeating-conic-gradient-times)),
var(--accent) calc(${GRADIENT_DIVISION * 2}% / var(--repeating-conic-gradient-times))
)`,
[]
);
const containerStyle = useMemo(
() => ({
"--blur": `${blur}px`,
"--spread": spread,
"--start": "0",
"--active": "0",
"--glowingeffect-border-width": `${borderWidth}px`,
"--repeating-conic-gradient-times": String(REPEATING_GRADIENT_TIMES),
"--gradient": gradient,
} as React.CSSProperties),
[blur, spread, borderWidth, gradient]
);
return (
<>
<div
className={cls(
"pointer-events-none absolute inset-0 hidden rounded-[inherit] border opacity-0 transition-opacity",
glow && "opacity-100",
disabled && "!block"
)}
/>
<div
ref={containerRef}
style={containerStyle}
className={cls(
"pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity",
glow && "opacity-100",
blur > 0 && "blur-[var(--blur)] ",
className,
disabled && "!hidden"
)}
>
<div
className={cls(
"glow",
"rounded-[inherit]",
'after:content-[""] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]',
"after:[border:var(--glowingeffect-border-width)_solid_transparent]",
"after:[background:var(--gradient)] after:[background-attachment:fixed]",
"after:opacity-[var(--active)] after:transition-opacity after:duration-300",
"after:[mask-clip:padding-box,border-box]",
"after:[mask-composite:intersect]",
"after:[mask-image:linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]"
)}
/>
</div>
</>
);
}
);
GlowingEffect.displayName = "GlowingEffect";
export { GlowingEffect };

View File

@@ -0,0 +1,52 @@
'use client';
import { memo } from 'react';
import { cls } from '@/lib/utils';
interface GlowingOrbBackgroundProps {
className?: string;
blurAmount?: string;
glowColor?: string;
backgroundColor?: string;
}
const GlowingOrbBackground = ({
className = "",
blurAmount = "57px",
glowColor = "var(--color-primary-cta)",
backgroundColor = "var(--background)",
}: GlowingOrbBackgroundProps) => {
return (
<div className="absolute z-0 top-0 left-0 w-full h-screen overflow-hidden pointer-events-none select-none [mask-image:linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]" aria-hidden="true">
<div
className={cls("absolute left-1/2 -translate-x-1/2 w-full h-[100vh] -bottom-[9vh] overflow-hidden z-0", className)}
>
<div
className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden"
style={{
background: `radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, ${glowColor} 25%, transparent), transparent)`,
filter: `blur(${blurAmount})`,
WebkitFilter: `blur(${blurAmount})`,
}}
/>
<div
className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-[100vh] rounded-[100%]"
style={{
background: `linear-gradient(180deg, color-mix(in srgb, ${glowColor} 30%, transparent), transparent)`,
}}
/>
<div
className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%]"
style={{
backgroundColor,
boxShadow: `inset 0 2px 20px color-mix(in srgb, ${glowColor} 30%, transparent), 0 -10px 50px 1px color-mix(in srgb, ${glowColor} 25%, transparent)`,
}}
/>
</div>
</div>
);
};
GlowingOrbBackground.displayName = 'GlowingOrbBackground';
export default memo(GlowingOrbBackground);

View File

@@ -0,0 +1,82 @@
'use client';
import { memo } from 'react';
import { cls } from '@/lib/utils';
import { Sparkles } from './Sparkles';
interface GlowingOrbSparklesBackgroundProps {
className?: string;
blurAmount?: string;
glowColor?: string;
backgroundColor?: string;
particleColor?: string;
particleDensity?: number;
minSize?: number;
maxSize?: number;
speed?: number;
}
const GlowingOrbSparklesBackground = ({
className = "",
blurAmount = "57px",
glowColor = "var(--color-primary-cta)",
backgroundColor = "var(--background)",
particleColor = "var(--color-primary-cta)",
particleDensity = 80,
minSize = 0.5,
maxSize = 1.5,
speed = 4,
}: GlowingOrbSparklesBackgroundProps) => {
return (
<div className="absolute z-0 top-0 left-0 w-full h-screen overflow-hidden pointer-events-none select-none [mask-image:linear-gradient(180deg,rgb(0,0,0)_0%,rgb(0,0,0)_80%,rgba(0,0,0,0)_100%)]" aria-hidden="true">
{/* Sparkles layer with radial mask */}
<div
className="absolute inset-0 z-10"
style={{
maskImage: 'radial-gradient(circle at 50% 50%, rgb(0,0,0) 0%, rgb(0,0,0) 20%, rgba(0,0,0,0) 50%)',
WebkitMaskImage: 'radial-gradient(circle at 50% 50%, rgb(0,0,0) 0%, rgb(0,0,0) 20%, rgba(0,0,0,0) 50%)',
}}
>
<Sparkles
className="absolute inset-0"
particleColor={particleColor}
particleDensity={particleDensity}
minSize={minSize}
maxSize={maxSize}
speed={speed}
/>
</div>
{/* Glowing orb layer */}
<div
className={cls("absolute left-1/2 -translate-x-1/2 w-full h-[100vh] -bottom-[9vh] overflow-hidden z-0", className)}
>
<div
className="absolute left-1/2 -translate-x-1/2 w-[49vw] h-[12vh] bottom-[25vh] overflow-hidden"
style={{
background: `radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, ${glowColor} 25%, transparent), transparent)`,
filter: `blur(${blurAmount})`,
WebkitFilter: `blur(${blurAmount})`,
}}
/>
<div
className="absolute -bottom-[61vh] -left-[33vw] -right-[33vw] h-[100vh] rounded-[100%]"
style={{
background: `linear-gradient(180deg, color-mix(in srgb, ${glowColor} 30%, transparent), transparent)`,
}}
/>
<div
className="absolute -bottom-[62vh] -left-[36vw] -right-[36vw] h-[105vh] rounded-[100%]"
style={{
backgroundColor,
boxShadow: `inset 0 2px 20px color-mix(in srgb, ${glowColor} 30%, transparent), 0 -10px 50px 1px color-mix(in srgb, ${glowColor} 25%, transparent)`,
}}
/>
</div>
</div>
);
};
GlowingOrbSparklesBackground.displayName = 'GlowingOrbSparklesBackground';
export default memo(GlowingOrbSparklesBackground);

View File

@@ -0,0 +1,74 @@
'use client';
import { memo } from 'react';
import { cls } from '@/lib/utils';
interface GradientBarsBackgroundProps {
className?: string;
numBarsPerSide?: number;
gradientFrom?: string;
gradientTo?: string;
opacity?: number;
sideWidth?: string;
}
const GradientBarsBackground = ({
className = "",
numBarsPerSide = 8,
gradientFrom = "var(--color-primary-cta)",
gradientTo = "transparent",
opacity = 0.075,
sideWidth = "35%",
}: GradientBarsBackgroundProps) => {
const getBarStyle = (side: 'left' | 'right') => ({
flex: '1 0 0',
minWidth: '30px',
maxWidth: '82px',
background: `linear-gradient(${side === 'left' ? '90deg' : '270deg'}, ${gradientFrom}, ${gradientTo})`,
opacity: opacity,
});
const renderBars = (side: 'left' | 'right') =>
Array.from({ length: numBarsPerSide }).map((_, index) => (
<div key={`${side}-${index}`} className="h-full" style={getBarStyle(side)} />
));
return (
<div
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
aria-hidden="true"
>
<div
className="flex h-8/10 w-full justify-between backface-hidden antialiased"
style={{
transform: 'translateZ(0)',
mask: 'linear-gradient(0deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
}}
>
<div
className="flex h-full overflow-hidden"
style={{
width: sideWidth,
mask: 'linear-gradient(270deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
}}
>
{renderBars('left')}
</div>
<div
className="flex h-full justify-end overflow-hidden"
style={{
width: sideWidth,
mask: 'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 100%)',
}}
>
{renderBars('right')}
</div>
</div>
</div>
);
};
GradientBarsBackground.displayName = 'GradientBarsBackground';
export default memo(GradientBarsBackground);

View File

@@ -0,0 +1,45 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
type GridSize = "small" | "medium" | "large";
interface GridBackroundProps {
size?: GridSize;
className?: string;
perspectiveThreeD?: boolean;
}
const GRID_SIZES: Record<GridSize, string> = {
small: "6.25vw 6.25vw",
medium: "10vw 10vw",
large: "20vw 20vw",
};
const GridBackround = ({
size = "medium",
className = "",
perspectiveThreeD = false
}: GridBackroundProps) => {
return (
<div
className={cls(
"fixed inset-0 -z-10 bg-background [mask-image:radial-gradient(circle_at_center,white_0%,transparent_90%)]",
perspectiveThreeD && "inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
className
)}
style={{
backgroundImage:
"linear-gradient(to right, color-mix(in srgb, var(--background-accent) 10%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--background-accent) 10%, transparent) 1px, transparent 1px)",
backgroundSize: GRID_SIZES[size],
backgroundRepeat: "repeat",
}}
aria-hidden="true"
/>
);
};
GridBackround.displayName = "GridBackround";
export default memo(GridBackround);

View File

@@ -0,0 +1,121 @@
"use client";
import { memo } from "react";
import AnimatedGridBackground from "./AnimatedGridBackground";
import CanvasRevealBackground from "./CanvasRevealBackground";
import CellWaveBackground from "./CellWaveBackground";
import DownwardRaysBackground from "./DownwardRaysBackground";
import GlowingOrbBackground from "./GlowingOrbBackground";
import GlowingOrbSparklesBackground from "./GlowingOrbSparklesBackground";
import GradientBarsBackground from "./GradientBarsBackground";
import RadialGradientBackground from "./RadialGradientBackground";
import RotatedRaysBackground from "./RotatedRaysBackground";
import RotatingGradientBackground from "./RotatingGradientBackground";
import SparklesGradientBackground from "./SparklesGradientBackground";
export type HeroBackgroundVariant =
| "plain"
| "animated-grid"
| "canvas-reveal"
| "cell-wave"
| "downward-rays-animated"
| "downward-rays-animated-grid"
| "downward-rays-static"
| "downward-rays-static-grid"
| "glowing-orb"
| "glowing-orb-sparkles"
| "gradient-bars"
| "radial-gradient"
| "rotated-rays-animated"
| "rotated-rays-animated-grid"
| "rotated-rays-static"
| "rotated-rays-static-grid"
| "rotating-gradient"
| "sparkles-gradient";
type AnimatedGridProps = React.ComponentProps<typeof AnimatedGridBackground>;
type CanvasRevealProps = React.ComponentProps<typeof CanvasRevealBackground>;
type CellWaveProps = React.ComponentProps<typeof CellWaveBackground>;
type GlowingOrbProps = React.ComponentProps<typeof GlowingOrbBackground>;
type GlowingOrbSparklesProps = React.ComponentProps<typeof GlowingOrbSparklesBackground>;
type GradientBarsProps = React.ComponentProps<typeof GradientBarsBackground>;
type RadialGradientProps = React.ComponentProps<typeof RadialGradientBackground>;
type RotatingGradientProps = React.ComponentProps<typeof RotatingGradientBackground>;
type SparklesGradientProps = React.ComponentProps<typeof SparklesGradientBackground>;
export type HeroBackgroundVariantProps =
| { variant: "plain" }
| ({ variant: "animated-grid" } & AnimatedGridProps)
| ({ variant: "canvas-reveal" } & CanvasRevealProps)
| ({ variant: "cell-wave" } & CellWaveProps)
| { variant: "downward-rays-animated" }
| { variant: "downward-rays-animated-grid" }
| { variant: "downward-rays-static" }
| { variant: "downward-rays-static-grid" }
| ({ variant: "glowing-orb" } & GlowingOrbProps)
| ({ variant: "glowing-orb-sparkles" } & GlowingOrbSparklesProps)
| ({ variant: "gradient-bars" } & GradientBarsProps)
| ({ variant: "radial-gradient" } & RadialGradientProps)
| { variant: "rotated-rays-animated" }
| { variant: "rotated-rays-animated-grid" }
| { variant: "rotated-rays-static" }
| { variant: "rotated-rays-static-grid" }
| ({ variant: "rotating-gradient" } & RotatingGradientProps)
| ({ variant: "sparkles-gradient" } & SparklesGradientProps);
const heroBackgroundComponents = {
"animated-grid": AnimatedGridBackground,
"canvas-reveal": CanvasRevealBackground,
"cell-wave": CellWaveBackground,
"downward-rays": DownwardRaysBackground,
"glowing-orb": GlowingOrbBackground,
"glowing-orb-sparkles": GlowingOrbSparklesBackground,
"gradient-bars": GradientBarsBackground,
"radial-gradient": RadialGradientBackground,
"rotated-rays": RotatedRaysBackground,
"rotating-gradient": RotatingGradientBackground,
"sparkles-gradient": SparklesGradientBackground,
} as const;
const HeroBackgrounds = (props: HeroBackgroundVariantProps) => {
if (props.variant === "plain") {
return null;
}
const { variant, ...restProps } = props;
// Handle rotated-rays preset variants
if (variant === "rotated-rays-animated") {
return <RotatedRaysBackground animated={true} showGrid={false} {...(restProps as any)} />;
}
if (variant === "rotated-rays-animated-grid") {
return <RotatedRaysBackground animated={true} showGrid={true} {...(restProps as any)} />;
}
if (variant === "rotated-rays-static") {
return <RotatedRaysBackground animated={false} showGrid={false} {...(restProps as any)} />;
}
if (variant === "rotated-rays-static-grid") {
return <RotatedRaysBackground animated={false} showGrid={true} {...(restProps as any)} />;
}
// Handle downward-rays preset variants
if (variant === "downward-rays-animated") {
return <DownwardRaysBackground animated={true} showGrid={false} {...(restProps as any)} />;
}
if (variant === "downward-rays-animated-grid") {
return <DownwardRaysBackground animated={true} showGrid={true} {...(restProps as any)} />;
}
if (variant === "downward-rays-static") {
return <DownwardRaysBackground animated={false} showGrid={false} {...(restProps as any)} />;
}
if (variant === "downward-rays-static-grid") {
return <DownwardRaysBackground animated={false} showGrid={true} {...(restProps as any)} />;
}
const BackgroundComponent = heroBackgroundComponents[variant];
return <BackgroundComponent {...(restProps as any)} />;
};
HeroBackgrounds.displayName = "HeroBackgrounds";
export default memo(HeroBackgrounds);

View File

@@ -0,0 +1,31 @@
"use client";
import React, { memo } from "react";
import { cls } from "@/lib/utils";
interface NoiseBackgroundProps {
className?: string;
}
const NoiseBackground = ({ className = "" }: NoiseBackgroundProps) => {
return (
<div
className={cls("fixed inset-0 -z-10 bg-background-accent/10",
className
)}
>
<div
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-10"
style={{
backgroundImage: "url(https://webuild-dev.s3.eu-north-1.amazonaws.com/default/noise.webp)",
backgroundSize: "512px"
}}
aria-hidden="true"
/>
</div>
);
};
NoiseBackground.displayName = "NoiseBackground";
export default memo(NoiseBackground);

View File

@@ -0,0 +1,35 @@
"use client";
import React, { memo } from "react";
import { cls } from "@/lib/utils";
interface NoiseDiagonalGradientBackgroundProps {
className?: string;
}
const NoiseDiagonalGradientBackground = ({ className = "" }: NoiseDiagonalGradientBackgroundProps) => {
return (
<div
className={cls("fixed inset-0 -z-10 bg-background-accent/10",
className
)}
>
<div
className="absolute inset-0 overflow-hidden pointer-events-none opacity-100 bg-gradient-to-br from-background via-background-accent/10 to-background-accent/20"
aria-hidden="true"
/>
<div
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-10"
style={{
backgroundImage: "url(https://webuild-dev.s3.eu-north-1.amazonaws.com/default/noise.webp)",
backgroundSize: "512px"
}}
aria-hidden="true"
/>
</div>
);
};
NoiseDiagonalGradientBackground.displayName = "NoiseDiagonalGradientBackground";
export default memo(NoiseDiagonalGradientBackground);

View File

@@ -0,0 +1,35 @@
"use client";
import React, { memo } from "react";
import { cls } from "@/lib/utils";
interface NoiseGradientBackgroundProps {
className?: string;
}
const NoiseGradientBackground = ({ className = "" }: NoiseGradientBackgroundProps) => {
return (
<div
className={cls("fixed inset-0 -z-10 bg-accent/10",
className
)}
>
<div
className="absolute inset-0 overflow-hidden pointer-events-none opacity-100 bg-gradient-to-r from-background via-accent/20 to-primary-cta/20"
aria-hidden="true"
/>
<div
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-12"
style={{
backgroundImage: "url(https://webuild-dev.s3.eu-north-1.amazonaws.com/default/noise.webp)",
backgroundSize: "512px"
}}
aria-hidden="true"
/>
</div>
);
};
NoiseGradientBackground.displayName = "NoiseGradientBackground";
export default memo(NoiseGradientBackground);

View File

@@ -0,0 +1,21 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface PlainBackgroundProps {
className?: string;
}
const PlainBackground = ({ className = "" }: PlainBackgroundProps) => {
return (
<div
className={cls("fixed inset-0 -z-10 bg-background", className)}
aria-hidden="true"
/>
);
};
PlainBackground.displayName = "PlainBackground";
export default memo(PlainBackground);

View File

@@ -0,0 +1,40 @@
'use client';
import React, { memo } from 'react';
import { cls } from '@/lib/utils';
interface RadialGradientBackgroundProps {
className?: string;
centerColor?: string;
edgeColor?: string;
size?: string;
position?: string;
}
const RadialGradientBackground = ({
className = "",
centerColor = "var(--background)",
edgeColor = "var(--color-background-accent)",
size = "130% 130%",
position = "50% 15%",
}: RadialGradientBackgroundProps) => {
return (
<div
className={cls("absolute inset-0 z-0 pointer-events-none select-none md:px-5 md:pb-5", className)}
>
<div
className="relative w-full h-full rounded-b-theme-capped"
style={{
background: `radial-gradient(${size} at ${position}, ${centerColor} 40%, ${edgeColor} 100%)`,
mask: 'linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 15%, rgb(0, 0, 0) 55%, rgb(0, 0, 0) 100%)',
WebkitMask: 'linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 15%, rgb(0, 0, 0) 55%, rgb(0, 0, 0) 100%)',
}}
aria-hidden="true"
/>
</div>
);
};
RadialGradientBackground.displayName = 'RadialGradientBackground';
export default memo(RadialGradientBackground);

View File

@@ -0,0 +1,130 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface RayConfig {
width: number;
opacity: number;
rotation: number;
scale?: number;
left?: string;
animationDuration: number;
animationDelay: number;
}
interface LightSourceConfig {
width: number;
height?: number;
opacity: number;
top: number;
}
interface RotatedRaysBackgroundProps {
animated: boolean;
showGrid: boolean;
className?: string;
containerClassName?: string;
}
const rays: RayConfig[] = [
{ width: 35, opacity: 0.85, rotation: -18, animationDuration: 4, animationDelay: 0 },
{ width: 35, opacity: 0.775, rotation: -12, animationDuration: 3.5, animationDelay: 0.5 },
{ width: 20, opacity: 0.65, rotation: -5, scale: 0.90, animationDuration: 5, animationDelay: 1.2 },
{ width: 15, opacity: 0.25, rotation: -3, animationDuration: 3, animationDelay: 0.3 },
{ width: 40, opacity: 0.45, rotation: 0, scale: 0.79, animationDuration: 4.5, animationDelay: 0.8 },
{ width: 20, opacity: 0.45, rotation: 6, animationDuration: 3.2, animationDelay: 1.5 },
{ width: 35, opacity: 0.65, rotation: 9, scale: 0.83, animationDuration: 4.2, animationDelay: 0.2 },
{ width: 35, opacity: 1, rotation: 14, scale: 0.9, animationDuration: 3.8, animationDelay: 1 },
];
const lightSources: LightSourceConfig[] = [
{ width: 1198, opacity: 0.05, top: -352 },
{ width: 865, height: 929, opacity: 0.15, top: -252 },
{ width: 865, height: 929, opacity: 0.15, top: -252 },
];
const RotatedRaysBackground = ({
animated,
showGrid,
className = "",
containerClassName = "",
}: RotatedRaysBackgroundProps) => {
return (
<div
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
aria-hidden="true"
>
{animated && (
<style>
{`
@keyframes rotatedRayPulse {
0%, 100% { opacity: 0; }
50% { opacity: var(--target-opacity); }
}
`}
</style>
)}
{showGrid && (
<div
className="absolute inset-0 -z-10 bg-background [mask-image:radial-gradient(50%_50%_at_50%_0%,white_0%,transparent_100%)]"
style={{
backgroundImage:
"linear-gradient(to right, color-mix(in srgb, var(--color-background-accent) 20%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--color-background-accent) 10%, transparent) 1px, transparent 1px)",
backgroundSize: "10vw 10vw",
backgroundRepeat: "repeat",
}}
/>
)}
<div
className={cls(
"absolute overflow-hidden w-[1142px] h-[179vh] -top-[571px] -left-[373px]",
"-rotate-[38deg] blur-[16px]",
"[mask:radial-gradient(50%_109%,#000_0%,#000000f6_0%,transparent_96%)]",
containerClassName
)}
>
{rays.map((ray, index) => (
<div
key={`ray-${index}`}
className="absolute overflow-hidden origin-top-right -top-[352px] -bottom-[920px] [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
style={{
width: `${ray.width}px`,
left: ray.left || `calc(50% - ${ray.width / 2}px)`,
transform: `${ray.scale ? `scale(${ray.scale})` : ""} rotate(${ray.rotation}deg)`,
...(animated
? {
"--target-opacity": ray.opacity,
animation: `rotatedRayPulse ${ray.animationDuration}s ease-in-out ${ray.animationDelay}s infinite both`,
}
: {
opacity: ray.opacity,
}),
} as React.CSSProperties}
/>
))}
{lightSources.map((source, index) => (
<div
key={`light-source-${index}`}
className="absolute overflow-hidden [background:radial-gradient(50%_50%_at_50%_50%,var(--color-background-accent)_0%,transparent_100%)]"
style={{
width: `${source.width}px`,
height: source.height ? `${source.height}px` : undefined,
top: `${source.top}px`,
bottom: source.height ? undefined : "-46px",
left: `calc(50% - ${source.width / 2}px)`,
opacity: source.opacity,
}}
/>
))}
</div>
</div>
);
};
RotatedRaysBackground.displayName = "RotatedRaysBackground";
export default memo(RotatedRaysBackground);

View File

@@ -0,0 +1,77 @@
'use client';
import { memo } from 'react';
import { cls } from '@/lib/utils';
import { Sparkles } from './Sparkles';
interface RotatingGradientBackgroundProps {
className?: string;
gradientColorStart?: string;
gradientColorEnd?: string;
bigCircleSize?: string;
smallCircleSize?: string;
blurAmount?: string;
opacity?: number;
showSparkles?: boolean;
}
const RotatingGradientBackground = ({
className = "",
gradientColorStart = "var(--color-background-accent)",
gradientColorEnd = "var(--color-background-accent)",
bigCircleSize = "28vw",
smallCircleSize = "21vw",
blurAmount = "10px",
opacity = 0.6,
showSparkles = true,
}: RotatingGradientBackgroundProps) => {
return (
<div
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
aria-hidden="true"
>
<div
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
style={{
filter: `blur(${blurAmount})`,
WebkitFilter: `blur(${blurAmount})`,
opacity,
}}
>
<div
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full aspect-square animate-spin-slow opacity-75"
style={{
width: bigCircleSize,
height: bigCircleSize,
background: `linear-gradient(229deg, ${gradientColorStart} 10%, color-mix(in srgb, ${gradientColorStart} 0%, transparent) 40%, color-mix(in srgb, ${gradientColorEnd} 0%, transparent) 64%, ${gradientColorEnd} 88%)`,
}}
/>
<div
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full aspect-square animate-spin-reverse opacity-75"
style={{
width: smallCircleSize,
height: smallCircleSize,
background: `linear-gradient(141deg, ${gradientColorStart} 13%, color-mix(in srgb, ${gradientColorStart} 0%, transparent) 37.5%, color-mix(in srgb, ${gradientColorEnd} 0%, transparent) 64%, ${gradientColorEnd} 88%)`,
}}
/>
</div>
{showSparkles && (
<div
className="absolute inset-0"
style={{
mask: 'radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 22%, rgb(0, 0, 0) 32%, rgb(0, 0, 0) 55%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0) 100%), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 15%, rgb(0, 0, 0) 85%, rgba(0, 0, 0, 0) 100%)',
maskComposite: 'intersect',
WebkitMask: 'radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 22%, rgb(0, 0, 0) 32%, rgb(0, 0, 0) 55%, rgba(0, 0, 0, 0) 75%, rgba(0, 0, 0, 0) 100%), linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) 15%, rgb(0, 0, 0) 85%, rgba(0, 0, 0, 0) 100%)',
WebkitMaskComposite: 'source-in',
}}
>
<Sparkles particleDensity={60} minSize={0.3} maxSize={0.8} speed={3} />
</div>
)}
</div>
);
};
RotatingGradientBackground.displayName = 'RotatingGradientBackground';
export default memo(RotatingGradientBackground);

View File

@@ -0,0 +1,460 @@
"use client";
import { useId, useEffect, useState } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import type { Container, SingleOrMultiple } from "@tsparticles/engine";
import { loadSlim } from "@tsparticles/slim";
import { cls } from "@/lib/utils";
import { motion, useAnimation } from "framer-motion";
type SparklesProps = {
id?: string;
className?: string;
background?: string;
particleSize?: number;
minSize?: number;
maxSize?: number;
speed?: number;
particleColor?: string;
particleDensity?: number;
};
const defaultProps = {
minSize: 0.5,
maxSize: 1,
speed: 4,
particleDensity: 100,
particleColor: "var(--color-primary-cta)",
background: "transparent",
};
export const Sparkles = (props: SparklesProps) => {
const {
id,
className,
background = defaultProps.background,
minSize = defaultProps.minSize,
maxSize = defaultProps.maxSize,
speed = defaultProps.speed,
particleColor = defaultProps.particleColor,
particleDensity = defaultProps.particleDensity,
} = props;
const [init, setInit] = useState(false);
const [resolvedColor, setResolvedColor] = useState("#ffffff");
useEffect(() => {
if (particleColor?.startsWith('var(')) {
const varName = particleColor.match(/var\((.*?)\)/)?.[1];
if (varName) {
const computed = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
if (computed) {
setResolvedColor(computed);
}
}
} else {
setResolvedColor(particleColor || "#ffffff");
}
}, [particleColor]);
useEffect(() => {
initParticlesEngine(async (engine) => {
await loadSlim(engine);
}).then(() => {
setInit(true);
});
}, []);
const controls = useAnimation();
const particlesLoaded = async (container?: Container) => {
if (container) {
controls.start({
opacity: 1,
transition: {
duration: 1,
},
});
}
};
const generatedId = useId();
return (
<motion.div animate={controls} className={cls("absolute inset-0 opacity-0", className)}>
{init && (
<Particles
id={id || generatedId}
className={cls("h-full w-full")}
particlesLoaded={particlesLoaded}
options={{
background: {
color: {
value: background || "transparent",
},
},
fullScreen: {
enable: false,
zIndex: 1,
},
fpsLimit: 120,
interactivity: {
events: {
onClick: {
enable: true,
mode: "push",
},
onHover: {
enable: false,
mode: "repulse",
},
resize: true as any,
},
modes: {
push: {
quantity: 4,
},
repulse: {
distance: 200,
duration: 0.4,
},
},
},
particles: {
bounce: {
horizontal: {
value: 1,
},
vertical: {
value: 1,
},
},
collisions: {
absorb: {
speed: 2,
},
bounce: {
horizontal: {
value: 1,
},
vertical: {
value: 1,
},
},
enable: false,
maxSpeed: 50,
mode: "bounce",
overlap: {
enable: true,
retries: 0,
},
},
color: {
value: resolvedColor,
animation: {
h: {
count: 0,
enable: false,
speed: 1,
decay: 0,
delay: 0,
sync: true,
offset: 0,
},
s: {
count: 0,
enable: false,
speed: 1,
decay: 0,
delay: 0,
sync: true,
offset: 0,
},
l: {
count: 0,
enable: false,
speed: 1,
decay: 0,
delay: 0,
sync: true,
offset: 0,
},
},
},
effect: {
close: true,
fill: true,
options: {},
type: {} as SingleOrMultiple<string> | undefined,
},
groups: {},
move: {
angle: {
offset: 0,
value: 90,
},
attract: {
distance: 200,
enable: false,
rotate: {
x: 3000,
y: 3000,
},
},
center: {
x: 50,
y: 50,
mode: "percent",
radius: 0,
},
decay: 0,
distance: {},
direction: "none",
drift: 0,
enable: true,
gravity: {
acceleration: 9.81,
enable: false,
inverse: false,
maxSpeed: 50,
},
path: {
clamp: true,
delay: {
value: 0,
},
enable: false,
options: {},
},
outModes: {
default: "out",
},
random: false,
size: false,
speed: {
min: 0.1,
max: 1,
},
spin: {
acceleration: 0,
enable: false,
},
straight: false,
trail: {
enable: false,
length: 10,
fill: {},
},
vibrate: false,
warp: false,
},
number: {
density: {
enable: true,
width: 400,
height: 400,
},
limit: {
mode: "delete",
value: 0,
},
value: particleDensity || 120,
},
opacity: {
value: {
min: 0.1,
max: 1,
},
animation: {
count: 0,
enable: true,
speed: speed || 4,
decay: 0,
delay: 0,
sync: false,
mode: "auto",
startValue: "random",
destroy: "none",
},
},
reduceDuplicates: false,
shadow: {
blur: 0,
color: {
value: "#000",
},
enable: false,
offset: {
x: 0,
y: 0,
},
},
shape: {
close: true,
fill: true,
options: {},
type: "circle",
},
size: {
value: {
min: minSize || 1,
max: maxSize || 3,
},
animation: {
count: 0,
enable: false,
speed: 5,
decay: 0,
delay: 0,
sync: false,
mode: "auto",
startValue: "random",
destroy: "none",
},
},
stroke: {
width: 0,
},
zIndex: {
value: 0,
opacityRate: 1,
sizeRate: 1,
velocityRate: 1,
},
destroy: {
bounds: {},
mode: "none",
split: {
count: 1,
factor: {
value: 3,
},
rate: {
value: {
min: 4,
max: 9,
},
},
sizeOffset: true,
},
},
roll: {
darken: {
enable: false,
value: 0,
},
enable: false,
enlighten: {
enable: false,
value: 0,
},
mode: "vertical",
speed: 25,
},
tilt: {
value: 0,
animation: {
enable: false,
speed: 0,
decay: 0,
sync: false,
},
direction: "clockwise",
enable: false,
},
twinkle: {
lines: {
enable: false,
frequency: 0.05,
opacity: 1,
},
particles: {
enable: false,
frequency: 0.05,
opacity: 1,
},
},
wobble: {
distance: 5,
enable: false,
speed: {
angle: 50,
move: 10,
},
},
life: {
count: 0,
delay: {
value: 0,
sync: false,
},
duration: {
value: 0,
sync: false,
},
},
rotate: {
value: 0,
animation: {
enable: false,
speed: 0,
decay: 0,
sync: false,
},
direction: "clockwise",
path: false,
},
orbit: {
animation: {
count: 0,
enable: false,
speed: 1,
decay: 0,
delay: 0,
sync: false,
},
enable: false,
opacity: 1,
rotation: {
value: 45,
},
width: 1,
},
links: {
blink: false,
color: {
value: "#fff",
},
consent: false,
distance: 100,
enable: false,
frequency: 1,
opacity: 1,
shadow: {
blur: 5,
color: {
value: "#000",
},
enable: false,
},
triangles: {
enable: false,
frequency: 1,
},
width: 1,
warp: false,
},
repulse: {
value: 0,
enabled: false,
distance: 1,
duration: 1,
factor: 1,
speed: 1,
},
},
detectRetina: true,
}}
/>
)}
</motion.div>
);
};

View File

@@ -0,0 +1,56 @@
'use client';
import { memo } from 'react';
import { cls } from '@/lib/utils';
import { Sparkles } from './Sparkles';
interface SparklesGradientBackgroundProps {
className?: string;
gradientColor?: string;
accentColor?: string;
blurAmount?: string;
}
const SparklesGradientBackground = ({
className = "",
gradientColor = "var(--color-background-accent)",
accentColor = "var(--color-background-accent)",
blurAmount = "6vw",
}: SparklesGradientBackgroundProps) => {
return (
<div
className={cls("absolute inset-0 z-0 overflow-hidden pointer-events-none select-none", className)}
style={{
mask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
WebkitMask: 'radial-gradient(ellipse 100% 100% at 50% 0%, rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 70%)',
}}
aria-hidden="true"
>
<div
className="absolute left-1/2 -translate-x-1/2 w-[65vw] h-[88vh] -top-[59vh] overflow-visible z-0"
>
<div
className="absolute inset-0 rounded-[100%] overflow-hidden"
style={{
background: `radial-gradient(50% 50% at 50% 50%, ${gradientColor}, color-mix(in srgb, ${gradientColor} 25%, transparent) 41%, color-mix(in srgb, ${gradientColor} 20%, transparent))`,
filter: `blur(${blurAmount})`,
WebkitFilter: `blur(${blurAmount})`,
}}
/>
<div
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[33vw] h-[53vh] rounded-[100%] overflow-hidden"
style={{
background: `color-mix(in srgb, ${accentColor} 30%, transparent)`,
filter: `blur(${blurAmount})`,
WebkitFilter: `blur(${blurAmount})`,
}}
/>
</div>
<Sparkles />
</div>
);
};
SparklesGradientBackground.displayName = 'SparklesGradientBackground';
export default memo(SparklesGradientBackground);

View File

@@ -0,0 +1,102 @@
.floating-gradient-background-container {
--circle-size: 80%;
--circle-size-small: 60%;
--blending: hard-light;
}
.floating-gradient-background-circle-one {
background: radial-gradient(circle at center, var(--color-background-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
mix-blend-mode: var(--blending);
width: var(--circle-size);
height: var(--circle-size);
top: calc(50% - var(--circle-size-small) / 2);
left: calc(50% - var(--circle-size-small) / 2);
transform-origin: center center;
animation: moveVertical 20s ease infinite;
}
.floating-gradient-background-circle-two {
background: radial-gradient(circle at center, var(--color-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
mix-blend-mode: var(--blending);
width: var(--circle-size);
height: var(--circle-size);
top: calc(50% - var(--circle-size-small) / 2);
left: calc(50% - var(--circle-size-small) / 2);
transform-origin: calc(50% - 400px);
animation: moveInCircle 20s reverse infinite;
}
.floating-gradient-background-circle-three {
background: radial-gradient(circle at center, var(--color-primary-cta) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
mix-blend-mode: var(--blending);
width: var(--circle-size-small);
height: var(--circle-size-small);
top: calc(50% - var(--circle-size) / 2 + 200px);
left: calc(50% - var(--circle-size) / 2 - 500px);
transform-origin: calc(50% + 400px);
animation: moveInCircle 30s linear infinite;
}
.floating-gradient-background-circle-four {
background: radial-gradient(circle at center, var(--color-background-accent) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
mix-blend-mode: var(--blending);
width: var(--circle-size-small);
height: var(--circle-size-small);
top: calc(50% - var(--circle-size) / 2);
left: calc(50% - var(--circle-size) / 2);
transform-origin: calc(50% - 200px);
animation: moveHorizontal 30s ease infinite;
}
.floating-gradient-background-circle-five {
background: radial-gradient(circle at center, var(--color-primary-cta) 0, rgba(255, 255, 255, 0) 50%) no-repeat;
mix-blend-mode: var(--blending);
width: calc(var(--circle-size-small) * 2);
height: calc(var(--circle-size-small) * 2);
top: calc(50% - var(--circle-size));
left: calc(50% - var(--circle-size));
transform-origin: calc(50% - 800px) calc(50% + 200px);
animation: moveInCircle 20s ease infinite;
}
@keyframes moveInCircle {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes moveVertical {
0% {
transform: translateY(-50%);
}
50% {
transform: translateY(50%);
}
100% {
transform: translateY(-50%);
}
}
@keyframes moveHorizontal {
0% {
transform: translateX(-50%) translateY(-10%);
}
50% {
transform: translateX(50%) translateY(10%);
}
100% {
transform: translateX(-50%) translateY(-10%);
}
}

View File

@@ -0,0 +1,38 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import "./FloatingGradientBackground.css";
interface FloatingGradientBackgroundProps {
className?: string;
}
const FloatingGradientBackground = ({
className = "",
}: FloatingGradientBackgroundProps) => {
return (
<div
className={cls(
"fixed top-0 bottom-0 left-0 right-0 w-full h-full z-0 pointer-events-none blur-[40px]",
"[mask-image:linear-gradient(to_bottom,transparent,#010101_20%,#010101_80%,transparent)]",
"[mask-composite:intersect]",
"[-webkit-mask-image:linear-gradient(to_bottom,transparent,#010101_20%,#010101_80%,transparent)]",
"[-webkit-mask-composite:destination-in]",
"floating-gradient-background-container",
className
)}
aria-hidden="true"
>
<div className="absolute opacity-[0.075] floating-gradient-background-circle-one" />
<div className="absolute opacity-[0.125] floating-gradient-background-circle-two" />
<div className="absolute opacity-[0.125] floating-gradient-background-circle-three" />
<div className="absolute opacity-[0.15] floating-gradient-background-circle-four" />
<div className="absolute opacity-[0.075] floating-gradient-background-circle-five" />
</div>
);
};
FloatingGradientBackground.displayName = "FloatingGradientBackground";
export default memo(FloatingGradientBackground);

View File

@@ -0,0 +1,122 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
export type GridCardItem = {
name: string;
icon: LucideIcon;
};
interface Bento3DCardGridProps {
useInvertedBackground: InvertedBackground;
items: [GridCardItem, GridCardItem, GridCardItem, GridCardItem];
centerIcon: LucideIcon;
className?: string;
}
const gridItemStyle = {
perspective: '1000px',
transformStyle: 'preserve-3d' as const,
};
const EmptyCell = () => (
<div
className="relative aspect-square card shadow rounded-theme-capped opacity-50"
style={gridItemStyle}
/>
);
const cardTranslateZ = [
'group-hover:[transform:translateZ(10px)]',
'group-hover:[transform:translateZ(14px)]',
'group-hover:[transform:translateZ(18px)]',
'group-hover:[transform:translateZ(22px)]',
] as const;
const CardCell = ({ name, Icon, cardIndex }: { name: string; Icon: LucideIcon; cardIndex: number }) => (
<div
className={cls(
"relative card shadow aspect-square rounded-theme-capped flex flex-col justify-between p-3 transition-transform duration-500",
cardTranslateZ[cardIndex]
)}
style={gridItemStyle}
>
<div className="h-6 w-[var(--height-6)] aspect-square rounded-theme primary-button flex items-center justify-center">
<Icon className="h-4/10 w-4/10 text-primary-cta-text" strokeWidth={1.5} />
</div>
<p className="text-xs text-foreground leading-tight line-clamp-4">{name}</p>
</div>
);
const CenterCell = ({ Icon }: { Icon: LucideIcon }) => (
<div
className="aspect-square flex items-center justify-center bg-transparent border-none overflow-visible"
style={gridItemStyle}
>
<div className="card shadow rounded-full h-6/10 aspect-square flex items-center justify-center">
<Icon className="h-4/10 w-4/10 text-foreground" strokeWidth={1.25} />
</div>
</div>
);
const Bento3DCardGrid = ({
useInvertedBackground,
items,
centerIcon: CenterIcon,
className = "",
}: Bento3DCardGridProps) => {
void useInvertedBackground;
const gridPositions = [
{ type: 'empty' },
{ type: 'card', index: 0 },
{ type: 'empty' },
{ type: 'card', index: 1 },
{ type: 'center' },
{ type: 'card', index: 2 },
{ type: 'empty' },
{ type: 'card', index: 3 },
{ type: 'empty' },
] as const;
return (
<div
className={cls("group w-full h-full", className)}
style={{
maskImage: 'linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 5%, black 95%, transparent 100%)',
maskComposite: 'intersect',
WebkitMaskImage: 'linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%), linear-gradient(to bottom, transparent 0%, black 5%, black 95%, transparent 100%)',
WebkitMaskComposite: 'source-in',
}}
>
<div
className="w-full h-full grid grid-cols-3 gap-4 -translate-y-9 -translate-x-8"
style={{
gridAutoRows: '1fr',
perspective: '5000px',
transformStyle: 'preserve-3d',
transform: 'rotateX(45deg) rotateY(20deg) rotate(-25deg) scale(1.1)',
}}
>
{gridPositions.map((pos, index) => {
switch (pos.type) {
case 'card':
const item = items[pos.index];
return <CardCell key={index} name={item.name} Icon={item.icon} cardIndex={pos.index} />;
case 'center':
return <CenterCell key={index} Icon={CenterIcon} />;
default:
return <EmptyCell key={index} />;
}
})}
</div>
</div>
);
};
Bento3DCardGrid.displayName = "Bento3DCardGrid";
export default memo(Bento3DCardGrid);

View File

@@ -0,0 +1,117 @@
"use client";
import { memo } from "react";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
interface StackCardProps {
Icon: LucideIcon;
title: string;
subtitle: string;
detail: string;
iconClassName?: string;
titleClassName?: string;
subtitleClassName?: string;
detailClassName?: string;
}
interface Bento3DStackCardProps extends StackCardProps {
className?: string;
useInvertedBackground: InvertedBackground;
}
const StackCard = memo(({
className = "",
Icon,
title,
subtitle,
detail,
iconClassName = "",
titleClassName = "",
subtitleClassName = "",
detailClassName = "",
useInvertedBackground,
}: Bento3DStackCardProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
return (
<div
className={cls(
"relative flex h-35 w-80 md:w-25 p-6 -skew-y-[8deg] card rounded-theme-capped flex-col justify-between transition-all duration-700",
className
)}
>
<div className="flex items-center gap-2">
<div
className={cls(
"relative h-5 aspect-square primary-button rounded-theme flex items-center justify-center",
iconClassName
)}
>
<Icon className="h-1/2 w-auto aspect-square text-primary-cta-text" strokeWidth={1.5} />
</div>
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", titleClassName)}>
{title}
</p>
</div>
<p className={cls("whitespace-nowrap text-lg", shouldUseLightText ? "text-background" : "text-foreground", subtitleClassName)}>
{subtitle}
</p>
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", detailClassName)}>
{detail}
</p>
</div>
);
});
StackCard.displayName = "StackCard";
interface Bento3DStackCardsProps {
cards: StackCardProps[];
useInvertedBackground: InvertedBackground;
className?: string;
}
const Bento3DStackCards = ({
cards,
useInvertedBackground,
className = "",
}: Bento3DStackCardsProps) => {
const baseClassNames = [
"[grid-area:stack] -translate-y-14 hover:-translate-y-20",
"[grid-area:stack] translate-x-15 translate-y-0 hover:-translate-y-5",
"[grid-area:stack] translate-x-31 translate-y-15 hover:translate-y-10",
];
const displayCards = cards.slice(0, 3).map((card, index) => ({
...card,
className: `${baseClassNames[index]} ${card.iconClassName || ""}`,
}));
return (
<div
className={cls("h-full grid [grid-template-areas:'stack'] place-items-center opacity-100 animate-in fade-in-0 duration-700", className)}
style={{
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, black 0%, black 80%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, black 0%, black 80%, transparent 100%)",
maskComposite: "intersect",
WebkitMaskComposite: "source-in"
}}
>
{displayCards.map((cardProps, index) => (
<StackCard
key={index}
{...cardProps}
useInvertedBackground={useInvertedBackground}
/>
))}
</div>
);
};
Bento3DStackCards.displayName = "Bento3DStackCards";
export default memo(Bento3DStackCards);

View File

@@ -0,0 +1,97 @@
"use client";
import { memo, Fragment } from "react";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
export type TaskItem = {
icon: LucideIcon;
label: string;
time: string;
};
interface Bento3DTaskListProps {
title: string;
items: TaskItem[];
useInvertedBackground: InvertedBackground;
className?: string;
}
const Bento3DTaskList = ({
title,
items,
useInvertedBackground,
className = "",
}: Bento3DTaskListProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
return (
<div
className={cls("h-full w-full flex items-center justify-center", className)}
style={{
perspective: "1200px",
transformStyle: "preserve-3d",
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
maskComposite: "intersect",
WebkitMaskComposite: "source-in"
}}
>
<div
className={cls(
"relative w-80 md:w-25 p-6 card rounded-theme-capped flex flex-col gap-3 translate-x-4 -translate-y-5"
)}
style={{
transform: "rotateX(30deg) rotateY(30deg) rotateZ(-30deg)",
transformStyle: "preserve-3d"
}}
>
<div className="flex items-center gap-2">
<div className="h-[var(--text-base)] w-auto aspect-square rounded-theme primary-button" />
<h3 className={cls("text-base leading-tight", shouldUseLightText ? "text-background" : "text-foreground")}>
{title}
</h3>
</div>
<div className="relative w-full min-w-0 secondary-button rounded-theme-capped flex flex-col p-5 gap-3">
{items.map((item, index) => {
const Icon = item.icon;
return (
<Fragment key={index}>
<div
className={cls(
"w-full min-w-0 flex items-center justify-between gap-3"
)}
>
<div className="w-full min-w-0 flex items-center gap-3">
<div
className="h-6 w-auto aspect-square rounded-theme flex items-center justify-center primary-button"
>
<Icon className="h-4/10 w-4/10 aspect-square text-primary-cta-text" strokeWidth={1.5} />
</div>
<p className={cls("text-sm truncate", shouldUseLightText ? "text-background" : "text-foreground")}>
{item.label}
</p>
</div>
<p className={cls("text-xs text-nowrap", shouldUseLightText ? "text-background/75" : "text-foreground/75")}>
{item.time}
</p>
</div>
{index !== items.length - 1 && (
<div className="h-px bg-background-accent/50" />
)}
</Fragment>
);
})}
</div>
</div>
</div>
);
};
Bento3DTaskList.displayName = "Bento3DTaskList";
export default memo(Bento3DTaskList);

View File

@@ -0,0 +1,77 @@
"use client";
import { memo, useState, useEffect } from "react";
import { cls } from "@/lib/utils";
type BarData = {
defaultHeight: number;
hoverHeight: number;
};
interface BentoAnimatedBarChartProps {
bars?: BarData[];
className?: string;
barClassName?: string;
}
const defaultBars: BarData[] = [
{ defaultHeight: 100, hoverHeight: 40 },
{ defaultHeight: 84, hoverHeight: 100 },
{ defaultHeight: 62, hoverHeight: 75 },
{ defaultHeight: 90, hoverHeight: 50 },
{ defaultHeight: 70, hoverHeight: 90 },
{ defaultHeight: 50, hoverHeight: 60 },
{ defaultHeight: 75, hoverHeight: 85 },
{ defaultHeight: 80, hoverHeight: 70 },
];
const BentoAnimatedBarChart = ({
bars = defaultBars,
className = "",
barClassName = "",
}: BentoAnimatedBarChartProps) => {
const [activeBar, setActiveBar] = useState(2); // Start at third bar (index 2)
useEffect(() => {
const interval = setInterval(() => {
setActiveBar((prev) => (prev + 1) % bars.length);
}, 3000);
return () => clearInterval(interval);
}, [bars.length]);
return (
<div className={cls("group w-full h-full [mask-image:linear-gradient(to_bottom,black_40%,transparent_100%)]", className)}>
<style>{`
.bento-bar {
height: var(--default-height);
}
@media (min-width: 768px) {
.group:hover .bento-bar {
height: var(--hover-height) !important;
}
}
`}</style>
<div className="w-full h-full flex items-end gap-5">
{bars.map((bar, index) => (
<div
key={index}
className={cls("relative bento-bar w-full rounded-theme transition-all duration-500 ease bg-background-accent", barClassName)}
style={
{
"--default-height": `${bar.defaultHeight}%`,
"--hover-height": `${bar.hoverHeight}%`,
} as React.CSSProperties
}
>
<div className={cls("absolute! inset-0 primary-button rounded-theme transition-opacity ease-in-out duration-500", activeBar === index ? "opacity-100" : "opacity-0")} />
</div>
))}
</div>
</div>
);
};
BentoAnimatedBarChart.displayName = "BentoAnimatedBarChart";
export default memo(BentoAnimatedBarChart);

View File

@@ -0,0 +1,96 @@
"use client";
import { memo } from "react";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import { Send } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
export type ChatExchange = {
userMessage: string;
aiResponse: string;
};
interface BentoChatAnimationProps {
aiIcon: LucideIcon;
userIcon: LucideIcon;
exchanges: ChatExchange[];
placeholder: string;
useInvertedBackground: InvertedBackground;
className?: string;
}
const BentoChatAnimation = ({
aiIcon: AiIcon,
userIcon: UserIcon,
exchanges,
placeholder,
useInvertedBackground,
className = "",
}: BentoChatAnimationProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const messages = exchanges.flatMap((exchange) => [
{ content: exchange.userMessage, isUser: true },
{ content: exchange.aiResponse, isUser: false },
]);
const duplicatedMessages = [...messages, ...messages];
return (
<div
className={cls(
"relative h-full w-full flex flex-col overflow-hidden",
className
)}
>
<div className="flex-1 overflow-hidden mask-fade-y">
<div className="flex flex-col animate-marquee-vertical px-4">
{duplicatedMessages.map((message, index) => (
<div
key={index}
className={cls(
"flex items-end gap-2 shrink-0 mb-4",
message.isUser ? "flex-row-reverse" : "flex-row"
)}
>
{message.isUser ? (
<div className="shrink-0 h-8 aspect-square rounded-theme primary-button flex items-center justify-center">
<UserIcon className="h-4/10 w-auto text-primary-cta-text" />
</div>
) : (
<div className="shrink-0 h-8 aspect-square rounded-theme card shadow flex items-center justify-center">
<AiIcon className={cls("h-4/10 w-auto", shouldUseLightText ? "text-background" : "text-foreground")} />
</div>
)}
<div
className={cls(
"max-w-75/100 px-4 py-3 text-sm leading-tight",
message.isUser
? "primary-button rounded-theme-capped rounded-br-none text-primary-cta-text"
: "card rounded-theme-capped rounded-bl-none",
!message.isUser && (shouldUseLightText ? "text-background" : "text-foreground")
)}
>
{message.content}
</div>
</div>
))}
</div>
</div>
<div className="card shadow rounded-theme p-2 pl-5 flex items-center gap-2">
<p className={cls("flex-1 text-sm truncate", shouldUseLightText ? "text-background/75" : "text-foreground/75")}>
{placeholder}
</p>
<div className="h-7 w-auto aspect-square primary-button rounded-theme flex items-center justify-center">
<Send className="h-4/10 w-auto text-primary-cta-text" strokeWidth={1.75} />
</div>
</div>
</div>
);
};
BentoChatAnimation.displayName = "BentoChatAnimation";
export default memo(BentoChatAnimation);

View File

@@ -0,0 +1,204 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { cls } from "@/lib/utils";
import createGlobe, { COBEOptions } from "cobe";
// Helper function to convert CSS color to RGB array
const getRGBFromCSSVar = (varName: string): [number, number, number] => {
if (typeof window === "undefined") return [0.5, 0.5, 0.5];
const value = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
// Handle CSS named colors by creating a temporary element to get computed RGB
if (value && !value.startsWith("#") && !value.startsWith("rgb") && !value.includes("%") && !value.match(/^\d+\s+\d+\s+\d+$/)) {
const temp = document.createElement("div");
temp.style.color = value;
document.body.appendChild(temp);
const computed = getComputedStyle(temp).color;
document.body.removeChild(temp);
if (computed && computed.startsWith("rgb")) {
const match = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) {
const r = parseInt(match[1]) / 255;
const g = parseInt(match[2]) / 255;
const b = parseInt(match[3]) / 255;
return [r, g, b];
}
}
}
// Handle rgba/rgb format (e.g., "rgba(18, 0, 6, .9)" or "rgb(255, 255, 255)")
if (value.startsWith("rgb")) {
const match = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) {
const r = parseInt(match[1]) / 255;
const g = parseInt(match[2]) / 255;
const b = parseInt(match[3]) / 255;
return [r, g, b];
}
}
// Handle hex format (e.g., "#ffffff", "#ffffffaa", or shorthand "#fff", "#f0f")
if (value.startsWith("#")) {
let hex = value.replace("#", "");
// Expand shorthand hex (e.g., "93f" -> "9933ff")
if (hex.length === 3 || hex.length === 4) {
hex = hex.split("").map(c => c + c).join("").substring(0, 6);
}
// Take only first 6 characters (ignore alpha channel if present)
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
return [r, g, b];
}
// Handle HSL format (e.g., "0 0% 100%")
if (value.includes("%")) {
const [h, s, l] = value.split(/\s+/).map(v => parseFloat(v));
// Convert HSL to RGB
const sNorm = s / 100;
const lNorm = l / 100;
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = lNorm - c / 2;
let r = 0, g = 0, b = 0;
if (h < 60) { r = c; g = x; b = 0; }
else if (h < 120) { r = x; g = c; b = 0; }
else if (h < 180) { r = 0; g = c; b = x; }
else if (h < 240) { r = 0; g = x; b = c; }
else if (h < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
return [(r + m), (g + m), (b + m)];
}
// Handle RGB format (e.g., "255 255 255")
const [r, g, b] = value.split(/\s+/).map(v => parseFloat(v) / 255);
return [r || 0.5, g || 0.5, b || 0.5];
};
const getGlobeConfig = (): COBEOptions => ({
width: 800,
height: 800,
onRender: () => {},
devicePixelRatio: 2,
phi: 0,
theta: 0.3,
dark: 0,
diffuse: 0.4,
mapSamples: 16000,
mapBrightness: 1.2,
baseColor: getRGBFromCSSVar("--secondary-cta"),
markerColor: getRGBFromCSSVar("--primary-cta"),
glowColor: getRGBFromCSSVar("--card"),
markers: [
{ location: [14.5995, 120.9842], size: 0.03 },
{ location: [19.076, 72.8777], size: 0.1 },
{ location: [23.8103, 90.4125], size: 0.05 },
{ location: [30.0444, 31.2357], size: 0.07 },
{ location: [39.9042, 116.4074], size: 0.08 },
{ location: [-23.5505, -46.6333], size: 0.1 },
{ location: [19.4326, -99.1332], size: 0.1 },
{ location: [40.7128, -74.006], size: 0.1 },
{ location: [34.6937, 135.5022], size: 0.05 },
{ location: [41.0082, 28.9784], size: 0.06 },
],
});
interface GlobeProps {
className?: string;
config?: COBEOptions;
}
const GlobeComponent = ({
className = "",
config,
}: GlobeProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const globeRef = useRef<{ destroy: () => void } | null>(null);
const phiRef = useRef(0);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [globeConfig, setGlobeConfig] = useState<COBEOptions | null>(null);
const onRender = useCallback(
(state: Record<string, number>) => {
phiRef.current += 0.005;
state.phi = phiRef.current;
state.width = dimensions.width * 2;
state.height = dimensions.width * 2;
},
[dimensions]
);
const onResize = useCallback(() => {
if (canvasRef.current) {
const newWidth = canvasRef.current.offsetWidth;
setDimensions(prev => {
if (prev.width === newWidth) return prev;
return { width: newWidth, height: newWidth };
});
}
}, []);
useEffect(() => {
window.addEventListener("resize", onResize);
onResize();
return () => {
window.removeEventListener("resize", onResize);
};
}, [onResize]);
useEffect(() => {
// Initialize globe config with CSS variables
const defaultConfig = getGlobeConfig();
setGlobeConfig(config ? { ...defaultConfig, ...config } : defaultConfig);
}, [config]);
useEffect(() => {
if (!canvasRef.current || dimensions.width === 0 || !globeConfig) return;
if (globeRef.current) {
globeRef.current.destroy();
}
globeRef.current = createGlobe(canvasRef.current, {
...globeConfig,
width: dimensions.width * 2,
height: dimensions.width * 2,
onRender,
});
setTimeout(() => {
if (canvasRef.current) {
canvasRef.current.style.opacity = "1";
}
});
return () => {
if (globeRef.current) {
globeRef.current.destroy();
globeRef.current = null;
}
};
}, [dimensions, globeConfig, onRender]);
return (
<div
className={cls(
"absolute inset-0 mx-auto w-full aspect-square",
className
)}
>
<canvas
className="size-full opacity-0 transition-opacity duration-500 [contain:layout_paint_size]"
ref={canvasRef}
/>
</div>
);
};
GlobeComponent.displayName = "BentoGlobe";
export const BentoGlobe = React.memo(GlobeComponent);

View File

@@ -0,0 +1,72 @@
"use client";
import { memo } from "react";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
type BentoInfoItem = {
icon: LucideIcon;
label: string;
value: string;
};
interface BentoIconInfoCardsProps {
items: BentoInfoItem[];
useInvertedBackground: InvertedBackground;
className?: string;
cardClassName?: string;
iconWrapperClassName?: string;
iconClassName?: string;
labelClassName?: string;
valueClassName?: string;
}
const BentoIconInfoCards = ({
items,
useInvertedBackground,
className = "",
cardClassName = "",
iconWrapperClassName = "",
iconClassName = "",
labelClassName = "",
valueClassName = "",
}: BentoIconInfoCardsProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const duplicatedItems = [...items, ...items, ...items, ...items];
return (
<div className={cls("h-full min-h-0 overflow-hidden mask-fade-y", className)}>
<div className="flex flex-col animate-marquee-vertical px-px">
{duplicatedItems.map((item, index) => {
const Icon = item.icon;
return (
<div
key={index}
className={cls("card shadow rounded-theme-capped p-3 flex items-center justify-between flex-shrink-0 mb-4", cardClassName)}
>
<div className="w-full min-w-0 flex items-center gap-3">
<div className={cls("h-10 w-auto aspect-square rounded-theme flex items-center justify-center secondary-button", iconWrapperClassName)}>
<Icon className={cls("h-4/10 w-4/10 text-secondary-cta-text", iconClassName)} strokeWidth={1.5} />
</div>
<p className={cls("text-base truncate", shouldUseLightText ? "text-background" : "text-foreground", labelClassName)}>
{item.label}
</p>
</div>
<p className={cls("text-base", shouldUseLightText ? "text-background" : "text-foreground", valueClassName)}>
{item.value}
</p>
</div>
);
})}
</div>
</div>
);
};
BentoIconInfoCards.displayName = "BentoIconInfoCards";
export default memo(BentoIconInfoCards);

View File

@@ -0,0 +1,145 @@
"use client";
import { memo } from "react";
import {
Area,
AreaChart,
CartesianGrid,
YAxis,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import { formatNumber, calculateYAxisWidth, type ChartDataItem } from "./utils";
import CustomTooltip from "./CustomTooltip";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
interface BentoLineChartProps {
data?: ChartDataItem[];
dataKey?: string;
metricLabel?: string;
isPercentage?: boolean;
useInvertedBackground: InvertedBackground;
className?: string;
}
const defaultData: ChartDataItem[] = [
{ value: 120 },
{ value: 180 },
{ value: 150 },
{ value: 280 },
{ value: 220 },
{ value: 350 },
{ value: 300 },
{ value: 250 },
];
const BentoLineChart = memo<BentoLineChartProps>(
({
data = defaultData,
dataKey = "value",
metricLabel = "Value",
isPercentage = false,
useInvertedBackground,
className = "",
}) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const yAxisWidth = calculateYAxisWidth(data, isPercentage);
const strokeColor = "var(--primary-cta)";
const gridColor = "color-mix(in srgb, var(--background-accent) 30%, transparent)";
const tickColor = shouldUseLightText ? "var(--background)" : "var(--foreground)";
return (
<div
className={cls("w-full h-full **:outline-none **:focus:outline-none", className)}
style={{
maskImage: "linear-gradient(to bottom, black 40%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 40%, transparent 100%)",
}}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{
top: 10,
right: 5,
left: 0,
bottom: 14,
}}
>
<defs>
<linearGradient id="bentoLineChartFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={strokeColor} stopOpacity={0.4} />
<stop offset="95%" stopColor={strokeColor} stopOpacity={0} />
</linearGradient>
<linearGradient id="bentoFadeGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="black" stopOpacity={0} />
<stop offset="5%" stopColor="black" stopOpacity={0} />
<stop offset="15%" stopColor="white" stopOpacity={1} />
<stop offset="95%" stopColor="white" stopOpacity={1} />
<stop offset="100%" stopColor="black" stopOpacity={0} />
</linearGradient>
<mask id="bentoFadeMask">
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="url(#bentoFadeGradient)"
/>
</mask>
</defs>
<CartesianGrid
vertical={false}
stroke={gridColor}
strokeWidth={1}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{
fill: tickColor,
fontSize: 10,
}}
width={yAxisWidth}
tickFormatter={(value) =>
isPercentage ? `${value}%` : formatNumber(value)
}
/>
<Tooltip
content={
<CustomTooltip
metricLabel={metricLabel}
isPercentage={isPercentage}
totalItems={data.length}
/>
}
cursor={{
stroke: gridColor,
}}
/>
<Area
dataKey={dataKey}
type="monotone"
fill="url(#bentoLineChartFill)"
stroke={strokeColor}
strokeWidth={2}
mask="url(#bentoFadeMask)"
activeDot={{
fill: strokeColor,
r: 5,
}}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
);
BentoLineChart.displayName = "BentoLineChart";
export default BentoLineChart;

View File

@@ -0,0 +1,64 @@
"use client";
import { memo } from "react";
import { formatNumber } from "./utils";
interface CustomTooltipProps {
active?: boolean;
payload?: Array<{
value: number;
color: string;
}>;
label?: number;
metricLabel?: string;
isPercentage?: boolean;
totalItems: number;
}
const CustomTooltip = memo<CustomTooltipProps>(
({
active,
payload,
label = 0,
metricLabel = "Value",
isPercentage = false,
totalItems,
}: CustomTooltipProps) => {
if (active && payload && payload.length) {
const value = isPercentage
? `${payload[0].value}%`
: formatNumber(payload[0].value);
const today = new Date();
const daysAgo = totalItems - 1 - label;
const date = new Date(today);
date.setDate(today.getDate() - daysAgo);
return (
<div className="card rounded-theme-capped p-3">
<p className="text-xs text-foreground mb-2">
{date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</p>
<div className="flex items-center gap-2">
<div
className="h-1.5 aspect-square rounded-full"
style={{
backgroundColor: payload[0].color,
}}
/>
<span className="text-xs text-foreground">
{metricLabel}: {value}
</span>
</div>
</div>
);
}
return null;
}
);
CustomTooltip.displayName = "CustomTooltip";
export default CustomTooltip;

View File

@@ -0,0 +1,33 @@
export const formatNumber = (value: number): string => {
if (value >= 100000) {
const millions = value / 1000000;
return `${millions.toFixed(1)}M`;
}
if (value >= 1000) {
const thousands = value / 1000;
const rounded = Math.round(thousands * 10) / 10;
return `${rounded}K`;
}
return value.toString();
};
export interface ChartDataItem {
value: number;
}
export const calculateYAxisWidth = (
data: ChartDataItem[],
isPercentage: boolean
): number => {
const maxValue = Math.max(...data.map((item) => item.value));
const formattedMax = isPercentage ? `${maxValue}%` : formatNumber(maxValue);
let multiplier = 9;
if (formattedMax.length === 2) {
multiplier = 11;
} else if (formattedMax.length === 3) {
multiplier = 13;
}
return formattedMax.length * multiplier;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
"use client";
import { memo } from "react";
import Marquee from "react-fast-marquee";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
type BentoMarqueeProps = {
centerIcon: LucideIcon;
useInvertedBackground: InvertedBackground;
className?: string;
} & (
| { variant: "text"; texts: string[] }
| { variant: "icon"; icons: LucideIcon[] }
);
const BentoMarquee = (props: BentoMarqueeProps) => {
const { centerIcon, useInvertedBackground, className = "" } = props;
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const CenterIcon = centerIcon;
const items = props.variant === "text"
? [...props.texts, ...props.texts]
: [...props.icons, ...props.icons];
return (
<div
className={cls("relative h-full w-full flex flex-col overflow-hidden", className)}
style={{
maskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)",
WebkitMaskImage: "radial-gradient(ellipse at center, black 0%, black 30%, transparent 70%)"
}}
>
<div className="absolute top-1/2 left-1/2 -translate-1/2 h-auto w-full flex flex-col justify-center gap-2 opacity-60">
{Array.from({ length: 10 }).map((_, rowIndex) => (
<Marquee
key={rowIndex}
gradient={false}
speed={10}
direction={rowIndex % 2 === 0 ? "left" : "right"}
>
{items.map((item, itemIndex) => (
<div
key={itemIndex}
className={cls("relative mx-1 card rounded-theme flex items-center justify-center", props.variant === "icon" ? "p-2 aspect-square" : "px-4 py-2")}
>
{props.variant === "text" ? (
<p className={cls("text-sm leading-tight", shouldUseLightText ? "text-background" : "text-foreground")}>{item as string}</p>
) : (
(() => {
const Icon = item as LucideIcon;
return <Icon className={cls("h-1/2 w-1/2", shouldUseLightText ? "text-background" : "text-foreground")} strokeWidth={1.5} />;
})()
)}
</div>
))}
</Marquee>
))}
</div>
<div className="absolute! top-1/2 left-1/2 -translate-1/2 z-10 h-18 w-auto aspect-square primary-button backdrop-blur-xs rounded-theme flex items-center justify-center">
<CenterIcon className="h-4/10 w-4/10 text-primary-cta-text" strokeWidth={1.5} />
</div>
</div>
);
};
BentoMarquee.displayName = "BentoMarquee";
export default memo(BentoMarquee);

View File

@@ -0,0 +1,82 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import MediaContent from "@/components/shared/MediaContent";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
export type MediaStackItem = {
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
};
interface BentoMediaStackProps {
items: [MediaStackItem, MediaStackItem, MediaStackItem];
useInvertedBackground: InvertedBackground;
className?: string;
}
const BentoMediaStack = ({
items,
className = "",
}: BentoMediaStackProps) => {
return (
<div
className={cls("group/stack relative w-full h-full card shadow rounded-theme-capped flex items-center justify-center select-none", className)}
>
<div
className={cls(
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-[1]",
"rotate-8 translate-x-[12%] -translate-y-[8%] transition-all duration-500 ease-out",
"2xl:translate-x-[8%] 2xl:-translate-y-[6%]",
"group-hover/stack:translate-x-[22%] group-hover/stack:rotate-12 group-hover/stack:-translate-y-[14%]",
"2xl:group-hover/stack:translate-x-[16%] 2xl:group-hover/stack:-translate-y-[10%]"
)}
>
<MediaContent
imageSrc={items[2].imageSrc}
videoSrc={items[2].videoSrc}
imageAlt={items[2].imageAlt}
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
/>
</div>
<div
className={cls(
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-[2]",
"-rotate-8 -translate-x-[12%] -translate-y-[8%] transition-all duration-500 ease-out",
"2xl:-translate-x-[8%] 2xl:-translate-y-[6%]",
"group-hover/stack:-translate-x-[22%] group-hover/stack:-rotate-12 group-hover/stack:-translate-y-[14%]",
"2xl:group-hover/stack:-translate-x-[16%] 2xl:group-hover/stack:-translate-y-[10%]"
)}
>
<MediaContent
imageSrc={items[1].imageSrc}
videoSrc={items[1].videoSrc}
imageAlt={items[1].imageAlt}
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
/>
</div>
<div
className={cls(
"absolute! w-3/5 2xl:w-1/2 aspect-[4/3] p-1 rounded-theme-capped overflow-hidden primary-button z-30",
"translate-y-[10%] transition-all duration-500 ease-out",
"2xl:translate-y-[7%]",
"group-hover/stack:translate-y-[20%]",
"2xl:group-hover/stack:translate-y-[14%]"
)}
>
<MediaContent
imageSrc={items[0].imageSrc}
videoSrc={items[0].videoSrc}
imageAlt={items[0].imageAlt}
imageClassName="h-full rounded-[calc(var(--radius-theme-capped)*0.95)]!"
/>
</div>
</div>
);
};
BentoMediaStack.displayName = "BentoMediaStack";
export default memo(BentoMediaStack);

View File

@@ -0,0 +1,104 @@
"use client";
import { memo } from "react";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
export type OrbitingItem = {
icon: LucideIcon;
ring?: 1 | 2 | 3; // Which ring to orbit on (1=innermost, 3=outermost), defaults to 2
duration?: number; // Animation duration in seconds, defaults to 10
};
interface BentoOrbitingIconsProps {
centerIcon: LucideIcon;
items: OrbitingItem[];
useInvertedBackground: InvertedBackground;
className?: string;
}
const BentoOrbitingIcons = ({
centerIcon,
items,
useInvertedBackground,
className = "",
}: BentoOrbitingIconsProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const CenterIcon = centerIcon;
const circleStyles = "secondary-button border border-background-accent! shadow rounded-full";
return (
<div
className={cls("relative h-full flex flex-col overflow-hidden", className)}
style={{
perspective: "2000px",
maskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%), linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
maskComposite: "intersect",
WebkitMaskComposite: "source-in"
}}
>
<div
className="flex-1 rounded-t-theme-capped gap-2 flex items-center justify-center w-full h-full inset-x-0 p-2 relative"
style={{
transform: "rotateY(20deg) rotateX(20deg) rotateZ(-20deg)"
}}
>
{/* Background concentric circles */}
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[15rem] w-[15rem] z-[9] opacity-85", circleStyles)} />
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[20rem] w-[20rem] z-[8] opacity-65", circleStyles)} />
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[25rem] w-[25rem] z-[7] opacity-45", circleStyles)} />
<div className={cls("absolute! top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shrink-0 h-[30rem] w-[30rem] z-[6] opacity-25", circleStyles)} />
{/* Center circle with icon */}
<div className={cls("absolute! inset-0 shrink-0 h-40 w-[10rem] z-10 m-auto flex items-center justify-center", circleStyles)}>
<div className="absolute! primary-button h-[5rem] w-[5rem] rounded-full flex items-center justify-center" >
<CenterIcon className="absolute h-1/2 w-1/2 text-primary-cta-text" strokeWidth={1.25} />
</div>
{/* Orbiting items */}
{items.map((item, index) => {
const Icon = item.icon;
const ring = item.ring || 2;
// Ring radii: 7.5rem=120px, 10rem=160px, 12.5rem=200px
const radiusMap = { 1: 120, 2: 160, 3: 200 };
const radius = radiusMap[ring];
const duration = item.duration || 10;
// Evenly distribute items around the circle
const initialPosition = (360 / items.length) * index;
return (
<div
key={index}
className={cls("!absolute top-1/2 left-1/2 h-[2.5rem] w-[2.5rem] card shadow rounded-theme flex items-center justify-center")}
style={{
marginLeft: '-1.25rem',
marginTop: '-1.25rem',
animation: `orbit ${duration}s linear infinite`,
"--initial-position": `${initialPosition}deg`,
"--translate-position": `${radius}px`,
"--orbit-duration": `${duration}s`,
} as React.CSSProperties & {
"--initial-position": string;
"--translate-position": string;
"--orbit-duration": string;
}}
>
<Icon className={cls("h-4/10 w-4/10", shouldUseLightText ? "text-background" : "text-foreground")} strokeWidth={1.5} />
</div>
);
})}
</div>
</div>
</div>
);
};
BentoOrbitingIcons.displayName = "BentoOrbitingIcons";
export default memo(BentoOrbitingIcons);

View File

@@ -0,0 +1,115 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
export type PhoneApp = {
name: string;
icon: LucideIcon;
};
export type PhoneApps8 = [PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp, PhoneApp];
interface BentoPhoneAnimationProps {
statusIcon: LucideIcon;
alertIcon: LucideIcon;
alertTitle: string;
alertMessage: string;
apps: PhoneApps8;
useInvertedBackground: InvertedBackground;
className?: string;
}
const BentoPhoneAnimation = ({
statusIcon: StatusIcon,
alertIcon: AlertIcon,
alertTitle,
alertMessage,
apps,
className = "",
}: BentoPhoneAnimationProps) => {
return (
<div
className={cls(
"group/phone relative h-full flex flex-auto items-center justify-center overflow-hidden cursor-pointer",
className
)}
style={{
maskImage: "linear-gradient(to bottom, black 60%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to bottom, black 60%, transparent 100%)",
}}
>
<div
className={cls(
"absolute inset-x-0 top-0 h-full overflow-hidden isolate",
"pt-8 transition-[padding] duration-500 ease-out group-hover/phone:pt-0",
)}
>
<div
className={cls(
"relative mx-auto card shadow h-100 w-[calc(100%-var(--vw-2)*2)] rounded-[3vw] p-2",
)}
>
<div className="w-full min-w-0 relative h-full overflow-hidden secondary-button rounded-[2.6vw] p-8 pt-6" >
<div
className="relative z-10 mx-auto h-7 w-auto aspect-square card shadow flex items-center justify-center rounded-full"
>
<StatusIcon className="h-4/10 w-4/10 text-foreground transition-colors duration-300 group-hover/phone:text-primary-cta" />
</div>
<div
className={cls(
"absolute! left-8 right-8 z-2 gap-[0.5vw] p-3 card flex flex-row items-center rounded-theme-capped",
"-translate-y-30 scale-90 blur-[2px] opacity-50",
"transition-all duration-500 ease-out",
"group-hover/phone:translate-y-0 group-hover/phone:scale-100 group-hover/phone:blur-none group-hover/phone:opacity-100",
)}
style={{ top: "calc(var(--vw-1_5) + var(--height-7) + var(--vw-1_5))" }}
>
<div
className={cls(
"relative h-8 w-auto aspect-square primary-button flex shrink-0 items-center justify-center rounded-theme",
)}
>
<AlertIcon className="h-4/10 w-4/10 text-primary-cta-text" />
</div>
<div className="min-w-0 flex flex-col gap-0">
<h3
className={cls(
"text-sm leading-tight text-foreground",
)}
>
{alertTitle}
</h3>
<p
className={cls(
"text-xs text-foreground/75 leading-tight truncate",
)}
>
{alertMessage}
</p>
</div>
</div>
<div className="w-full min-w-0 grid grid-cols-4 gap-6 mt-6">
{apps.map(({ name, icon: Icon }) => (
<div key={name} className="w-full min-w-0 flex flex-col items-center gap-2">
<div className="aspect-square w-full primary-button rounded-theme-capped flex items-center justify-center">
<Icon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</div>
<p className="w-full text-xs text-foreground text-center truncate">
{name}
</p>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};
BentoPhoneAnimation.displayName = "BentoPhoneAnimation";
export default memo(BentoPhoneAnimation);

View File

@@ -0,0 +1,83 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
interface BentoRevealIconProps {
icon: LucideIcon;
useInvertedBackground: InvertedBackground;
className?: string;
}
const BentoRevealIcon = ({
icon: Icon,
useInvertedBackground,
className = "",
}: BentoRevealIconProps) => {
void useInvertedBackground;
return (
<div
className={cls(
"group relative h-full w-full flex items-center justify-center overflow-hidden",
className
)}
style={{
maskImage: "linear-gradient(to right, transparent, black 15%, black 85%, transparent), linear-gradient(to bottom, transparent, black 15%, black 85%, transparent)",
WebkitMaskImage: "linear-gradient(to right, transparent, black 15%, black 85%, transparent), linear-gradient(to bottom, transparent, black 15%, black 85%, transparent)",
maskComposite: "intersect",
WebkitMaskComposite: "source-in",
}}
>
<div className="relative h-26 w-[6.5rem]">
<div
className="absolute right-full top-1/2 -mt-48 transition-transform duration-500 ease-out group-hover:-translate-x-12"
style={{ transform: "translateX(calc(52px + 1px - 2px))" }}
>
<div className="relative h-96 aspect-[224/280] -scale-x-100">
<svg viewBox="0 0 224 280" fill="none" className="absolute inset-0 h-full w-full overflow-visible">
<path fill="currentColor" className="text-background-accent/10" d="M8 .25a8 8 0 0 0-8 8v91.704c0 2.258.954 4.411 2.628 5.927l10.744 9.738A7.998 7.998 0 0 1 16 121.546v36.408a7.998 7.998 0 0 1-2.628 5.927l-10.744 9.738A7.998 7.998 0 0 0 0 179.546v92.204a8 8 0 0 0 8 8h308a8 8 0 0 0 8-8V8.25a8 8 0 0 0-8-8H8Z" />
<path stroke="currentColor" className="text-background-accent" d="M.5 99.954V8.25A7.5 7.5 0 0 1 8 .75h308a7.5 7.5 0 0 1 7.5 7.5v263.5a7.5 7.5 0 0 1-7.5 7.5H8a7.5 7.5 0 0 1-7.5-7.5v-92.204a7.5 7.5 0 0 1 2.464-5.557l10.744-9.737a8.5 8.5 0 0 0 2.792-6.298v-36.408a8.5 8.5 0 0 0-2.792-6.298l-10.744-9.737A7.5 7.5 0 0 1 .5 99.954Z" />
</svg>
</div>
</div>
<div
className="absolute left-full top-1/2 -mt-48 transition-transform duration-500 ease-out group-hover:translate-x-12"
style={{ transform: "translateX(calc(-52px - 1px + 2px))" }}
>
<div className="relative h-96 aspect-[224/280]">
<svg viewBox="0 0 224 280" fill="none" className="absolute inset-0 h-full w-full overflow-visible">
<path fill="currentColor" className="text-background-accent/10" d="M8 .25a8 8 0 0 0-8 8v91.704c0 2.258.954 4.411 2.628 5.927l10.744 9.738A7.998 7.998 0 0 1 16 121.546v36.408a7.998 7.998 0 0 1-2.628 5.927l-10.744 9.738A7.998 7.998 0 0 0 0 179.546v92.204a8 8 0 0 0 8 8h308a8 8 0 0 0 8-8V8.25a8 8 0 0 0-8-8H8Z" />
<path stroke="currentColor" className="text-background-accent" d="M.5 99.954V8.25A7.5 7.5 0 0 1 8 .75h308a7.5 7.5 0 0 1 7.5 7.5v263.5a7.5 7.5 0 0 1-7.5 7.5H8a7.5 7.5 0 0 1-7.5-7.5v-92.204a7.5 7.5 0 0 1 2.464-5.557l10.744-9.737a8.5 8.5 0 0 0 2.792-6.298v-36.408a8.5 8.5 0 0 0-2.792-6.298l-10.744-9.737A7.5 7.5 0 0 1 .5 99.954Z" />
</svg>
</div>
</div>
<div className="relative w-full h-full p-2">
<div className="relative w-full h-full primary-button rounded-theme flex items-center justify-center">
<Icon className="relative z-10 h-4/10 w-auto text-primary-cta-text" strokeWidth={1.25} />
</div>
</div>
<div
className="absolute inset-px z-10 rounded-full mix-blend-overlay"
style={{ clipPath: "circle(50%)" }}
>
<div
className="absolute inset-0 z-10 transition-transform duration-500 ease-out group-hover:translate-x-0 group-hover:translate-y-0"
style={{
backgroundImage: "linear-gradient(to bottom right, transparent 30%, black, transparent 70%)",
transform: "translate(-65px, -65px)",
}}
/>
</div>
</div>
</div>
);
};
BentoRevealIcon.displayName = "BentoRevealIcon";
export default memo(BentoRevealIcon);

View File

@@ -0,0 +1,115 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
import { Check, Loader } from "lucide-react";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
export type TimelineItem = {
label: string;
detail: string;
};
interface BentoTimelineProps {
heading: string;
subheading: string;
items: [TimelineItem, TimelineItem, TimelineItem];
completedLabel: string;
useInvertedBackground: InvertedBackground;
className?: string;
}
const itemDelays = [
{ check: 'delay-[150ms]', label: 'delay-[200ms]', detail: 'delay-[250ms]' },
{ check: 'delay-[350ms]', label: 'delay-[400ms]', detail: 'delay-[450ms]' },
{ check: 'delay-[550ms]', label: 'delay-[600ms]', detail: 'delay-[650ms]' },
] as const;
const BentoTimeline = ({
heading,
subheading,
items,
completedLabel,
useInvertedBackground,
className = "",
}: BentoTimelineProps) => {
void useInvertedBackground;
return (
<div
className={cls(
"group relative h-full w-full flex items-center justify-center overflow-hidden",
className
)}
>
<div className="absolute inset-0 flex items-center justify-center">
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-100" />
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-80" />
<div className="absolute h-full aspect-square rounded-full border border-background-accent/30 scale-60" />
</div>
<div className="relative max-w-full min-w-0 flex flex-col gap-3 p-4 mask-fade-y-small">
<div className="card shadow rounded-theme-capped p-3 flex items-center gap-2">
<Loader className="h-[var(--text-sm)] w-auto text-primary transition-transform duration-1000 ease-out group-hover:rotate-[360deg]" strokeWidth={1.5} />
<p className="text-xs text-foreground truncate">{heading}</p>
<p className="text-xs text-foreground/75 ml-auto text-nowrap">{subheading}</p>
</div>
{items.map((item, index) => (
<div
key={index}
className="card shadow rounded-theme-capped px-3 py-2 flex items-center gap-2"
>
<div className="relative h-6 w-auto aspect-square card shadow rounded-theme flex items-center justify-center">
<div className="absolute! h-3/10 w-3/10 primary-button rounded-theme transition-opacity duration-300 group-hover:opacity-0" />
<div
className={cls(
"absolute! inset-0 rounded-theme primary-button flex items-center justify-center",
"opacity-0 scale-75 transition-all duration-300",
`group-hover:opacity-100 group-hover:scale-100 ${itemDelays[index].check}`
)}
>
<Check className="h-1/2 w-1/2 text-primary-cta-text" strokeWidth={2} />
</div>
</div>
<div className="w-full min-w-0 max-w-full flex-1 flex items-center gap-10 justify-between">
<p
className={cls(
"text-xs text-foreground truncate opacity-0 transition-all duration-300",
`group-hover:opacity-100 ${itemDelays[index].label}`
)}
>
{item.label}
</p>
<p
className={cls(
"text-xs text-foreground/75 text-nowrap opacity-0 translate-y-1 transition-all duration-300",
`group-hover:opacity-100 group-hover:translate-y-0 ${itemDelays[index].detail}`
)}
>
{item.detail}
</p>
</div>
</div>
))}
<div className="primary-button rounded-theme-capped p-3 flex items-center justify-center">
<div className="absolute flex gap-2 transition-opacity duration-500 delay-[900ms] group-hover:opacity-0">
{[0, 1, 2].map((i) => (
<div key={i} className="h-2 w-auto aspect-square rounded-theme bg-primary-cta-text" />
))}
</div>
<p
className="text-xs text-primary-cta-text truncate opacity-0 transition-opacity duration-500 delay-[900ms] group-hover:opacity-100"
>
{completedLabel}
</p>
</div>
</div>
</div>
);
};
BentoTimeline.displayName = "BentoTimeline";
export default memo(BentoTimeline);

View File

@@ -0,0 +1,41 @@
"use client";
import { memo } from "react";
import ButtonHoverMagnetic from "./ButtonHoverMagnetic/ButtonHoverMagnetic";
import ButtonIconArrow from "./ButtonIconArrow";
import ButtonShiftHover from "./ButtonShiftHover/ButtonShiftHover";
import ButtonTextStagger from "./ButtonTextStagger/ButtonTextStagger";
import ButtonTextUnderline from "./ButtonTextUnderline";
import ButtonHoverBubble from "./ButtonHoverBubble";
import ButtonExpandHover from "./ButtonExpandHover";
import ButtonElasticEffect from "./ButtonElasticEffect/ButtonElasticEffect";
import ButtonBounceEffect from "./ButtonBounceEffect/ButtonBounceEffect";
import ButtonDirectionalHover from "./ButtonDirectionalHover/ButtonDirectionalHover";
import ButtonTextShift from "./ButtonTextShift/ButtonTextShift";
import type { ButtonVariantProps } from "./types";
export type { ButtonVariant, ButtonVariantProps, ButtonPropsForVariant } from "./types";
const buttonComponents = {
"hover-magnetic": ButtonHoverMagnetic,
"hover-bubble": ButtonHoverBubble,
"expand-hover": ButtonExpandHover,
"elastic-effect": ButtonElasticEffect,
"bounce-effect": ButtonBounceEffect,
"icon-arrow": ButtonIconArrow,
"shift-hover": ButtonShiftHover,
"text-stagger": ButtonTextStagger,
"text-shift": ButtonTextShift,
"text-underline": ButtonTextUnderline,
"directional-hover": ButtonDirectionalHover,
} as const;
const Button = (props: ButtonVariantProps) => {
const { variant = "hover-magnetic", ...restProps } = props;
const ButtonComponent = buttonComponents[variant];
return <ButtonComponent {...restProps} />;
};
Button.displayName = "Button";
export default memo(Button);

View File

@@ -0,0 +1,30 @@
.bounce-button {
--ease-elastic: linear(0, 0.55 7.5%, 0.85 12%, 0.95 14%, 1.03 16.5%, 1.09 20%, 1.13 22%, 1.14 23%, 1.15 24.5%, 1.15 26%, 1.13 28%, 1.11 31%, 1.05 39%, 1.02 43%, 0.99 47%, 0.98 52%, 0.97 59%, 1.002 81%, 1);
transition: transform 0.65s var(--ease-elastic);
}
.bounce-button [data-button-animate-chars] span {
display: inline-block;
position: relative;
text-shadow: 0px calc(var(--text-sm) * 1.5) currentColor;
transform: translateY(0) rotate(0.001deg);
transition: transform 0.65s var(--ease-elastic);
}
.bounce-button:hover {
transform: scale(0.92) rotate(-3deg);
}
.bounce-button:hover [data-button-animate-chars] span {
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(3deg);
}
@media (max-width: 768px) {
.bounce-button:hover {
transform: scale(1) rotate(0deg);
}
.bounce-button:hover [data-button-animate-chars] span {
transform: translateY(0) rotate(0);
}
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useRef, memo } from "react";
import { useCharAnimation } from "../useCharAnimation";
import { useButtonClick } from "../useButtonClick";
import { cls } from "@/lib/utils";
import "./BounceButton.css";
interface ButtonBounceEffectProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
bgClassName?: string;
textClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonBounceEffect = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonBounceEffectProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useButtonClick(href, onClick, scrollToSection);
useCharAnimation(buttonRef, text);
return (
<button
ref={buttonRef}
type={type}
onClick={handleClick}
data-href={href}
disabled={disabled}
aria-label={ariaLabel || text}
className={cls(
"bounce-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<div
className={cls(
"bounce-button-bg absolute! inset-0 rounded-theme primary-button",
bgClassName
)}
></div>
<span
data-button-animate-chars=""
className={cls(
"bounce-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
textClassName
)}
>
{text}
</span>
</button>
);
};
ButtonBounceEffect.displayName = "ButtonBounceEffect";
export default memo(ButtonBounceEffect);

View File

@@ -0,0 +1,83 @@
"use client";
import { useRef, memo } from "react";
import { useDirectionalHover } from "./useDirectionalHover";
import { useButtonClick } from "../useButtonClick";
import { cls } from "@/lib/utils";
import "./DirectionalButton.css";
export interface ButtonDirectionalHoverProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
bgClassName?: string;
textClassName?: string;
circleClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonDirectionalHover = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
circleClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonDirectionalHoverProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useButtonClick(href, onClick, scrollToSection);
useDirectionalHover(buttonRef);
return (
<button
ref={buttonRef}
type={type}
data-href={href}
onClick={handleClick}
disabled={disabled}
aria-label={ariaLabel || text}
className={cls(
"directional-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<div
className={cls(
"directional-button-bg absolute! inset-0 rounded-theme primary-button",
bgClassName
)}
></div>
<div className="directional-button-circle-wrap">
<div
className={cls(
"directional-button-circle bg-accent",
circleClassName
)}
></div>
</div>
<span
className={cls(
"directional-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
textClassName
)}
>
{text}
</span>
</button>
);
};
ButtonDirectionalHover.displayName = "ButtonDirectionalHover";
export default memo(ButtonDirectionalHover);

View File

@@ -0,0 +1,37 @@
.directional-button-circle-wrap {
border-radius: inherit;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: hidden;
}
.directional-button-circle {
pointer-events: none;
border-radius: 50%;
width: 100%;
display: block;
position: absolute;
top: 50%;
left: 50%;
transition: transform 0.7s cubic-bezier(0.625, 0.05, 0, 1);
transform: translate(-50%, -50%) scale(0) rotate(0.001deg);
}
.directional-button-circle::before {
content: "";
display: block;
padding-top: 100%;
}
.directional-button:hover .directional-button-circle {
transform: translate(-50%, -50%) scale(1) rotate(0.001deg);
}
@media (max-width: 768px) {
.directional-button:hover .directional-button-circle {
transform: translate(-50%, -50%) scale(0) rotate(0.001deg);
}
}

View File

@@ -0,0 +1,48 @@
import { useEffect, useCallback, RefObject } from "react";
export const useDirectionalHover = (
buttonRef: RefObject<HTMLButtonElement | null>,
circleSelector: string = ".directional-button-circle"
) => {
const handleHover = useCallback(
(event: MouseEvent) => {
const button = buttonRef.current;
if (!button) return;
const buttonRect = button.getBoundingClientRect();
const buttonWidth = buttonRect.width;
const buttonHeight = buttonRect.height;
const buttonCenterX = buttonRect.left + buttonWidth / 2;
const mouseX = event.clientX;
const mouseY = event.clientY;
const offsetXFromLeft = ((mouseX - buttonRect.left) / buttonWidth) * 100;
const offsetYFromTop = ((mouseY - buttonRect.top) / buttonHeight) * 100;
let offsetXFromCenter = ((mouseX - buttonCenterX) / (buttonWidth / 2)) * 50;
offsetXFromCenter = Math.abs(offsetXFromCenter);
const circle = button.querySelector(circleSelector) as HTMLElement;
if (circle) {
circle.style.left = `${offsetXFromLeft.toFixed(1)}%`;
circle.style.top = `${offsetYFromTop.toFixed(1)}%`;
circle.style.width = `${115 + offsetXFromCenter * 2}%`;
}
},
[buttonRef, circleSelector]
);
useEffect(() => {
const button = buttonRef.current;
if (!button) return;
button.addEventListener("mouseenter", handleHover);
button.addEventListener("mouseleave", handleHover);
return () => {
button.removeEventListener("mouseenter", handleHover);
button.removeEventListener("mouseleave", handleHover);
};
}, [buttonRef, handleHover]);
};

View File

@@ -0,0 +1,55 @@
"use client";
import { memo } from "react";
import useElasticEffect from "./useElasticEffect";
import { useButtonClick } from "../useButtonClick";
import { cls } from "@/lib/utils";
interface ButtonElasticEffectProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
textClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonElasticEffect = ({
text,
onClick,
href,
className = "",
textClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonElasticEffectProps) => {
const elasticRef = useElasticEffect<HTMLButtonElement>();
const handleClick = useButtonClick(href, onClick, scrollToSection);
return (
<button
ref={elasticRef}
type={type}
onClick={handleClick}
disabled={disabled}
data-href={href}
aria-label={ariaLabel || text}
className={cls(
"relative cursor-pointer h-9 min-w-0 w-fit max-w-full px-6 primary-button rounded-theme text-primary-cta-text",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
</button>
);
};
ButtonElasticEffect.displayName = "ButtonElasticEffect";
export default memo(ButtonElasticEffect);

View File

@@ -0,0 +1,59 @@
"use client";
import { useRef, useEffect, useCallback } from "react";
import gsap from "gsap";
const useElasticEffect = <T extends HTMLElement>() => {
const elementRef = useRef<T>(null);
const hoverLockedRef = useRef(false);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const handleMouseEnter = useCallback(() => {
const el = elementRef.current;
if (!el || hoverLockedRef.current) return;
hoverLockedRef.current = true;
setTimeout(() => {
hoverLockedRef.current = false;
}, 500);
const w = el.offsetWidth;
const h = el.offsetHeight;
const fs = parseFloat(getComputedStyle(el).fontSize);
const stretch = 0.75 * fs;
const sx = (w + stretch) / w;
const sy = (h - stretch * 0.33) / h;
if (timelineRef.current) {
timelineRef.current.kill();
}
timelineRef.current = gsap
.timeline()
.to(el, { scaleX: sx, scaleY: sy, duration: 0.1, ease: "power1.out" })
.to(el, { scaleX: 1, scaleY: 1, duration: 1, ease: "elastic.out(1, 0.3)" });
}, []);
useEffect(() => {
// Skip on touch devices
if (window.matchMedia("(hover: none) and (pointer: coarse)").matches) {
return;
}
const el = elementRef.current;
if (!el) return;
el.addEventListener("mouseenter", handleMouseEnter);
return () => {
el.removeEventListener("mouseenter", handleMouseEnter);
if (timelineRef.current) {
timelineRef.current.kill();
}
};
}, [handleMouseEnter]);
return elementRef;
};
export default useElasticEffect;

View File

@@ -0,0 +1,92 @@
"use client";
import { memo } from "react";
import { ArrowUpRight } from "lucide-react";
import { useButtonClick } from "./useButtonClick";
import { cls } from "@/lib/utils";
interface ButtonExpandHoverProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
textClassName?: string;
iconClassName?: string;
iconBgClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonExpandHover = ({
text,
onClick,
href,
className = "",
textClassName = "",
iconClassName = "",
iconBgClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonExpandHoverProps) => {
const handleClick = useButtonClick(href, onClick, scrollToSection);
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
data-href={href}
aria-label={ariaLabel || text}
className={cls(
"group relative cursor-pointer h-fit min-w-0 w-fit max-w-full rounded-theme text-sm text-background pointer-events-auto outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<div
className="relative h-9 w-full px-5"
style={{ paddingRight: "calc(2.25rem + 0.75rem)" }}
>
<div className="h-9 flex items-center" >
<span
className={cls(
"relative z-10 block overflow-hidden truncate whitespace-nowrap md:transition-colors md:duration-[900ms] md:[transition-timing-function:cubic-bezier(.77,0,.18,1)]",
textClassName
)}
>
{text}
</span>
</div>
<div className="absolute overflow-hidden top-[2px] bottom-[2px] left-[2px] right-[2px] rounded-theme flex justify-end">
<div
className={cls(
"relative z-10 h-full w-auto aspect-square flex items-center justify-center",
iconClassName
)}
>
<ArrowUpRight
className="h-1/2 w-auto aspect-square"
strokeWidth={1}
/>
</div>
<div
className={cls(
"absolute z-0 h-full w-full rounded-theme",
"md:transition-transform md:duration-[900ms] md:[transition-timing-function:cubic-bezier(.77,0,.18,1)]",
"-translate-x-[calc(-100%+2.25rem-4px)] md:group-hover:translate-x-0",
iconBgClassName
)}
></div>
</div>
</div>
</button>
);
};
ButtonExpandHover.displayName = "ButtonExpandHover";
export default memo(ButtonExpandHover);

View File

@@ -0,0 +1,83 @@
"use client";
import { memo } from "react";
import { ArrowDownRight } from "lucide-react";
import { useButtonClick } from "./useButtonClick";
import { cls } from "@/lib/utils";
interface ButtonHoverBubbleProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
bgClassName?: string;
textClassName?: string;
iconClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonHoverBubble = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
iconClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonHoverBubbleProps) => {
const handleClick = useButtonClick(href, onClick, scrollToSection);
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
aria-label={ariaLabel || text}
data-href={href}
className={cls(
"relative group flex justify-center items-center min-w-0 w-fit max-w-full rounded-theme cursor-pointer pointer-events-auto outline-none",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<div
className={cls(
"flex justify-center items-center h-9 aspect-square rounded-theme relative",
"scale-0 md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:origin-left md:group-hover:scale-100",
iconClassName
)}
>
<ArrowDownRight strokeWidth={1.5} className="h-[35%] w-auto aspect-square object-contain md:transition-transform md:duration-700 md:group-hover:rotate-[-45deg]" />
</div>
<div
className={cls(
"flex justify-center items-center h-9 px-4 min-w-0 w-fit max-w-full rounded-theme relative",
"-translate-x-[var(--height-9)] md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:group-hover:translate-x-0",
bgClassName
)}
>
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
</div>
<div
className={cls(
"flex justify-center items-center h-9 aspect-square rounded-theme absolute right-0 z-20",
"scale-100 md:transition-transform md:duration-700 md:ease-[cubic-bezier(0.625,0.05,0,1)] md:origin-right md:group-hover:scale-0",
iconClassName
)}
>
<ArrowDownRight strokeWidth={1.5} className="h-[35%] w-auto aspect-square object-contain md:transition-transform md:duration-700 md:group-hover:rotate-[-45deg]" />
</div>
</button>
);
};
ButtonHoverBubble.displayName = "ButtonHoverBubble";
export default memo(ButtonHoverBubble);

View File

@@ -0,0 +1,57 @@
"use client";
import { memo } from "react";
import useMagneticEffect from "./useMagneticEffect";
import { useButtonClick } from "../useButtonClick";
import { cls } from "@/lib/utils";
interface ButtonHoverMagneticProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
textClassName?: string;
strengthFactor?: number;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonHoverMagnetic = ({
text,
onClick,
href,
className = "",
textClassName = "",
strengthFactor = 20,
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonHoverMagneticProps) => {
const magneticRef = useMagneticEffect(strengthFactor);
const handleClick = useButtonClick(href, onClick, scrollToSection);
return (
<button
ref={magneticRef as React.RefObject<HTMLButtonElement>}
data-href={href}
type={type}
onClick={handleClick}
disabled={disabled}
aria-label={ariaLabel || text}
className={cls(
"relative cursor-pointer h-9 min-w-0 w-fit max-w-full px-6 rounded-theme",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<span className={cls("text-sm block overflow-hidden truncate whitespace-nowrap", textClassName)}>{text}</span>
</button>
);
};
ButtonHoverMagnetic.displayName = "ButtonHoverMagnetic";
export default memo(ButtonHoverMagnetic);

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef } from "react";
const useMagneticEffect = (strengthFactor = 10) => {
const elementRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
import("gsap").then((gsap) => {
const element = elementRef.current;
if (!element || window.innerWidth < 768) return;
const resetEl = (el: HTMLElement, immediate: boolean) => {
if (!el) return;
gsap.default.killTweensOf(el);
(immediate ? gsap.default.set : gsap.default.to)(el, {
x: "0vw",
y: "0vw",
rotate: "0deg",
clearProps: "all",
...(!immediate && { ease: "elastic.out(1, 0.3)", duration: 1.6 })
});
};
const resetOnEnter = () => {
resetEl(element, true);
};
const moveMagnet = (e: MouseEvent) => {
const b = element.getBoundingClientRect();
const strength = strengthFactor;
const offsetX = ((e.clientX - b.left) / element.offsetWidth - 0.5) * (strength / 16);
const offsetY = ((e.clientY - b.top) / element.offsetHeight - 0.5) * (strength / 16);
gsap.default.to(element, {
x: offsetX + "vw",
y: offsetY + "vw",
rotate: "0.001deg",
ease: "power4.out",
duration: 1.6
});
};
const resetMagnet = () => {
gsap.default.to(element, {
x: "0vw",
y: "0vw",
ease: "elastic.out(1, 0.3)",
duration: 1.6,
clearProps: "all"
});
};
element.addEventListener("mouseenter", resetOnEnter);
element.addEventListener("mousemove", moveMagnet);
element.addEventListener("mouseleave", resetMagnet);
return () => {
element.removeEventListener("mouseenter", resetOnEnter);
element.removeEventListener("mousemove", moveMagnet);
element.removeEventListener("mouseleave", resetMagnet);
};
});
}, [strengthFactor]);
return elementRef;
};
export default useMagneticEffect;

View File

@@ -0,0 +1,66 @@
"use client";
import { ArrowRight } from "lucide-react";
import { memo } from "react";
import { useButtonClick } from "./useButtonClick";
import { cls } from "@/lib/utils";
interface ButtonIconArrowProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
textClassName?: string;
iconClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonIconArrow = ({
text,
onClick,
href,
className = "",
textClassName = "",
iconClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonIconArrowProps) => {
const handleClick = useButtonClick(href, onClick, scrollToSection);
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
data-href={href}
aria-label={ariaLabel || text}
className={cls(
"relative group cursor-pointer h-9 min-w-0 w-fit max-w-full primary-button rounded-theme px-6 text-sm text-primary-cta-text flex items-center gap-3",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<span className={cls(
"block overflow-hidden truncate whitespace-nowrap md:transition-transform md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:[transform:translateX(calc(var(--height-9)/4))]",
textClassName
)}>
{text}
</span>
<div className={cls(
"h-5 w-[var(--height-5)] aspect-square rounded-theme flex items-center justify-center md:transition-transform md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:scale-[0.2] md:group-hover:rotate-90",
iconClassName || "secondary-button text-secondary-cta-text"
)}>
<ArrowRight className="h-1/2 w-1/2 md:transition-opacity md:duration-[600ms] md:[transition-timing-function:cubic-bezier(.25,.8,.25,1)] md:group-hover:opacity-0" />
</div>
</button>
);
};
ButtonIconArrow.displayName = "ButtonIconArrow";
export default memo(ButtonIconArrow);

View File

@@ -0,0 +1,73 @@
"use client";
import { useRef, memo } from "react";
import { useCharAnimation } from "../useCharAnimation";
import { useButtonClick } from "../useButtonClick";
import { cls } from "@/lib/utils";
import "./ShiftButton.css";
interface ButtonShiftHoverProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
bgClassName?: string;
textClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonShiftHover = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonShiftHoverProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useButtonClick(href, onClick, scrollToSection);
useCharAnimation(buttonRef, text);
return (
<button
ref={buttonRef}
type={type}
onClick={handleClick}
disabled={disabled}
data-href={href}
aria-label={ariaLabel || text}
className={cls(
"shift-button group relative cursor-pointer flex gap-2 items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-5 pr-4 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text text-sm",
"disabled:cursor-not-allowed disabled:opacity-50",
textClassName,
className
)}
>
<div
className={cls(
"shift-button-bg absolute! inset-0 rounded-theme transition-transform duration-[600ms] primary-button",
bgClassName
)}
></div>
<span
data-button-animate-chars=""
className="shift-button-text relative inline-block overflow-hidden truncate whitespace-nowrap"
>
{text}
</span>
<div className="relative h-[1em] w-auto aspect-square rounded-theme border border-current scale-65 transition-all duration-300 md:group-hover:bg-current md:group-hover:scale-40" />
</button>
);
};
ButtonShiftHover.displayName = "ButtonShiftHover";
export default memo(ButtonShiftHover);

View File

@@ -0,0 +1,29 @@
.shift-button [data-button-animate-chars] span {
display: inline-block;
position: relative;
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
transform: translateY(0em) rotate(0.001deg);
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
}
.shift-button:hover [data-button-animate-chars] span {
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
}
.shift-button:hover .shift-button-bg {
transform: scale(0.975);
}
@media (max-width: 768px) {
.shift-button [data-button-animate-chars] span {
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
}
.shift-button:hover [data-button-animate-chars] span {
transform: translateY(0vw) rotate(0);
}
.shift-button:hover .shift-button-bg {
transform: scale(1);
}
}

View File

@@ -0,0 +1,75 @@
"use client";
import { memo } from "react";
import { useButtonClick } from "./useButtonClick";
import { cls } from "@/lib/utils";
interface ButtonSlideBackgroundProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
textClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonSlideBackground = ({
text,
onClick,
href,
className = "",
textClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonSlideBackgroundProps) => {
const handleClick = useButtonClick(href, onClick, scrollToSection);
const cubicBezier = "cubic-bezier(0.4, 0, 0, 1)";
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
data-href={href}
aria-label={ariaLabel || text}
className={cls(
"group relative flex items-center justify-center h-9 min-w-0 w-fit max-w-full px-6 rounded-theme overflow-hidden cursor-pointer",
"primary-button",
"after:content-[''] after:absolute after:left-0 after:bottom-0 after:w-full after:h-full",
"after:translate-y-[101%] after:rounded-t-[50%] hover:after:translate-y-0 hover:after:rounded-none",
"after:transition-all after:duration-500",
"after:bg-background",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
style={{
transform: "scaleX(1)",
transition: `transform 0.5s ${cubicBezier}`,
}}
>
<span
className={cls(
"inline-block text-sm overflow-hidden relative",
"text-primary-cta-text",
"after:content-[attr(data-text)] after:w-full after:h-full after:inline-block after:absolute",
"after:left-1/2 after:bottom-0 after:z-[1] after:-translate-x-1/2 after:translate-y-full group-hover:after:translate-y-0",
"after:transition-transform after:duration-500 after:ease-[cubic-bezier(0.2,0,0,1)]",
"after:text-foreground",
textClassName
)}
data-text={text}
>
{text}
</span>
</button>
);
};
ButtonSlideBackground.displayName = "ButtonSlideBackground";
export default memo(ButtonSlideBackground);

View File

@@ -0,0 +1,74 @@
"use client";
import { useRef, memo } from "react";
import { useCharAnimation } from "../useCharAnimation";
import { useButtonClick } from "../useButtonClick";
import { cls } from "@/lib/utils";
import "./TextShiftButton.css";
export interface ButtonTextShiftProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
bgClassName?: string;
textClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonTextShift = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonTextShiftProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useButtonClick(href, onClick, scrollToSection);
useCharAnimation(buttonRef, text, "[data-button-animate-chars]", 0.0);
return (
<button
ref={buttonRef}
type={type}
data-href={href}
onClick={handleClick}
disabled={disabled}
aria-label={ariaLabel || text}
className={cls(
"stagger-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<div
className={cls(
"stagger-button-bg absolute! inset-0 rounded-theme primary-button",
bgClassName
)}
></div>
<span
data-button-animate-chars=""
className={cls(
"stagger-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
textClassName
)}
>
{text}
</span>
</button>
);
};
ButtonTextShift.displayName = "ButtonTextShift";
export default memo(ButtonTextShift);

View File

@@ -0,0 +1,21 @@
.stagger-button [data-button-animate-chars] span {
display: inline-block;
position: relative;
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
transform: translateY(0em) rotate(0.001deg);
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
}
.stagger-button:hover [data-button-animate-chars] span {
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
}
@media (max-width: 768px) {
.stagger-button [data-button-animate-chars] span {
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
}
.stagger-button:hover [data-button-animate-chars] span {
transform: translateY(0vw) rotate(0);
}
}

View File

@@ -0,0 +1,74 @@
"use client";
import { useRef, memo } from "react";
import { useCharAnimation } from "../useCharAnimation";
import { useButtonClick } from "../useButtonClick";
import { cls } from "@/lib/utils";
import "./StaggerButton.css";
export interface ButtonTextStaggerProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
bgClassName?: string;
textClassName?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonTextStagger = ({
text,
onClick,
href,
className = "",
bgClassName = "",
textClassName = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonTextStaggerProps) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const handleClick = useButtonClick(href, onClick, scrollToSection);
useCharAnimation(buttonRef, text, "[data-button-animate-chars]", 0.01);
return (
<button
ref={buttonRef}
type={type}
onClick={handleClick}
data-href={href}
disabled={disabled}
aria-label={ariaLabel || text}
className={cls(
"stagger-button relative cursor-pointer flex items-center justify-center bg-transparent border-none leading-none no-underline h-9 px-6 min-w-0 w-fit max-w-full rounded-theme text-primary-cta-text",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
>
<div
className={cls(
"stagger-button-bg absolute! inset-0 rounded-theme transition-transform duration-[600ms] primary-button",
bgClassName
)}
></div>
<span
data-button-animate-chars=""
className={cls(
"stagger-button-text relative text-sm inline-block overflow-hidden truncate whitespace-nowrap",
textClassName
)}
>
{text}
</span>
</button>
);
};
ButtonTextStagger.displayName = "ButtonTextStagger";
export default memo(ButtonTextStagger);

View File

@@ -0,0 +1,29 @@
.stagger-button [data-button-animate-chars] span {
display: inline-block;
position: relative;
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
transform: translateY(0em) rotate(0.001deg);
transition: transform 0.6s cubic-bezier(0.215, 0.61, 0.355, 1);
}
.stagger-button:hover [data-button-animate-chars] span {
transform: translateY(calc(var(--text-sm) * -1.5)) rotate(0.001deg);
}
.stagger-button:hover .stagger-button-bg {
transform: scale(0.975);
}
@media (max-width: 768px) {
.stagger-button [data-button-animate-chars] span {
text-shadow: 0px calc(var(--text-sm)*1.5) currentColor;
}
.stagger-button:hover [data-button-animate-chars] span {
transform: translateY(0vw) rotate(0);
}
.stagger-button:hover .stagger-button-bg {
transform: scale(1);
}
}

View File

@@ -0,0 +1,53 @@
"use client";
import { memo } from "react";
import { useButtonClick } from "./useButtonClick";
import { cls } from "@/lib/utils";
interface ButtonTextUnderlineProps {
text: string;
onClick?: () => void;
href?: string;
className?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
scrollToSection?: boolean;
}
const ButtonTextUnderline = ({
text,
onClick,
href,
className = "",
disabled = false,
ariaLabel,
type = "button",
scrollToSection,
}: ButtonTextUnderlineProps) => {
const handleClick = useButtonClick(href, onClick, scrollToSection);
return (
<button
type={type}
onClick={handleClick}
disabled={disabled}
data-href={href}
aria-label={ariaLabel || text}
className={cls(
"relative text-sm inline-block bg-transparent border-none p-0 cursor-pointer",
"after:content-[''] after:absolute after:bottom-0 after:left-0 after:w-full after:h-[1px]",
"after:bg-current after:scale-x-0 after:origin-right after:transition-transform after:duration-300",
"hover:after:scale-x-100 hover:after:origin-left",
"disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:after:scale-x-0",
className
)}
>
{text}
</button>
);
};
ButtonTextUnderline.displayName = "ButtonTextUnderline";
export default memo(ButtonTextUnderline);

View File

@@ -0,0 +1,125 @@
"use client";
import { useRef, useEffect, memo, ReactNode } from "react";
import { cls } from "@/lib/utils";
export interface SelectorOption {
value: string;
label: ReactNode;
disabled?: boolean;
labelClassName?: string;
}
export interface SelectorButtonProps {
options: SelectorOption[];
activeValue: string;
onValueChange: (value: string) => void;
className?: string;
buttonClassName?: string;
wrapperClassName?: string;
labelClassName?: string;
}
const SelectorButton = memo<SelectorButtonProps>(({
options,
activeValue,
onValueChange,
className = "",
buttonClassName = "",
wrapperClassName = "",
labelClassName = "",
}) => {
const hoverRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = containerRef.current;
const hoverElement = hoverRef.current;
if (!container || !hoverElement) return;
const moveHoverBlock = (target: HTMLElement) => {
if (!target) return;
const targetRect = target.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
hoverElement.style.width = `${targetRect.width}px`;
hoverElement.style.transform = `translateX(${targetRect.left - containerRect.left}px)`;
};
const updatePosition = () => {
const activeButton = container.querySelector(
`[data-value="${activeValue}"]`
) as HTMLElement;
if (activeButton) moveHoverBlock(activeButton);
};
updatePosition();
const resizeObserver = new ResizeObserver(updatePosition);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [activeValue]);
return (
<div className={cls("relative w-fit p-1 card rounded-theme-capped", wrapperClassName)}>
<div
ref={containerRef}
className={cls("relative overflow-hidden cursor-pointer flex", className)}
>
{options.map((option) => (
<button
key={option.value}
data-value={option.value}
disabled={option.disabled}
onClick={() => !option.disabled && onValueChange(option.value)}
className={cls(
"relative px-4 py-2 text-sm md:text-base rounded-theme transition-all duration-300 ease-in-out z-1 text-nowrap",
option.disabled ? "opacity-50" : "cursor-pointer",
activeValue === option.value ? "" : "bg-transparent",
buttonClassName
)}
>
{typeof option.label === "string" ? (
<span
className={cls(
"transition-colors duration-300 ease-in-out",
activeValue === option.value ? "text-primary-cta-text" : "text-foreground",
option.disabled ? "" : "cursor-pointer",
option.labelClassName || labelClassName
)}
>
{option.label}
</span>
) : (
<div
className={cls(
"flex items-center justify-center transition-opacity duration-300",
activeValue === option.value ? "opacity-100" : "opacity-50",
option.disabled ? "" : "cursor-pointer",
option.labelClassName || labelClassName
)}
>
{option.label}
</div>
)}
</button>
))}
<div
ref={hoverRef}
className="absolute top-0 left-0 h-full rounded-theme overflow-hidden pointer-events-none z-0 transition-all duration-400 ease-out"
>
<div className="relative primary-button w-full h-full rounded-theme" />
</div>
</div>
</div>
);
});
SelectorButton.displayName = "SelectorButton";
export default SelectorButton;

View File

@@ -0,0 +1,91 @@
export type ButtonVariant =
| "hover-magnetic"
| "hover-bubble"
| "expand-hover"
| "elastic-effect"
| "bounce-effect"
| "icon-arrow"
| "shift-hover"
| "text-stagger"
| "text-shift"
| "text-underline"
| "directional-hover";
export type CTAButtonVariant = Exclude<ButtonVariant, "text-underline">;
export type ButtonWithBgClassName = "text-stagger" | "text-shift" | "shift-hover" | "bounce-effect" | "directional-hover";
export const hasBgClassName = (variant?: string): variant is ButtonWithBgClassName => {
return variant === "text-stagger" || variant === "text-shift" || variant === "shift-hover" || variant === "bounce-effect" || variant === "directional-hover";
};
export type BaseButtonProps = {
text: string;
onClick?: () => void;
href?: string;
className?: string;
scrollToSection?: boolean;
type?: "button" | "submit" | "reset";
};
export type ButtonVariantProps =
| ({
variant?: "hover-magnetic";
textClassName?: string;
strengthFactor?: number;
} & BaseButtonProps)
| ({
variant: "hover-bubble";
bgClassName?: string;
textClassName?: string;
iconClassName?: string;
} & BaseButtonProps)
| ({
variant: "expand-hover";
textClassName?: string;
iconClassName?: string;
iconBgClassName?: string;
} & BaseButtonProps)
| ({
variant: "elastic-effect";
textClassName?: string;
} & BaseButtonProps)
| ({
variant: "bounce-effect";
bgClassName?: string;
textClassName?: string;
} & BaseButtonProps)
| ({
variant: "icon-arrow";
textClassName?: string;
iconClassName?: string;
} & BaseButtonProps)
| ({
variant: "shift-hover";
bgClassName?: string;
textClassName?: string;
} & BaseButtonProps)
| ({
variant: "text-stagger";
bgClassName?: string;
} & BaseButtonProps)
| ({
variant: "text-shift";
bgClassName?: string;
textClassName?: string;
} & BaseButtonProps)
| ({
variant: "text-underline";
disabled?: boolean;
} & BaseButtonProps)
| ({
variant: "directional-hover";
bgClassName?: string;
textClassName?: string;
circleClassName?: string;
} & BaseButtonProps);
export type ButtonPropsForVariant<V extends ButtonVariant> = Extract<
ButtonVariantProps,
{ variant?: V }
>;

View File

@@ -0,0 +1,74 @@
"use client";
import { useLenis } from "lenis/react";
import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
export const useButtonClick = (
href?: string,
onClick?: () => void,
scrollToSection?: boolean
) => {
const lenis = useLenis();
const router = useRouter();
const pathname = usePathname();
const scrollToElement = (sectionId: string, delay: number = 100) => {
setTimeout(() => {
if (lenis) {
lenis.scrollTo(`#${sectionId}`, { offset: 0 });
} else {
const element = document.getElementById(sectionId);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
}, delay);
};
const handleClick = () => {
if (href) {
const isExternalLink = /^(https?:\/\/|www\.)/.test(href);
if (isExternalLink) {
window.open(
href.startsWith("www.") ? `https://${href}` : href,
"_blank",
"noopener,noreferrer"
);
} else if (href.startsWith("/")) {
const [path, hash] = href.split("#");
if (path !== pathname) {
router.push(path);
if (hash) {
setTimeout(() => {
window.location.hash = hash;
scrollToElement(hash, 100);
}, 100);
}
} else {
if (hash) {
window.location.hash = hash;
scrollToElement(hash, 50);
} else if (scrollToSection) {
const sectionId = path.replace(/^\//, "").replace(/\//g, "-");
scrollToElement(sectionId, 50);
}
}
} else {
scrollToElement(href, 50);
}
}
onClick?.();
};
useEffect(() => {
if (typeof window !== "undefined" && window.location.hash) {
const hash = window.location.hash.replace("#", "");
scrollToElement(hash, 300);
}
}, [pathname]);
return handleClick;
};

View File

@@ -0,0 +1,31 @@
import { useEffect, RefObject } from "react";
export const useCharAnimation = (
buttonRef: RefObject<HTMLButtonElement | null>,
text: string | undefined,
selector: string = "[data-button-animate-chars]",
staggerDelay: number = 0
) => {
useEffect(() => {
const buttonElement = buttonRef.current?.querySelector(selector);
if (!buttonElement) return;
const textContent = text || buttonElement.textContent || "";
buttonElement.innerHTML = "";
[...textContent].forEach((char, index) => {
const span = document.createElement("span");
span.textContent = char;
if (staggerDelay > 0) {
span.style.transitionDelay = `${index * staggerDelay}s`;
}
if (char === " ") {
span.style.whiteSpace = "pre";
}
buttonElement.appendChild(span);
});
}, [buttonRef, text, selector, staggerDelay]);
};

View File

@@ -0,0 +1,123 @@
"use client";
import { memo, Children } from "react";
import CardStackTextBox from "@/components/cardStack/CardStackTextBox";
import { useCardAnimation } from "@/components/cardStack/hooks/useCardAnimation";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, ButtonAnimationType, CardAnimationType, TitleSegment } from "@/components/cardStack/types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
interface CardListProps {
children: React.ReactNode;
animationType: CardAnimationType;
useUncappedRounding?: boolean;
title?: string;
titleSegments?: TitleSegment[];
description?: string;
tag?: string;
tagIcon?: LucideIcon;
tagAnimation?: ButtonAnimationType;
buttons?: ButtonConfig[];
buttonAnimation?: ButtonAnimationType;
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
disableCardWrapper?: boolean;
ariaLabel?: string;
className?: string;
containerClassName?: string;
cardClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const CardList = ({
children,
animationType,
useUncappedRounding = false,
title,
titleSegments,
description,
tag,
tagIcon,
tagAnimation,
buttons,
buttonAnimation,
textboxLayout,
useInvertedBackground,
disableCardWrapper = false,
ariaLabel = "Card list",
className = "",
containerClassName = "",
cardClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: CardListProps) => {
const childrenArray = Children.toArray(children);
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length, useIndividualTriggers: true });
return (
<section
aria-label={ariaLabel}
className={cls(
"relative py-20 w-full",
useInvertedBackground && "bg-foreground",
className
)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-8", containerClassName)}>
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
<div className="flex flex-col gap-6">
{childrenArray.map((child, index) => (
<div
key={index}
ref={(el) => { itemRefs.current[index] = el; }}
className={cls(!disableCardWrapper && "card", !disableCardWrapper && (useUncappedRounding ? "rounded-theme" : "rounded-theme-capped"), cardClassName)}
>
{child}
</div>
))}
</div>
</div>
</section>
);
};
CardList.displayName = "CardList";
export default memo(CardList);

View File

@@ -0,0 +1,229 @@
"use client";
import { memo, Children } from "react";
import { CardStackProps } from "./types";
import GridLayout from "./layouts/grid/GridLayout";
import AutoCarousel from "./layouts/carousels/AutoCarousel";
import ButtonCarousel from "./layouts/carousels/ButtonCarousel";
import TimelineBase from "./layouts/timelines/TimelineBase";
import { gridConfigs } from "./layouts/grid/gridConfigs";
const CardStack = ({
children,
mode = "buttons",
gridVariant = "uniform-all-items-equal",
uniformGridCustomHeightClasses,
gridRowsClassName,
itemHeightClassesOverride,
animationType,
supports3DAnimation = false,
title,
titleSegments,
description,
tag,
tagIcon,
tagAnimation,
buttons,
buttonAnimation,
textboxLayout = "default",
useInvertedBackground,
carouselThreshold = 5,
bottomContent,
className = "",
containerClassName = "",
gridClassName = "",
carouselClassName = "",
carouselItemClassName = "",
controlsClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Card stack",
}: CardStackProps) => {
const childrenArray = Children.toArray(children);
const itemCount = childrenArray.length;
// Check if the current grid config has gridRows defined
const gridConfig = gridConfigs[gridVariant]?.[itemCount];
const hasFixedGridRows = gridConfig && 'gridRows' in gridConfig && gridConfig.gridRows;
// If grid has fixed row heights and we have uniformGridCustomHeightClasses,
// we need to use min-h-0 on md+ to prevent conflicts
let adjustedHeightClasses = uniformGridCustomHeightClasses;
if (hasFixedGridRows && uniformGridCustomHeightClasses) {
// Extract the mobile min-height and add md:min-h-0
const mobileMinHeight = uniformGridCustomHeightClasses.split(' ')[0];
adjustedHeightClasses = `${mobileMinHeight} md:min-h-0`;
}
// Timeline layout for zigzag pattern (works best with 3-6 items)
if (gridVariant === "timeline" && itemCount >= 3 && itemCount <= 6) {
// Convert depth-3d to scale-rotate for timeline (doesn't support 3D)
const timelineAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
return (
<TimelineBase
variant={gridVariant}
uniformGridCustomHeightClasses={adjustedHeightClasses}
animationType={timelineAnimationType}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
className={className}
containerClassName={containerClassName}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
ariaLabel={ariaLabel}
>
{childrenArray}
</TimelineBase>
);
}
// Use grid for items below threshold, carousel for items at or above threshold
// Timeline with 7+ items will also use carousel
const useCarousel = itemCount >= carouselThreshold || (gridVariant === "timeline" && itemCount > 6);
// Grid layout for 1-4 items
if (!useCarousel) {
return (
<GridLayout
itemCount={itemCount}
gridVariant={gridVariant}
uniformGridCustomHeightClasses={adjustedHeightClasses}
gridRowsClassName={gridRowsClassName}
itemHeightClassesOverride={itemHeightClassesOverride}
animationType={animationType}
supports3DAnimation={supports3DAnimation}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
bottomContent={bottomContent}
className={className}
containerClassName={containerClassName}
gridClassName={gridClassName}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
ariaLabel={ariaLabel}
>
{childrenArray}
</GridLayout>
);
}
// Auto-scroll carousel for 5+ items
if (mode === "auto") {
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
return (
<AutoCarousel
uniformGridCustomHeightClasses={adjustedHeightClasses}
animationType={carouselAnimationType}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
bottomContent={bottomContent}
className={className}
containerClassName={containerClassName}
carouselClassName={carouselClassName}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
ariaLabel={ariaLabel}
>
{childrenArray}
</AutoCarousel>
);
}
// Button-controlled carousel for 5+ items
// Convert depth-3d to scale-rotate for carousel (doesn't support 3D)
const carouselAnimationType = animationType === "depth-3d" ? "scale-rotate" : animationType;
return (
<ButtonCarousel
uniformGridCustomHeightClasses={adjustedHeightClasses}
animationType={carouselAnimationType}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
bottomContent={bottomContent}
className={className}
containerClassName={containerClassName}
carouselClassName={carouselClassName}
carouselItemClassName={carouselItemClassName}
controlsClassName={controlsClassName}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
ariaLabel={ariaLabel}
>
{childrenArray}
</ButtonCarousel>
);
};
CardStack.displayName = "CardStack";
export default memo(CardStack);

View File

@@ -0,0 +1,92 @@
"use client";
import { memo, useMemo } from "react";
import TextBox from "@/components/Textbox";
import { cls } from "@/lib/utils";
import type { TextBoxProps } from "./types";
const CardStackTextBox = ({
title,
titleSegments,
description,
tag,
tagIcon,
tagAnimation,
buttons,
buttonAnimation,
textboxLayout,
useInvertedBackground,
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: TextBoxProps) => {
const styles = useMemo(() => {
if (textboxLayout === "default") {
return {
className: cls("flex flex-col gap-3 md:gap-2", textBoxClassName),
titleClassName: cls("text-6xl font-medium text-center", titleClassName),
descriptionClassName: cls("text-lg leading-tight text-center md:max-w-6/10", descriptionClassName),
tagClassName: cls("w-fit px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2 mb-0 mx-auto", tagClassName),
buttonContainerClassName: cls("flex flex-wrap gap-4 max-md:justify-center mt-1 md:mt-3 justify-center", buttonContainerClassName),
center: true,
};
}
if (textboxLayout === "inline-image") {
return {
className: cls("flex flex-col gap-3 md:gap-2", textBoxClassName),
titleClassName: cls("text-4xl md:text-5xl font-medium text-center", titleClassName),
descriptionClassName: cls("text-lg leading-tight text-center", descriptionClassName),
tagClassName: cls("w-fit px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2 mb-0 mx-auto", tagClassName),
buttonContainerClassName: cls("flex flex-wrap gap-4 max-md:justify-center mt-1 md:mt-3 justify-center", buttonContainerClassName),
center: true,
};
}
return {
className: textBoxClassName,
titleClassName: cls("text-6xl font-medium", titleClassName),
descriptionClassName: cls("text-lg leading-tight", descriptionClassName),
tagClassName: cls("px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2", tagClassName),
buttonContainerClassName: cls("flex flex-wrap gap-4 max-md:justify-center", buttonContainerClassName),
center: false,
};
}, [textboxLayout, textBoxClassName, titleClassName, descriptionClassName, tagClassName, buttonContainerClassName]);
if (!title && !titleSegments && !description) return null;
return (
<TextBox
title={title!}
titleSegments={titleSegments}
description={description!}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
className={styles.className}
titleClassName={styles.titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={styles.descriptionClassName}
tagClassName={styles.tagClassName}
buttonContainerClassName={styles.buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
center={styles.center}
/>
);
};
CardStackTextBox.displayName = "CardStackTextBox";
export default memo(CardStackTextBox);

View File

@@ -0,0 +1,187 @@
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { CardAnimationType, GridVariant } from "../types";
import { useDepth3DAnimation } from "./useDepth3DAnimation";
gsap.registerPlugin(ScrollTrigger);
interface UseCardAnimationProps {
animationType: CardAnimationType | "depth-3d";
itemCount: number;
isGrid?: boolean;
supports3DAnimation?: boolean;
gridVariant?: GridVariant;
useIndividualTriggers?: boolean;
}
export const useCardAnimation = ({
animationType,
itemCount,
isGrid = true,
supports3DAnimation = false,
gridVariant,
useIndividualTriggers = false
}: UseCardAnimationProps) => {
const itemRefs = useRef<(HTMLElement | null)[]>([]);
const containerRef = useRef<HTMLDivElement | null>(null);
const perspectiveRef = useRef<HTMLDivElement | null>(null);
const bottomContentRef = useRef<HTMLDivElement | null>(null);
// Enable 3D effect only when explicitly supported and conditions are met
const { isMobile } = useDepth3DAnimation({
itemRefs,
containerRef,
perspectiveRef,
isEnabled: animationType === "depth-3d" && isGrid && supports3DAnimation && gridVariant === "uniform-all-items-equal",
});
// Use scale-rotate as fallback when depth-3d conditions aren't met
const effectiveAnimationType =
animationType === "depth-3d" && (isMobile || !isGrid || gridVariant !== "uniform-all-items-equal")
? "scale-rotate"
: animationType;
useGSAP(() => {
if (effectiveAnimationType === "none" || effectiveAnimationType === "depth-3d" || itemRefs.current.length === 0) return;
const items = itemRefs.current.filter((el) => el !== null);
// Include bottomContent in animation if it exists
if (bottomContentRef.current) {
items.push(bottomContentRef.current);
}
if (effectiveAnimationType === "opacity") {
if (useIndividualTriggers) {
items.forEach((item) => {
gsap.fromTo(
item,
{ opacity: 0 },
{
opacity: 1,
duration: 1.25,
ease: "sine",
scrollTrigger: {
trigger: item,
start: "top 80%",
toggleActions: "play none none none",
},
}
);
});
} else {
gsap.fromTo(
items,
{ opacity: 0 },
{
opacity: 1,
duration: 1.25,
stagger: 0.15,
ease: "sine",
scrollTrigger: {
trigger: items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
}
} else if (effectiveAnimationType === "slide-up") {
items.forEach((item, index) => {
gsap.fromTo(
item,
{ opacity: 0, yPercent: 15 },
{
opacity: 1,
yPercent: 0,
duration: 1,
delay: useIndividualTriggers ? 0 : index * 0.15,
ease: "sine",
scrollTrigger: {
trigger: useIndividualTriggers ? item : items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
});
} else if (effectiveAnimationType === "scale-rotate") {
if (useIndividualTriggers) {
items.forEach((item) => {
gsap.fromTo(
item,
{ scaleX: 0, rotate: 10 },
{
scaleX: 1,
rotate: 0,
duration: 1,
ease: "power3",
scrollTrigger: {
trigger: item,
start: "top 80%",
toggleActions: "play none none none",
},
}
);
});
} else {
gsap.fromTo(
items,
{ scaleX: 0, rotate: 10 },
{
scaleX: 1,
rotate: 0,
duration: 1,
stagger: 0.15,
ease: "power3",
scrollTrigger: {
trigger: items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
}
} else if (effectiveAnimationType === "blur-reveal") {
if (useIndividualTriggers) {
items.forEach((item) => {
gsap.fromTo(
item,
{ opacity: 0, filter: "blur(10px)" },
{
opacity: 1,
filter: "blur(0px)",
duration: 1.2,
ease: "power2.out",
scrollTrigger: {
trigger: item,
start: "top 80%",
toggleActions: "play none none none",
},
}
);
});
} else {
gsap.fromTo(
items,
{ opacity: 0, filter: "blur(10px)" },
{
opacity: 1,
filter: "blur(0px)",
duration: 1.2,
stagger: 0.15,
ease: "power2.out",
scrollTrigger: {
trigger: items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
}
}
}, [effectiveAnimationType, itemCount, useIndividualTriggers]);
return { itemRefs, containerRef, perspectiveRef, bottomContentRef };
};

View File

@@ -0,0 +1,118 @@
import { useEffect, useState, useRef, RefObject } from "react";
const MOBILE_BREAKPOINT = 768;
const ANIMATION_SPEED = 0.05;
const ROTATION_SPEED = 0.1;
const MOUSE_MULTIPLIER = 0.5;
const ROTATION_MULTIPLIER = 0.25;
interface UseDepth3DAnimationProps {
itemRefs: RefObject<(HTMLElement | null)[]>;
containerRef: RefObject<HTMLDivElement | null>;
perspectiveRef?: RefObject<HTMLDivElement | null>;
isEnabled: boolean;
}
export const useDepth3DAnimation = ({
itemRefs,
containerRef,
perspectiveRef,
isEnabled,
}: UseDepth3DAnimationProps) => {
const [isMobile, setIsMobile] = useState(false);
// Detect mobile viewport
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
checkMobile();
window.addEventListener("resize", checkMobile);
return () => {
window.removeEventListener("resize", checkMobile);
};
}, []);
// 3D mouse-tracking effect (desktop only)
useEffect(() => {
if (!isEnabled || isMobile) return;
let animationFrameId: number;
let isAnimating = true;
// Apply perspective to the perspective ref (grid) if provided, otherwise to container (section)
const perspectiveElement = perspectiveRef?.current || containerRef.current;
if (perspectiveElement) {
perspectiveElement.style.perspective = "1200px";
perspectiveElement.style.transformStyle = "preserve-3d";
}
let mouseX = 0;
let mouseY = 0;
let isMouseInSection = false;
let currentX = 0;
let currentY = 0;
let currentRotationX = 0;
let currentRotationY = 0;
const handleMouseMove = (event: MouseEvent): void => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
isMouseInSection =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
}
if (isMouseInSection) {
mouseX = (event.clientX / window.innerWidth) * 100 - 50;
mouseY = (event.clientY / window.innerHeight) * 100 - 50;
}
};
const animate = (): void => {
if (!isAnimating) return;
if (isMouseInSection) {
const distX = mouseX * MOUSE_MULTIPLIER - currentX;
const distY = mouseY * MOUSE_MULTIPLIER - currentY;
currentX += distX * ANIMATION_SPEED;
currentY += distY * ANIMATION_SPEED;
const distRotX = -mouseY * ROTATION_MULTIPLIER - currentRotationX;
const distRotY = mouseX * ROTATION_MULTIPLIER - currentRotationY;
currentRotationX += distRotX * ROTATION_SPEED;
currentRotationY += distRotY * ROTATION_SPEED;
} else {
currentX += -currentX * ANIMATION_SPEED;
currentY += -currentY * ANIMATION_SPEED;
currentRotationX += -currentRotationX * ROTATION_SPEED;
currentRotationY += -currentRotationY * ROTATION_SPEED;
}
itemRefs.current?.forEach((ref) => {
if (!ref) return;
ref.style.transform = `translate(${currentX}px, ${currentY}px) rotateX(${currentRotationX}deg) rotateY(${currentRotationY}deg)`;
});
animationFrameId = requestAnimationFrame(animate);
};
animate();
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
isAnimating = false;
};
}, [isEnabled, isMobile, itemRefs, containerRef]);
return { isMobile };
};

View File

@@ -0,0 +1,108 @@
import { useLayoutEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
export interface TimelinePhoneViewItem {
trigger: string;
content: React.ReactNode;
imageOne?: string;
videoOne?: string;
imageAltOne?: string;
videoAriaLabelOne?: string;
imageTwo?: string;
videoTwo?: string;
imageAltTwo?: string;
videoAriaLabelTwo?: string;
}
const getImageAnimationConfig = (itemIndex: number, imageIndex: number) => {
const isFirstImage = imageIndex === 0;
const isOddItem = itemIndex % 2 === 1;
if (isFirstImage) {
return {
from: { xPercent: -200, rotation: -45 },
to: { rotation: isOddItem ? 10 : -10 },
};
} else {
return {
from: { xPercent: 200, rotation: 45 },
to: { rotation: isOddItem ? -10 : 10 },
};
}
};
export const usePhoneAnimations = (items: TimelinePhoneViewItem[]) => {
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
const mobileImageRefs = useRef<(HTMLDivElement | null)[]>([]);
useLayoutEffect(() => {
const mm = gsap.matchMedia();
const animatePhones = (isMobile: boolean) => {
items.forEach((item, itemIndex) => {
const images = [item.imageOne || item.videoOne, item.imageTwo || item.videoTwo];
images.forEach((_, imageIndex) => {
const refIndex = itemIndex * 2 + imageIndex;
const element = isMobile
? mobileImageRefs.current[refIndex]
: imageRefs.current[refIndex];
if (element) {
const isFirstImage = imageIndex === 0;
const fromConfig = isMobile
? {
xPercent: isFirstImage ? -150 : 150,
rotation: isFirstImage ? -25 : 25,
}
: getImageAnimationConfig(itemIndex, imageIndex).from;
const toConfig = isMobile
? {
xPercent: 0,
rotation: 0,
duration: 1,
scrollTrigger: {
trigger: element,
start: "top 90%",
end: "top 50%",
scrub: 1,
},
}
: {
xPercent: 0,
rotation: getImageAnimationConfig(itemIndex, imageIndex).to
.rotation,
scrollTrigger: {
trigger: `.${item.trigger}`,
start: "top bottom",
end: "top top",
scrub: 1,
},
};
gsap.fromTo(element, fromConfig, toConfig);
}
});
});
};
mm.add("(max-width: 767px)", () => animatePhones(true));
mm.add("(min-width: 768px)", () => animatePhones(false));
return () => {
mm.revert();
imageRefs.current = [];
mobileImageRefs.current = [];
};
}, [items]);
return {
imageRefs,
mobileImageRefs,
};
};

View File

@@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from "react";
import { EmblaCarouselType } from "embla-carousel";
export const usePrevNextButtons = (emblaApi: EmblaCarouselType | undefined) => {
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);
const [nextBtnDisabled, setNextBtnDisabled] = useState(true);
const onPrevButtonClick = useCallback(() => {
if (!emblaApi) return;
emblaApi.scrollPrev();
}, [emblaApi]);
const onNextButtonClick = useCallback(() => {
if (!emblaApi) return;
emblaApi.scrollNext();
}, [emblaApi]);
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
setPrevBtnDisabled(!emblaApi.canScrollPrev());
setNextBtnDisabled(!emblaApi.canScrollNext());
}, []);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
emblaApi.on("reInit", onSelect).on("select", onSelect);
return () => {
emblaApi.off("reInit", onSelect).off("select", onSelect);
};
}, [emblaApi, onSelect]);
return {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
};
};

View File

@@ -0,0 +1,30 @@
import { useCallback, useEffect, useState } from "react";
import { EmblaCarouselType } from "embla-carousel";
export const useScrollProgress = (emblaApi: EmblaCarouselType | undefined) => {
const [scrollProgress, setScrollProgress] = useState(0);
const onScroll = useCallback((emblaApi: EmblaCarouselType) => {
const progress = Math.max(0, Math.min(1, emblaApi.scrollProgress()));
setScrollProgress(progress * 100);
}, []);
useEffect(() => {
if (!emblaApi) return;
onScroll(emblaApi);
emblaApi
.on("reInit", onScroll)
.on("scroll", onScroll)
.on("slideFocus", onScroll);
return () => {
emblaApi
.off("reInit", onScroll)
.off("scroll", onScroll)
.off("slideFocus", onScroll);
};
}, [emblaApi, onScroll]);
return scrollProgress;
};

View File

@@ -0,0 +1,243 @@
import { useState, useEffect, useRef, useCallback } from "react";
const ANIMATION_CONFIG = {
PROGRESS_DURATION: 5000,
TRANSITION_DURATION: 500,
ANIMATION_START_DELAY: 100,
IMAGE_TRANSITION_DELAY: 300,
} as const;
export interface MediaItem {
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
}
interface UseTimelineHorizontalProps {
itemCount: number;
mediaItems?: MediaItem[];
}
export const useTimelineHorizontal = ({ itemCount, mediaItems }: UseTimelineHorizontalProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [imageOpacity, setImageOpacity] = useState(1);
const [currentMediaSrc, setCurrentMediaSrc] = useState<{ imageSrc?: string; videoSrc?: string }>(() => {
if (mediaItems && mediaItems[0]) {
return {
imageSrc: mediaItems[0].imageSrc,
videoSrc: mediaItems[0].videoSrc,
};
}
return {};
});
const progressRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationFrameRef = useRef<number | null>(null);
const imageTransitionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isMountedRef = useRef(false);
const hasInitializedRef = useRef(false);
const resetAllProgressBars = useCallback(() => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
progressRefs.current.forEach((bar) => {
if (bar) {
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
bar.style.transform = "scaleX(0)";
setTimeout(() => {
if (bar) {
bar.style.transition = "none";
}
}, ANIMATION_CONFIG.TRANSITION_DURATION);
}
});
}, []);
const animateProgress = useCallback(
(index: number) => {
if (!progressRefs.current[index]) return;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
const progressBar = progressRefs.current[index];
progressBar.style.transition = "none";
progressBar.style.transform = "scaleX(0)";
const easeInOut = (t: number): number => {
return -(Math.cos(Math.PI * t) - 1) / 2;
};
setTimeout(() => {
let startTime: number | null = null;
const animate = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const linearProgress = Math.min(elapsed / ANIMATION_CONFIG.PROGRESS_DURATION, 1);
const easedProgress = easeInOut(linearProgress);
if (progressRefs.current[index]) {
progressRefs.current[index]!.style.transform = `scaleX(${easedProgress})`;
}
if (linearProgress < 1) {
animationFrameRef.current = requestAnimationFrame(animate);
} else {
setActiveIndex((prevIndex) => {
const nextIndex = prevIndex + 1;
if (nextIndex >= itemCount) {
resetAllProgressBars();
return 0;
}
return nextIndex;
});
}
};
animationFrameRef.current = requestAnimationFrame(animate);
}, ANIMATION_CONFIG.ANIMATION_START_DELAY);
},
[itemCount, resetAllProgressBars]
);
useEffect(() => {
for (let i = 0; i < activeIndex; i++) {
const bar = progressRefs.current[i];
if (bar) {
bar.style.transform = "scaleX(1)";
}
}
if (isMountedRef.current) {
animateProgress(activeIndex);
}
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}, [activeIndex, animateProgress]);
useEffect(() => {
isMountedRef.current = true;
if (!hasInitializedRef.current) {
hasInitializedRef.current = true;
if (mediaItems && mediaItems[0]) {
setCurrentMediaSrc({
imageSrc: mediaItems[0].imageSrc,
videoSrc: mediaItems[0].videoSrc,
});
setImageOpacity(1);
}
setTimeout(() => {
if (isMountedRef.current) {
animateProgress(0);
}
}, ANIMATION_CONFIG.ANIMATION_START_DELAY);
}
return () => {
isMountedRef.current = false;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (imageTransitionTimeoutRef.current) {
clearTimeout(imageTransitionTimeoutRef.current);
imageTransitionTimeoutRef.current = null;
}
};
}, [animateProgress, mediaItems]);
useEffect(() => {
if (!isMountedRef.current || !mediaItems) return;
const currentItem = mediaItems[activeIndex];
if (!currentItem) return;
const newMediaSrc = {
imageSrc: currentItem.imageSrc,
videoSrc: currentItem.videoSrc,
};
if (
(newMediaSrc.imageSrc && newMediaSrc.imageSrc !== currentMediaSrc.imageSrc) ||
(newMediaSrc.videoSrc && newMediaSrc.videoSrc !== currentMediaSrc.videoSrc)
) {
if (imageTransitionTimeoutRef.current) {
clearTimeout(imageTransitionTimeoutRef.current);
}
setImageOpacity(0);
imageTransitionTimeoutRef.current = setTimeout(() => {
if (isMountedRef.current) {
setCurrentMediaSrc(newMediaSrc);
setTimeout(() => {
if (isMountedRef.current) {
setImageOpacity(1);
}
}, 50);
}
}, ANIMATION_CONFIG.IMAGE_TRANSITION_DELAY);
}
return () => {
if (imageTransitionTimeoutRef.current) {
clearTimeout(imageTransitionTimeoutRef.current);
}
};
}, [activeIndex, mediaItems, currentMediaSrc]);
const handleImageLoad = useCallback(() => {
setImageOpacity(1);
}, []);
const handleItemClick = useCallback(
(index: number) => {
if (index === activeIndex) return;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
for (let i = 0; i < index; i++) {
const bar = progressRefs.current[i];
if (bar) {
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
bar.style.transform = "scaleX(1)";
}
}
for (let i = index; i < progressRefs.current.length; i++) {
const bar = progressRefs.current[i];
if (bar) {
bar.style.transition = `transform ${ANIMATION_CONFIG.TRANSITION_DURATION}ms ease-in-out`;
bar.style.transform = "scaleX(0)";
}
}
setActiveIndex(index);
},
[activeIndex]
);
return {
activeIndex,
progressRefs,
handleItemClick,
imageOpacity,
currentMediaSrc,
handleImageLoad,
};
};

View File

@@ -0,0 +1,144 @@
"use client";
import { memo, useState, useEffect, useRef } from "react";
import { motion } from "framer-motion";
import MediaContent from "@/components/shared/MediaContent";
import { cls } from "@/lib/utils";
interface AngledCarouselItem {
id: string;
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
}
interface AngledCarouselProps {
items: AngledCarouselItem[];
className?: string;
autoPlay?: boolean;
autoPlayInterval?: number;
}
const CARD_TRANSITION_DURATION = 0.8;
const CARD_TRANSITION_EASE = [0.65, 0, 0.35, 1] as const;
const cardVariants = {
'hidden-0': { opacity: 0, y: '25px' },
'hidden-1': { scale: 0.88, opacity: 0, x: 'calc(100% + 20px)', y: '5%', rotate: 2 },
'hidden--1': { scale: 0.88, opacity: 0, x: 'calc(-100% - 20px)', y: '5%', rotate: -2 },
'0': { scale: 1, opacity: 1, x: '0%', y: '0%', rotate: 0 },
'1': { scale: 0.88, opacity: 1, x: '100%', y: '5%', rotate: 2 },
'-1': { scale: 0.88, opacity: 1, x: '-100%', y: '5%', rotate: -2 },
'2': { scale: 0.8, opacity: 0, x: '200%', y: '10%', rotate: 4 },
'-2': { scale: 0.8, opacity: 0, x: '-200%', y: '10%', rotate: -4 },
};
const AngledCarousel = ({ items, className = "", autoPlay = true, autoPlayInterval = 4000 }: AngledCarouselProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [isFirstRender, setIsFirstRender] = useState(true);
const autoPlayRef = useRef<NodeJS.Timeout | null>(null);
const n = items.length;
useEffect(() => {
if (isFirstRender) {
const timeout = setTimeout(() => {
setIsFirstRender(false);
}, CARD_TRANSITION_DURATION * 1000);
return () => clearTimeout(timeout);
}
}, [isFirstRender]);
const resetAutoPlay = () => {
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
}
if (autoPlay) {
autoPlayRef.current = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % n);
}, autoPlayInterval);
}
};
useEffect(() => {
resetAutoPlay();
return () => {
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
}
};
}, [autoPlay, autoPlayInterval]);
const positionFactors = [-2, -1, 0, 1, 2];
return (
<div className={cls("relative w-full flex justify-center items-center overflow-hidden", className)}>
<div className="w-[70%] md:w-[40%] aspect-square md:aspect-[16/10] opacity-0" />
{positionFactors.map((positionFactor) => {
const itemIndex = (activeIndex + positionFactor + n) % n;
const item = items[itemIndex];
const isCenter = positionFactor === 0;
const isVisible = Math.abs(positionFactor) <= 1;
const getAnimateState = () => {
const key = positionFactor.toString() as keyof typeof cardVariants;
return cardVariants[key];
};
const getInitialState = () => {
if (isVisible && isFirstRender) {
const key = `hidden-${positionFactor}` as keyof typeof cardVariants;
return cardVariants[key];
}
return getAnimateState();
};
const getDelay = () => {
if (isVisible && isFirstRender) {
const delays: { [key: string]: number } = { '-1': 0.6, '0': 0.45, '1': 0.6 };
return delays[positionFactor.toString()] || 0;
}
return 0;
};
return (
<motion.div
key={item.id}
className="!absolute w-[70%] md:w-[40%] aspect-square md:aspect-[16/10] card p-1 rounded-theme-capped overflow-hidden"
style={{
zIndex: positionFactor === 0 ? 10 : 5 - Math.abs(positionFactor),
}}
initial={getInitialState()}
animate={getAnimateState()}
transition={{
duration: CARD_TRANSITION_DURATION,
ease: CARD_TRANSITION_EASE,
delay: getDelay(),
}}
>
<MediaContent
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
imageAlt={item.imageAlt}
videoAriaLabel={item.videoAriaLabel}
imageClassName="w-full h-full rounded-theme-capped object-cover"
/>
<motion.div
className="absolute inset-0 bg-background/50 backdrop-blur-[1px] pointer-events-none select-none"
initial={{ opacity: isCenter ? 0 : 1 }}
animate={{ opacity: isCenter ? 0 : 1 }}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
/>
</motion.div>
);
})}
</div>
);
};
AngledCarousel.displayName = "AngledCarousel";
export default memo(AngledCarousel);

View File

@@ -0,0 +1,144 @@
"use client";
import { memo, Children, useCallback, useEffect, useState } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { EmblaCarouselType } from "embla-carousel";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ArrowCarouselProps } from "../../types";
const ArrowCarousel = ({
children,
title,
titleSegments,
description,
tag,
tagIcon,
tagAnimation,
buttons,
buttonAnimation,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
carouselClassName = "",
controlsClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Carousel section",
}: ArrowCarouselProps) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true, align: "center" });
const [selectedIndex, setSelectedIndex] = useState(0);
const childrenArray = Children.toArray(children);
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
setSelectedIndex(emblaApi.selectedScrollSnap());
}, []);
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
emblaApi.on("select", onSelect).on("reInit", onSelect);
return () => {
emblaApi.off("select", onSelect).off("reInit", onSelect);
};
}, [emblaApi, onSelect]);
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-full mx-auto flex flex-col gap-6", containerClassName)}>
<div className="w-content-width mx-auto">
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
</div>
<div className="relative w-full">
<div
className={cls(
"overflow-hidden w-full relative z-10 mask-fade-x",
carouselClassName
)}
ref={emblaRef}
>
<div className="flex w-full">
{childrenArray.map((child, index) => (
<div
key={index}
className="flex-none w-60 md:w-40 mr-6"
>
<div className={cls(
"transition-all duration-500 ease-out",
selectedIndex === index ? "opacity-100 scale-100" : "opacity-70 scale-90"
)}>
{child}
</div>
</div>
))}
</div>
</div>
<div className={cls("absolute inset-y-0 w-content-width mx-auto left-0 right-0 flex items-center justify-between pointer-events-none z-10", controlsClassName)}>
<button
onClick={scrollPrev}
className="pointer-events-auto primary-button h-8 w-auto aspect-square rounded-theme flex items-center justify-center cursor-pointer"
aria-label="Previous slide"
>
<ChevronLeft className="w-4/10 h-4/10 text-primary-cta-text" />
</button>
<button
onClick={scrollNext}
className="pointer-events-auto primary-button h-8 w-auto aspect-square rounded-theme flex items-center justify-center cursor-pointer"
aria-label="Next slide"
>
<ChevronRight className="w-4/10 h-4/10 text-primary-cta-text" />
</button>
</div>
</div>
</div>
</section>
);
};
ArrowCarousel.displayName = "ArrowCarousel";
export default memo(ArrowCarousel);

View File

@@ -0,0 +1,148 @@
"use client";
import { memo, Children } from "react";
import Marquee from "react-fast-marquee";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import { AutoCarouselProps } from "../../types";
import { useCardAnimation } from "../../hooks/useCardAnimation";
const AutoCarousel = ({
children,
uniformGridCustomHeightClasses,
animationType,
speed = 50,
title,
titleSegments,
description,
tag,
tagIcon,
tagAnimation,
buttons,
buttonAnimation,
textboxLayout = "default",
useInvertedBackground,
bottomContent,
className = "",
containerClassName = "",
carouselClassName = "",
itemClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel,
showTextBox = true,
dualMarquee = false,
topMarqueeDirection = "left",
bottomCarouselClassName = "",
marqueeGapClassName = "",
}: AutoCarouselProps) => {
const childrenArray = Children.toArray(children);
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
const { itemRefs, bottomContentRef } = useCardAnimation({
animationType,
itemCount: childrenArray.length,
isGrid: false
});
// Bottom marquee direction is opposite of top
const bottomMarqueeDirection = topMarqueeDirection === "left" ? "right" : "left";
// Reverse order for bottom marquee to avoid alignment with top
const bottomChildren = dualMarquee ? [...childrenArray].reverse() : [];
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground && "bg-foreground",
className
)}
aria-label={ariaLabel}
aria-live="off"
>
<div className={cls("w-full md:w-content-width mx-auto", containerClassName)}>
<div className="w-full flex flex-col items-center">
<div className="w-full flex flex-col gap-6">
{showTextBox && (title || titleSegments || description) && (
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
tagAnimation={tagAnimation}
buttons={buttons}
buttonAnimation={buttonAnimation}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
)}
<div
className={cls(
"w-full flex flex-col",
marqueeGapClassName || "gap-6"
)}
>
{/* Top/Single Marquee */}
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", carouselClassName)}>
<Marquee gradient={false} speed={speed} direction={topMarqueeDirection}>
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
))}
</Marquee>
</div>
{/* Bottom Marquee (only if dualMarquee is true) - Reversed order, opposite direction */}
{dualMarquee && (
<div className={cls("overflow-hidden w-full relative z-10 mask-padding-x", bottomCarouselClassName || carouselClassName)}>
<Marquee gradient={false} speed={speed} direction={bottomMarqueeDirection}>
{Children.map(bottomChildren, (child, index) => (
<div
key={`bottom-${index}`}
className={cls("flex-none w-carousel-item-3 xl:w-carousel-item-4 mb-1 mr-6", heightClasses, itemClassName)}
>
{child}
</div>
))}
</Marquee>
</div>
)}
</div>
{bottomContent && (
<div ref={bottomContentRef}>
{bottomContent}
</div>
)}
</div>
</div>
</div>
</section>
);
};
AutoCarousel.displayName = "AutoCarousel";
export default memo(AutoCarousel);

Some files were not shown because too many files have changed in this diff Show More