Initial commit

This commit is contained in:
Nikolay Pecheniev
2026-01-15 14:40:18 +02:00
commit 593f5cc4b1
263 changed files with 50227 additions and 0 deletions

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

@@ -0,0 +1,105 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarStyleFullscreen from '@/components/navbar/NavbarStyleFullscreen/NavbarStyleFullscreen';
import TextSplitAbout from '@/components/sections/about/TextSplitAbout';
import FeatureCardSeven from '@/components/sections/feature/FeatureCardSeven';
import FooterCard from '@/components/sections/footer/FooterCard';
import { CheckCircle, Instagram, Facebook, Mail } from "lucide-react";
import Link from "next/link";
export default function AboutPage() {
// Navigation logic for about page
const navItems = [
{ name: "Home", id: "/" },
{ name: "Products", id: "/products" },
{ name: "About", id: "about" }, // scroll to about section on current page
{ name: "FAQ", id: "/faq" },
{ name: "Contact", id: "/contact" }
];
return (
<ThemeProvider
defaultButtonVariant="hover-magnetic"
defaultTextAnimation="reveal-blur"
borderRadius="pill"
contentWidth="compact"
sizing="largeSmallSizeLargeTitles"
background="aurora"
cardStyle="gradient-radial"
primaryButtonStyle="primary-glow"
secondaryButtonStyle="radial-glow"
headingFontWeight="normal"
>
<div id="nav" data-section="nav">
<NavbarStyleFullscreen
navItems={navItems}
brandName="Moya Kvitka"
bottomLeftText="Fresh Flowers Daily"
bottomRightText="hello@moyakvitka.com"
/>
</div>
<div id="about" data-section="about">
<TextSplitAbout
title="About Moya Kvitka"
description={[
"For over a decade, Moya Kvitka has been Ukraine's premier destination for exquisite floral arrangements. Our passion for flowers and commitment to excellence drives every creation.",
"We believe that flowers tell stories. Each arrangement is meticulously crafted by our expert florists using only the freshest blooms sourced from trusted growers. Whether it's a romantic gesture, a celebration, or a moment of sympathy, we create arrangements that capture the perfect emotion.",
"Our mission is to make every occasion memorable through the timeless beauty of nature. From custom designs to seasonal collections, we're here to bring your floral dreams to life."
]}
useInvertedBackground="noInvert"
showBorder={true}
buttons={[
{ text: "Explore Our Story", href: "/about" }
]}
/>
</div>
<div id="features" data-section="features">
<FeatureCardSeven
title="Why Choose Moya Kvitka"
description="Experience the difference that premium quality and expert craftsmanship make"
tag="Our Promise"
tagIcon={CheckCircle}
features={[
{
id: 1,
title: "Fresh Flowers Guaranteed",
description: "We source only the freshest blooms daily from trusted growers. Our flowers are hand-selected and arranged the same day to ensure maximum freshness and longevity.",
imageSrc: "https://img.b2bpic.net/free-photo/woman-florist-her-own-floral-shop-taking-care-flowers_1303-15607.jpg",
imageAlt: "Fresh flowers in shop"
},
{
id: 2,
title: "Expert Florist Design",
description: "Our award-winning florists bring decades of experience and artistic vision to every arrangement. Each creation is a masterpiece tailored to your preferences and occasion.",
imageSrc: "https://img.b2bpic.net/free-photo/minimal-floral-arrangement-red-surface_58702-17285.jpg",
imageAlt: "Expert floral design"
},
{
id: 3,
title: "Fast & Reliable Delivery",
description: "Same-day delivery available in select areas. We handle your precious flowers with care, ensuring they arrive in perfect condition, on time, every time.",
imageSrc: "https://img.b2bpic.net/free-photo/young-female-florist-looking-bunch-beautiful-white-flowers_23-2147882071.jpg",
imageAlt: "Flower delivery service"
}
]}
textboxLayout="default"
animationType="blur-reveal"
useInvertedBackground="invertDefault"
/>
</div>
<FooterCard
logoText="Moya Kvitka"
copyrightText="© 2025 Moya Kvitka | Premium Floral Design | All rights reserved"
socialLinks={[
{ icon: Instagram, href: "https://instagram.com/moyakvitka", ariaLabel: "Follow us on Instagram" },
{ icon: Facebook, href: "https://facebook.com/moyakvitka", ariaLabel: "Follow us on Facebook" },
{ icon: Mail, href: "mailto:hello@moyakvitka.com", ariaLabel: "Email us" }
]}
/>
</ThemeProvider>
);
}

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

@@ -0,0 +1,69 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarStyleFullscreen from '@/components/navbar/NavbarStyleFullscreen/NavbarStyleFullscreen';
import ContactSplit from '@/components/sections/contact/ContactSplit';
import FooterCard from '@/components/sections/footer/FooterCard';
import { Flower2, Instagram, Facebook, Mail } from "lucide-react";
import Link from "next/link";
export default function ContactPage() {
// Navigation logic for contact page
const navItems = [
{ name: "Home", id: "/" },
{ name: "Products", id: "/products" },
{ name: "About", id: "/about" },
{ name: "FAQ", id: "/faq" },
{ name: "Contact", id: "contact" } // scroll to contact section on current page
];
return (
<ThemeProvider
defaultButtonVariant="hover-magnetic"
defaultTextAnimation="reveal-blur"
borderRadius="pill"
contentWidth="compact"
sizing="largeSmallSizeLargeTitles"
background="aurora"
cardStyle="gradient-radial"
primaryButtonStyle="primary-glow"
secondaryButtonStyle="radial-glow"
headingFontWeight="normal"
>
<div id="nav" data-section="nav">
<NavbarStyleFullscreen
navItems={navItems}
brandName="Moya Kvitka"
bottomLeftText="Fresh Flowers Daily"
bottomRightText="hello@moyakvitka.com"
/>
</div>
<div id="contact" data-section="contact">
<ContactSplit
tag="Stay Updated"
title="Join Our Flower Lovers Community"
description="Subscribe to receive special offers, new arrivals, seasonal collections, and floral inspiration delivered straight to your inbox. Be the first to know about exclusive promotions!"
useInvertedBackground="noInvert"
imageSrc="https://img.b2bpic.net/free-photo/young-female-florist-looking-bunch-beautiful-white-flowers_23-2147882071.jpg"
imageAlt="Fresh flower delivery"
mediaPosition="right"
tagIcon={Flower2}
inputPlaceholder="Your email address"
buttonText="Subscribe"
termsText="We respect your privacy. Unsubscribe anytime. Your information is secure and will never be shared."
/>
</div>
<FooterCard
logoText="Moya Kvitka"
copyrightText="© 2025 Moya Kvitka | Premium Floral Design | All rights reserved"
socialLinks={[
{ icon: Instagram, href: "https://instagram.com/moyakvitka", ariaLabel: "Follow us on Instagram" },
{ icon: Facebook, href: "https://facebook.com/moyakvitka", ariaLabel: "Follow us on Facebook" },
{ icon: Mail, href: "mailto:hello@moyakvitka.com", ariaLabel: "Email us" }
]}
/>
</ThemeProvider>
);
}

98
src/app/faq/page.tsx Normal file
View File

@@ -0,0 +1,98 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarStyleFullscreen from '@/components/navbar/NavbarStyleFullscreen/NavbarStyleFullscreen';
import FaqBase from '@/components/sections/faq/FaqBase';
import FooterCard from '@/components/sections/footer/FooterCard';
import { HelpCircle, Instagram, Facebook, Mail } from "lucide-react";
import Link from "next/link";
export default function FaqPage() {
// Navigation logic for FAQ page
const navItems = [
{ name: "Home", id: "/" },
{ name: "Products", id: "/products" },
{ name: "About", id: "/about" },
{ name: "FAQ", id: "faq" }, // scroll to faq section on current page
{ name: "Contact", id: "/contact" }
];
return (
<ThemeProvider
defaultButtonVariant="hover-magnetic"
defaultTextAnimation="reveal-blur"
borderRadius="pill"
contentWidth="compact"
sizing="largeSmallSizeLargeTitles"
background="aurora"
cardStyle="gradient-radial"
primaryButtonStyle="primary-glow"
secondaryButtonStyle="radial-glow"
headingFontWeight="normal"
>
<div id="nav" data-section="nav">
<NavbarStyleFullscreen
navItems={navItems}
brandName="Moya Kvitka"
bottomLeftText="Fresh Flowers Daily"
bottomRightText="hello@moyakvitka.com"
/>
</div>
<div id="faq" data-section="faq">
<FaqBase
title="Frequently Asked Questions"
description="Everything you need to know about our flowers and services"
tag="Help"
tagIcon={HelpCircle}
textboxLayout="default"
useInvertedBackground="noInvert"
animationType="smooth"
showCard={true}
faqs={[
{
id: "1",
title: "How long do your flowers last?",
content: "With proper care, most of our fresh flower arrangements last 7-10 days. We include care instructions with every order. Tips: change water daily, trim stems at an angle, remove lower leaves, and keep flowers away from direct sunlight and heat sources."
},
{
id: "2",
title: "Do you offer same-day delivery?",
content: "Yes! Same-day delivery is available for orders placed before 2 PM in select areas within Kyiv and nearby regions. Orders can be tracked in real-time, and our delivery team treats each arrangement with the utmost care."
},
{
id: "3",
title: "Can I customize my arrangement?",
content: "Absolutely! We love creating custom arrangements. You can specify your color preferences, flower types, budget, and occasion. Contact our florists directly via phone or email to discuss your vision, and we'll create something uniquely perfect for you."
},
{
id: "4",
title: "What payment methods do you accept?",
content: "We accept all major payment methods including credit cards, debit cards, bank transfers, and mobile payments (Apple Pay, Google Pay). All transactions are secure and encrypted for your protection."
},
{
id: "5",
title: "Can I send flowers internationally?",
content: "Currently, we offer delivery within Ukraine and select neighboring regions. For international orders, we recommend contacting us directly to discuss special arrangements and shipping options."
},
{
id: "6",
title: "What if the recipient isn't home for delivery?",
content: "We always attempt redelivery. Alternatively, you can specify delivery instructions (safe place to leave flowers, neighbor details, etc.). We can also arrange for flower storage at our shop for a small fee if needed."
}
]}
/>
</div>
<FooterCard
logoText="Moya Kvitka"
copyrightText="© 2025 Moya Kvitka | Premium Floral Design | All rights reserved"
socialLinks={[
{ icon: Instagram, href: "https://instagram.com/moyakvitka", ariaLabel: "Follow us on Instagram" },
{ icon: Facebook, href: "https://facebook.com/moyakvitka", ariaLabel: "Follow us on Facebook" },
{ icon: Mail, href: "mailto:hello@moyakvitka.com", ariaLabel: "Email us" }
]}
/>
</ThemeProvider>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

@@ -0,0 +1,526 @@
@import "tailwindcss";
:root {
/* Base units */
/* --vw is set by ThemeProvider */
/* --background: #f5f5f5;;
--card: #ffffff;;
--foreground: #1c1c1c;;
--primary-cta: #6139e6;;
--secondary-cta: #ffffff;;
--accent: #6139e6;;
--background-accent: #b3a8e8;; */
--background: #f5f5f5;;
--card: #ffffff;;
--foreground: #1c1c1c;;
--primary-cta: #6139e6;;
--secondary-cta: #ffffff;;
--accent: #6139e6;;
--background-accent: #b3a8e8;;
/* 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;
}
}
@theme inline {
--color-background: var(--background);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-primary-cta: var(--primary-cta);
--color-secondary-cta: var(--secondary-cta);
--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);
}
@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;
}
.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-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%;
}
}
}
* {
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-spectral), sans-serif;
position: relative;
min-height: 100vh;
overscroll-behavior: none;
overscroll-behavior-y: none;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-spectral), sans-serif;
}

1273
src/app/layout.tsx Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,131 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarStyleFullscreen from '@/components/navbar/NavbarStyleFullscreen/NavbarStyleFullscreen';
import HeroBillboardGallery from '@/components/sections/hero/HeroBillboardGallery';
import TestimonialCardFifteen from '@/components/sections/testimonial/TestimonialCardFifteen';
import FooterCard from '@/components/sections/footer/FooterCard';
import { Sparkles, Instagram, Facebook, Mail } from "lucide-react";
import Link from "next/link";
export default function HomePage() {
// Navigation logic for current page
const navItems = [
{ name: "Home", id: "/" },
{ name: "Products", id: "/products" },
{ name: "About", id: "/about" },
{ name: "FAQ", id: "/faq" },
{ name: "Contact", id: "/contact" }
];
const processedNavItems = navItems.map(item => {
if (item.id === "/") {
return { name: item.name, id: "hero" }; // scroll to hero section on current page
}
return item; // external page routes
});
return (
<ThemeProvider
defaultButtonVariant="hover-magnetic"
defaultTextAnimation="reveal-blur"
borderRadius="pill"
contentWidth="compact"
sizing="largeSmallSizeLargeTitles"
background="aurora"
cardStyle="gradient-radial"
primaryButtonStyle="primary-glow"
secondaryButtonStyle="radial-glow"
headingFontWeight="normal"
>
<div id="nav" data-section="nav">
<NavbarStyleFullscreen
navItems={processedNavItems}
brandName="Moya Kvitka"
bottomLeftText="Fresh Flowers Daily"
bottomRightText="hello@moyakvitka.com"
/>
</div>
<div id="hero" data-section="hero">
<HeroBillboardGallery
title="Celebrate Life's Moments with Fresh Flowers"
description="Discover our exquisite collection of hand-crafted floral arrangements. From romantic roses to vibrant tropicals, we bring nature's beauty to your special moments."
tag="Premium Floristry"
tagIcon={Sparkles}
mediaItems={[
{
imageSrc: "https://img.b2bpic.net/free-photo/minimal-floral-arrangement-red-surface_58702-17285.jpg",
imageAlt: "Fresh rose bouquet arrangement"
},
{
imageSrc: "https://img.b2bpic.net/free-photo/bouquet-fresh-roses-glass-vase-close-up_169016-8804.jpg",
imageAlt: "Colorful spring flowers"
},
{
imageSrc: "https://img.b2bpic.net/free-photo/pink-fresh-tulips-wooden-table-top-view_169016-50656.jpg",
imageAlt: "Elegant orchid arrangement"
},
{
imageSrc: "https://img.b2bpic.net/free-photo/tender-pink-roses-put-bouquet_1304-5428.jpg",
imageAlt: "Romantic peony bouquet"
},
{
imageSrc: "https://img.b2bpic.net/free-photo/bouquet-fresh-roses_169016-8809.jpg",
imageAlt: "Bright sunflower arrangement"
}
]}
buttons={[
{ text: "Shop Now", href: "/products" },
{ text: "Learn More", href: "/about" }
]}
/>
</div>
<div id="testimonials" data-section="testimonials">
<TestimonialCardFifteen
testimonial="Moya Kvitka created the most beautiful wedding flowers I could have imagined. Every detail was perfect, and the quality was exceptional. Our guests couldn't stop complimenting the arrangements!"
rating={5}
author="Марія Петренко, Bride"
avatars={[
{
src: "https://img.b2bpic.net/free-photo/positive-confident-businesswoman-wearing-formal-suit-standing-with-arms-folded_74855-10328.jpg",
alt: "Customer testimonial avatar 1"
},
{
src: "https://img.b2bpic.net/free-photo/close-up-positive-businesswoman_1098-3531.jpg",
alt: "Customer testimonial avatar 2"
},
{
src: "https://img.b2bpic.net/free-photo/close-up-good-looking-smiling-friendly-female-trainee-ready-tackle-assignments-smiling-broadly-feeling-lucky-day-work-self-assured-encouraged-achieve-success-goal-white-wall_176420-35567.jpg",
alt: "Customer testimonial avatar 3"
},
{
src: "https://img.b2bpic.net/free-photo/closeup-smiling-beautiful-adult-businesswoman_1262-1760.jpg",
alt: "Customer testimonial avatar 4"
},
{
src: "https://img.b2bpic.net/free-photo/confident-middle-aged-businesswoman-looking-camera_74855-4120.jpg",
alt: "Customer testimonial avatar 5"
},
{
src: "https://img.b2bpic.net/free-photo/business-woman-banner-concept-with-copy-space_23-2149601533.jpg",
alt: "Customer testimonial avatar 6"
}
]}
useInvertedBackground="invertDefault"
/>
</div>
<FooterCard
logoText="Moya Kvitka"
copyrightText="© 2025 Moya Kvitka | Premium Floral Design | All rights reserved"
socialLinks={[
{ icon: Instagram, href: "https://instagram.com/moyakvitka", ariaLabel: "Follow us on Instagram" },
{ icon: Facebook, href: "https://facebook.com/moyakvitka", ariaLabel: "Follow us on Facebook" },
{ icon: Mail, href: "mailto:hello@moyakvitka.com", ariaLabel: "Email us" }
]}
/>
</ThemeProvider>
);
}

100
src/app/products/page.tsx Normal file
View File

@@ -0,0 +1,100 @@
"use client";
import { ThemeProvider } from "@/providers/themeProvider/ThemeProvider";
import NavbarStyleFullscreen from '@/components/navbar/NavbarStyleFullscreen/NavbarStyleFullscreen';
import ProductCardThree from '@/components/sections/product/ProductCardThree';
import FooterCard from '@/components/sections/footer/FooterCard';
import { Flower2, Instagram, Facebook, Mail } from "lucide-react";
import Link from "next/link";
export default function ProductsPage() {
// Navigation logic for products page
const navItems = [
{ name: "Home", id: "/" },
{ name: "Products", id: "products" }, // scroll to products section on current page
{ name: "About", id: "/about" },
{ name: "FAQ", id: "/faq" },
{ name: "Contact", id: "/contact" }
];
return (
<ThemeProvider
defaultButtonVariant="hover-magnetic"
defaultTextAnimation="reveal-blur"
borderRadius="pill"
contentWidth="compact"
sizing="largeSmallSizeLargeTitles"
background="aurora"
cardStyle="gradient-radial"
primaryButtonStyle="primary-glow"
secondaryButtonStyle="radial-glow"
headingFontWeight="normal"
>
<div id="nav" data-section="nav">
<NavbarStyleFullscreen
navItems={navItems}
brandName="Moya Kvitka"
bottomLeftText="Fresh Flowers Daily"
bottomRightText="hello@moyakvitka.com"
/>
</div>
<div id="products" data-section="products">
<ProductCardThree
title="Featured Flower Collections"
description="Handpicked arrangements designed for every occasion and budget"
tag="New Arrivals"
tagIcon={Flower2}
products={[
{
id: "1",
name: "Romantic Red Rose Bouquet",
price: "₴1,299",
imageSrc: "https://img.b2bpic.net/free-photo/floral-decor-woman-holding-bouquet-anthurium_140725-10842.jpg",
imageAlt: "Red roses luxury bouquet",
initialQuantity: 1
},
{
id: "2",
name: "Spring Mixed Arrangement",
price: "₴899",
imageSrc: "https://img.b2bpic.net/free-photo/side-view-woman-s-body-move-eating-white-coconut-candy-brunette-girl-with-long-hair-elegant-bracelet-hand-holding-bouquet-red-flowers-concept-holiday-woman-s-presents-jewelry_132075-12359.jpg",
imageAlt: "Colorful spring flowers arrangement",
initialQuantity: 1
},
{
id: "3",
name: "Wedding Elegance Bouquet",
price: "₴2,499",
imageSrc: "https://img.b2bpic.net/free-photo/valentines-day-womens-mothers-day-red-rose-with-ribbon-heart-gift-surprise_114579-449.jpg",
imageAlt: "Elegant white wedding flowers",
initialQuantity: 1
},
{
id: "4",
name: "Tropical Paradise Mix",
price: "₴1,599",
imageSrc: "https://img.b2bpic.net/free-photo/composition-valentine-s-day-with-bouquet-red-drink-cup_169016-26067.jpg",
imageAlt: "Exotic tropical flower arrangement",
initialQuantity: 1
}
]}
gridVariant="uniform-alternating-sizes"
animationType="slide-up"
textboxLayout="default"
useInvertedBackground="noInvert"
/>
</div>
<FooterCard
logoText="Moya Kvitka"
copyrightText="© 2025 Moya Kvitka | Premium Floral Design | All rights reserved"
socialLinks={[
{ icon: Instagram, href: "https://instagram.com/moyakvitka", ariaLabel: "Follow us on Instagram" },
{ icon: Facebook, href: "https://facebook.com/moyakvitka", ariaLabel: "Follow us on Facebook" },
{ icon: Mail, href: "mailto:hello@moyakvitka.com", ariaLabel: "Email us" }
]}
/>
</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?: "noInvert" | "invertDefault";
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-background",
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}
</>
);
}

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

@@ -0,0 +1,290 @@
"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 } from "@/types/button";
import type { Avatar } from "./shared/AvatarGroup";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
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;
buttons?: ButtonConfig[];
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
avatars?: Avatar[];
avatarText?: string;
avatarGroupClassName?: string;
}
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 = "",
buttons,
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
avatars,
avatarText,
avatarGroupClassName = "",
}: TextBoxProps) => {
const theme = useTheme();
// Shared tag component
const tagElement = useMemo(() => tag && (
<Tag
text={tag}
icon={TagIcon}
useInvertedBackground={useInvertedBackground}
className={cls(textboxLayout === "default" && "mb-3", tagClassName)}
/>
), [tag, TagIcon, useInvertedBackground, textboxLayout, tagClassName]);
// 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 === "invertDefault" && "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 === "invertDefault" && "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 === "invertDefault" && "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" && "mt-3",
center && textboxLayout === "default" && "justify-center",
avatarGroupClassName
)}
/>
) : null, [avatars, avatarText, textboxLayout, center, avatarGroupClassName]);
// Shared buttons/children component
const actionsElement = useMemo(() => buttons && buttons.length > 0 ? (
<div className={cls(
"flex gap-4",
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
), [buttons, textboxLayout, center, buttonContainerClassName, theme.defaultButtonVariant, buttonClassName, buttonTextClassName, 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)}>
{inlineImageTitleElement}
{actionsElement}
</div>
);
}
// Default layout
return (
<div className={cls("flex flex-col gap-3 md:gap-1", center && "items-center text-center", className)}>
{tagElement}
{titleElement}
{descriptionElement}
{actionsElement}
{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,111 @@
"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;
perspectiveThreeD?: boolean;
}
const AnimatedGridBackground = ({
className = "",
squareSize = 100,
numSquares = 50,
maxOpacity = 0.15,
perspectiveThreeD = true,
}: 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(
"fixed inset-0 -z-10 overflow-hidden [mask-image:radial-gradient(circle_at_center,white_0%,transparent_90%)]",
perspectiveThreeD && "inset-x-0 inset-y-[-30%] h-[200%] skew-y-12",
className
)}
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-accent/20"
/>
</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-50 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,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: 0.625, rotation: -20, animationDuration: 4, animationDelay: 0 },
{ width: 35, opacity: 0.3, rotation: -12, animationDuration: 3.5, animationDelay: 0.5 },
{ width: 20, opacity: 0.05, rotation: -5, scale: 0.90, animationDuration: 5, animationDelay: 1.2 },
{ width: 15, opacity: 0.325, rotation: -3, animationDuration: 3, animationDelay: 0.3 },
{ width: 40, opacity: 0.2, rotation: 0, scale: 0.79, animationDuration: 4.5, animationDelay: 0.8 },
{ width: 20, opacity: 0.125, rotation: 3, animationDuration: 3.2, animationDelay: 1.5 },
{ width: 15, opacity: 0.325, rotation: 5, scale: 0.90, animationDuration: 4.2, animationDelay: 0.2 },
{ width: 35, opacity: 0.3, rotation: 12, animationDuration: 3.8, animationDelay: 1 },
{ width: 35, opacity: 0.625, rotation: 20, animationDuration: 4, animationDelay: 0.7 },
];
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 DownwardRaysBackground = ({
animated,
showGrid,
className = "",
containerClassName = "",
}: DownwardRaysBackgroundProps) => {
return (
<div
className={cls("fixed 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-primary-cta) 10%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--color-primary-cta) 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-primary-cta)_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-primary-cta)_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-primary-cta'),
accent: getComputedColor('--color-accent'),
secondaryCta: getComputedColor('--color-secondary-cta'),
});
};
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,67 @@
'use client';
import React, { memo } from 'react';
import { cls } from '@/lib/utils';
interface GradientBarsBackgroundProps {
className?: string;
numBars?: number;
gradientFrom?: string;
gradientTo?: string;
opacity?: number;
}
const GradientBarsBackground = ({
className = "",
numBars = 15,
gradientFrom = "var(--color-primary-cta)",
gradientTo = "transparent",
opacity = 0.5,
}: GradientBarsBackgroundProps) => {
const calculateHeight = (index: number, total: number): number => {
const position = index / (total - 1);
const maxHeight = 100;
const minHeight = 30;
const center = 0.5;
const distanceFromCenter = Math.abs(position - center);
const heightPercentage = Math.pow(distanceFromCenter * 2, 1.2);
return minHeight + (maxHeight - minHeight) * heightPercentage;
};
return (
<div
className={cls("fixed inset-0 -z-10 overflow-hidden", className)}
aria-hidden="true"
>
<div
className="flex h-full w-full backface-hidden antialiased"
style={{
transform: 'translateZ(0)',
}}
>
{Array.from({ length: numBars }).map((_, index) => {
const height = calculateHeight(index, numBars);
return (
<div
key={index}
className="h-full origin-bottom box-border"
style={{
flex: `1 0 calc(100% / ${numBars})`,
maxWidth: `calc(100% / ${numBars})`,
background: `linear-gradient(to top, ${gradientFrom}, ${gradientTo})`,
transform: `scaleY(${height / 100})`,
opacity: opacity,
}}
/>
);
})}
</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,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-accent/10",
className
)}
>
<div
className="absolute inset-0 bg-repeat mix-blend-overlay opacity-12"
style={{
backgroundImage: "url(/images/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-accent/10",
className
)}
>
<div
className="absolute inset-0 overflow-hidden pointer-events-none opacity-100 bg-gradient-to-br 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(/images/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(/images/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,34 @@
'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("fixed top-0 left-0 right-0 bottom-0 h-screen w-full -z-10", className)}
style={{
background: `radial-gradient(${size} at ${position}, ${centerColor} 40%, ${edgeColor} 100%)`,
}}
aria-hidden="true"
/>
);
};
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.625, rotation: -18, animationDuration: 4, animationDelay: 0 },
{ width: 35, opacity: 0.3, rotation: -12, animationDuration: 3.5, animationDelay: 0.5 },
{ width: 20, opacity: 0.05, rotation: -5, scale: 0.90, animationDuration: 5, animationDelay: 1.2 },
{ width: 15, opacity: 0.325, rotation: -3, animationDuration: 3, animationDelay: 0.3 },
{ width: 40, opacity: 0.2, rotation: 0, scale: 0.79, animationDuration: 4.5, animationDelay: 0.8 },
{ width: 20, opacity: 0.125, rotation: 6, animationDuration: 3.2, animationDelay: 1.5 },
{ width: 35, opacity: 0.425, rotation: 9, scale: 0.83, animationDuration: 4.2, animationDelay: 0.2 },
{ width: 35, opacity: 0.625, 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("fixed 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-primary-cta) 10%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in srgb, var(--color-primary-cta) 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-primary-cta)_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-primary-cta)_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,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,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-background",
"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-background",
"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-background",
"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 primary-button rounded-theme text-background",
"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-background 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-foreground"
)}>
<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-background 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,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-background",
"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-background",
"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-background" : "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,90 @@
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;
};
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,75 @@
"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) => {
// Невелика затримка для того, щоб DOM встиг оновитися після навігації
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,117 @@
"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, 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;
buttons?: ButtonConfig[];
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,
buttons,
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 });
return (
<section
aria-label={ariaLabel}
className={cls(
"relative py-20 w-full",
useInvertedBackground === "invertDefault" && "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}
buttons={buttons}
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,190 @@
"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";
const CardStack = ({
children,
mode = "buttons",
gridVariant = "uniform-all-items-equal",
uniformGridCustomHeightClasses,
gridRowsClassName,
itemHeightClassesOverride,
animationType,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
carouselThreshold = 5,
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;
// Timeline layout for zigzag pattern (works best with 3-6 items)
if ((gridVariant === "timeline" || gridVariant === "timeline-three-columns") && itemCount >= 3 && itemCount <= 6) {
return (
<TimelineBase
variant={gridVariant}
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
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" || gridVariant === "timeline-three-columns") && itemCount > 6);
// Grid layout for 1-4 items
if (!useCarousel) {
return (
<GridLayout
itemCount={itemCount}
gridVariant={gridVariant}
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
gridRowsClassName={gridRowsClassName}
itemHeightClassesOverride={itemHeightClassesOverride}
animationType={animationType}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
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") {
return (
<AutoCarousel
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
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
return (
<ButtonCarousel
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
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,88 @@
"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,
buttons,
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 gap-4 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 gap-4 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 gap-4", 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}
buttons={buttons}
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,95 @@
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { CardAnimationType } from "../types";
gsap.registerPlugin(ScrollTrigger);
interface UseCardAnimationProps {
animationType: CardAnimationType;
itemCount: number;
}
export const useCardAnimation = ({ animationType, itemCount }: UseCardAnimationProps) => {
const itemRefs = useRef<(HTMLElement | null)[]>([]);
useGSAP(() => {
if (animationType === "none" || itemRefs.current.length === 0) return;
const items = itemRefs.current.filter((el) => el !== null);
if (animationType === "opacity") {
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 (animationType === "slide-up") {
items.forEach((item, index) => {
gsap.fromTo(
item,
{ opacity: 0, yPercent: 15 },
{
opacity: 1,
yPercent: 0,
duration: 1,
delay: index * 0.15,
ease: "sine",
scrollTrigger: {
trigger: items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
});
} else if (animationType === "scale-rotate") {
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 (animationType === "blur-reveal") {
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",
},
}
);
}
}, [animationType, itemCount]);
return { itemRefs };
};

View File

@@ -0,0 +1,108 @@
import { useEffect, 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)[]>([]);
useEffect(() => {
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, useCallback } 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 = useCallback(() => {
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
}
if (autoPlay) {
autoPlayRef.current = setInterval(() => {
setActiveIndex((prev) => (prev + 1) % n);
}, autoPlayInterval);
}
}, [autoPlay, autoPlayInterval, n]);
useEffect(() => {
resetAutoPlay();
return () => {
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
}
};
}, [resetAutoPlay]);
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 pointer-events-none" />
{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}-pos-${positionFactor}`}
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]"
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,134 @@
"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,
buttons,
textboxLayout = "default",
useInvertedBackground,
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 } = useCardAnimation({ animationType, itemCount: childrenArray.length });
// 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 === "invertDefault" && "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}
buttons={buttons}
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>
</div>
</div>
</div>
</section>
);
};
AutoCarousel.displayName = "AutoCarousel";
export default memo(AutoCarousel);

View File

@@ -0,0 +1,168 @@
"use client";
import { memo, Children } from "react";
import useEmblaCarousel from "embla-carousel-react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import { ButtonCarouselProps } from "../../types";
import { usePrevNextButtons } from "../../hooks/usePrevNextButtons";
import { useScrollProgress } from "../../hooks/useScrollProgress";
import { useCardAnimation } from "../../hooks/useCardAnimation";
const ButtonCarousel = ({
children,
uniformGridCustomHeightClasses,
animationType,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
carouselClassName = "",
carouselItemClassName = "",
controlsClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel,
}: ButtonCarouselProps) => {
const [emblaRef, emblaApi] = useEmblaCarousel({ dragFree: true });
const {
prevBtnDisabled,
nextBtnDisabled,
onPrevButtonClick,
onNextButtonClick,
} = usePrevNextButtons(emblaApi);
const scrollProgress = useScrollProgress(emblaApi);
const childrenArray = Children.toArray(children);
const heightClasses = uniformGridCustomHeightClasses || "min-h-80 2xl:min-h-90";
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length });
return (
<section
className={cls(
"relative px-[var(--width-0)] py-20 w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-full mx-auto", containerClassName)}>
<div className="w-full flex flex-col items-center">
<div className="w-full flex flex-col gap-6">
{(title || titleSegments || description) && (
<div className="w-content-width mx-auto">
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
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={cls(
"w-full flex flex-col gap-6"
)}
>
<div
className={cls(
"overflow-hidden w-full relative z-10 flex cursor-grab",
carouselClassName
)}
ref={emblaRef}
>
<div className="flex gap-6 w-full">
<div className="flex-shrink-0 w-carousel-padding" />
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls("flex-none select-none w-carousel-item-3 xl:w-carousel-item-4 mb-6", heightClasses, carouselItemClassName)}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
))}
<div className="flex-shrink-0 w-carousel-padding" />
</div>
</div>
<div className={cls("w-full flex", controlsClassName)}>
<div className="flex-shrink-0 w-carousel-padding-controls" />
<div className="flex justify-between items-center w-full">
<div
className="rounded-theme card relative h-2 w-50 overflow-hidden"
role="progressbar"
aria-label="Carousel progress"
aria-valuenow={Math.round(scrollProgress)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="bg-foreground primary-button absolute w-full top-0 bottom-0 -left-full rounded-theme"
style={{ transform: `translate3d(${scrollProgress}%,0px,0px)` }}
/>
</div>
<div className="flex items-center gap-3">
<button
onClick={onPrevButtonClick}
disabled={prevBtnDisabled}
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
type="button"
aria-label="Previous slide"
>
<ChevronLeft className="h-[40%] w-auto aspect-square text-foreground" />
</button>
<button
onClick={onNextButtonClick}
disabled={nextBtnDisabled}
className="secondary-button h-8 aspect-square flex items-center justify-center rounded-theme cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50"
type="button"
aria-label="Next slide"
>
<ChevronRight className="h-[40%] w-auto aspect-square text-foreground" />
</button>
</div>
</div>
<div className="flex-shrink-0 w-carousel-padding-controls" />
</div>
</div>
</div>
</div>
</div>
</section>
);
};
ButtonCarousel.displayName = "ButtonCarousel";
export default memo(ButtonCarousel);

View File

@@ -0,0 +1,151 @@
"use client";
import { memo, Children, cloneElement, isValidElement, 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 { FullWidthCarouselProps } from "../../types";
const FullWidthCarousel = ({
children,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
carouselClassName = "",
dotsClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Carousel section",
}: FullWidthCarouselProps) => {
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 scrollTo = useCallback(
(index: number) => {
if (!emblaApi) return;
emblaApi.scrollTo(index);
},
[emblaApi]
);
useEffect(() => {
if (!emblaApi) return;
onSelect(emblaApi);
emblaApi.on("select", onSelect).on("reInit", onSelect);
return () => {
emblaApi.off("select", onSelect).off("reInit", onSelect);
};
}, [emblaApi, onSelect]);
useEffect(() => {
if (!emblaApi) return;
const autoplay = setInterval(() => {
emblaApi.scrollNext();
}, 5000);
return () => clearInterval(autoplay);
}, [emblaApi]);
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground === "invertDefault" && "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}
buttons={buttons}
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="w-full">
<div
className={cls(
"overflow-hidden w-full relative z-10",
carouselClassName
)}
ref={emblaRef}
>
<div className="flex w-full">
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className="flex-none w-70 mr-6"
>
{isValidElement(child)
? cloneElement(child, { isActive: selectedIndex === index } as Record<string, unknown>)
: child}
</div>
))}
</div>
</div>
</div>
<div className={cls("flex items-center justify-center gap-2", dotsClassName)}>
{childrenArray.map((_, index) => (
<button
key={index}
type="button"
onClick={() => scrollTo(index)}
className={cls(
"relative cursor-pointer h-2 rounded-theme bg-accent transition-all duration-300",
selectedIndex === index
? "w-8 opacity-100"
: "w-2 opacity-20"
)}
aria-label={`Go to slide ${index + 1}`}
aria-current={selectedIndex === index}
/>
))}
</div>
</div>
</section>
);
};
FullWidthCarousel.displayName = "FullWidthCarousel";
export default memo(FullWidthCarousel);

View File

@@ -0,0 +1,131 @@
"use client";
import { memo, Children } from "react";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import { GridLayoutProps } from "../../types";
import { gridConfigs } from "./gridConfigs";
import { useCardAnimation } from "../../hooks/useCardAnimation";
const GridLayout = ({
children,
itemCount,
gridVariant = "uniform-all-items-equal",
uniformGridCustomHeightClasses,
gridRowsClassName,
itemHeightClassesOverride,
animationType,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
gridClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel,
}: GridLayoutProps) => {
// Get config for this variant and item count
const config = gridConfigs[gridVariant]?.[itemCount];
// Fallback to default uniform grid if no config
const gridColsMap = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const defaultGridCols = gridColsMap[itemCount as keyof typeof gridColsMap] || "md:grid-cols-4";
// Use config values or fallback
const gridCols = config?.gridCols || defaultGridCols;
const gridRows = gridRowsClassName || config?.gridRows || "";
const itemClasses = config?.itemClasses || [];
const itemHeightClasses = itemHeightClassesOverride || config?.itemHeightClasses || [];
const heightClasses = uniformGridCustomHeightClasses || config?.heightClasses || "";
const itemWrapperClass = config?.itemWrapperClass || "";
const childrenArray = Children.toArray(children);
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length });
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}>
{(title || titleSegments || description) && (
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
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(
"grid grid-cols-1 gap-6",
gridCols,
gridRows,
gridClassName
)}
>
{childrenArray.map((child, index) => {
const itemClass = itemClasses[index] || "";
const itemHeightClass = itemHeightClasses[index] || "";
const combinedClass = cls(itemWrapperClass, itemClass, itemHeightClass, heightClasses);
return combinedClass ? (
<div
key={index}
className={combinedClass}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
) : (
<div
key={index}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
);
})}
</div>
</div>
</section>
);
};
GridLayout.displayName = "GridLayout";
export default memo(GridLayout);

View File

@@ -0,0 +1,317 @@
type GridConfig = {
gridCols: string;
gridRows?: string;
itemClasses: string[];
itemHeightClasses?: string[];
heightClasses?: string;
itemWrapperClass?: string;
} | null;
type GridVariantConfig = {
[key: number]: GridConfig;
};
export const gridConfigs: Record<string, GridVariantConfig> = {
"uniform-all-items-equal": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
4: { gridCols: "md:grid-cols-4", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
},
"uniform-alternating-sizes": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-10 md:items-start",
itemClasses: [
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
4: {
gridCols: "md:grid-cols-14 md:items-start",
itemClasses: [
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
},
"uniform-alternating-sizes-inverted": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-10 md:items-start",
itemClasses: [
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-2 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
4: {
gridCols: "md:grid-cols-14 md:items-start",
itemClasses: [
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
"md:col-span-3 min-h-80 md:min-h-70 2xl:min-h-80",
"md:col-span-4 min-h-80 md:min-h-100 2xl:min-h-110",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid md:items-start"
},
},
"two-items-tall-short": {
1: null,
2: {
gridCols: "md:grid-cols-2 md:items-start",
itemClasses: [
"min-h-80 md:min-h-100 2xl:min-h-120",
"min-h-80 md:min-h-70 2xl:min-h-80",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid"
},
3: { gridCols: "md:grid-cols-3", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
4: { gridCols: "md:grid-cols-4", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
},
"two-items-short-tall": {
1: null,
2: {
gridCols: "md:grid-cols-2 md:items-start",
itemClasses: [
"min-h-80 md:min-h-70 2xl:min-h-80",
"min-h-80 md:min-h-100 2xl:min-h-120",
],
heightClasses: "md:!h-fit",
itemWrapperClass: "grid"
},
3: { gridCols: "md:grid-cols-3", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
4: { gridCols: "md:grid-cols-4", itemClasses: [], heightClasses: "min-h-80 2xl:min-h-90" },
},
"bento-grid": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-4",
gridRows: "md:grid-rows-[14rem_14rem] 2xl:grid-rows-[17rem_17rem]",
itemClasses: [
"md:col-span-2 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
],
heightClasses: "min-h-80"
},
4: {
gridCols: "md:grid-cols-4",
gridRows: "md:grid-rows-[14rem_14rem] 2xl:grid-rows-[17rem_17rem]",
itemClasses: [
"md:col-span-2 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
],
heightClasses: "min-h-80"
},
},
"bento-grid-inverted": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-4",
gridRows: "md:grid-rows-[14rem_14rem] 2xl:grid-rows-[17rem_17rem]",
itemClasses: [
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
],
heightClasses: "min-h-80"
},
4: {
gridCols: "md:grid-cols-4",
gridRows: "md:grid-rows-[14rem_14rem] 2xl:grid-rows-[17rem_17rem]",
itemClasses: [
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-2 md:row-start-1 md:col-start-3 md:min-h-0 md:overflow-hidden",
],
heightClasses: "min-h-80"
},
},
"two-columns-alternating-heights": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [] },
4: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[13rem_13rem_0.5rem_0.5rem_13rem_13rem] 2xl:grid-rows-[16rem_16rem_0.5rem_0.5rem_16rem_16rem]",
itemClasses: [
"md:col-span-1 md:row-span-2 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-4 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-4 md:row-start-3 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-2 md:row-start-5 md:min-h-0 md:overflow-hidden",
]
},
},
"asymmetric-60-wide-40-narrow": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-10",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-6 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-10 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-10",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-6 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-6 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"three-columns-all-equal-width": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[21rem_21rem] 2xl:grid-rows-[24rem_24rem]",
itemClasses: [
"md:col-span-1 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-3",
gridRows: "md:grid-rows-[21rem_21rem] 2xl:grid-rows-[24rem_24rem]",
itemClasses: [
"md:col-span-1 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-2 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"four-items-2x2-equal-grid": {
1: null,
2: null,
3: { gridCols: "md:grid-cols-3", itemClasses: [] },
4: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[26.5rem_26.5rem] 2xl:grid-rows-[32.5rem_32.5rem]",
itemClasses: [
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"one-large-right-three-stacked-left": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-6",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-2 md:row-span-1 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-2 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-2 md:row-start-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-6",
gridRows: "md:grid-rows-[17.5rem_17.5rem_17.5rem] 2xl:grid-rows-[21rem_21rem_21rem]",
itemClasses: [
"md:col-span-2 md:row-span-1 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-3 md:min-h-0 md:overflow-hidden",
"md:col-span-4 md:row-span-3 md:row-start-1 md:min-h-0 md:overflow-hidden",
]
},
},
"items-top-row-full-width-bottom": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-3",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-3 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"full-width-top-items-bottom-row": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-2",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-2 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-3",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-3 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
"md:col-span-1 md:row-span-1 md:min-h-0 md:overflow-hidden",
]
},
},
"one-large-left-three-stacked-right": {
1: null,
2: null,
3: {
gridCols: "md:grid-cols-6",
gridRows: "md:grid-rows-[24rem_24rem] 2xl:grid-rows-[27rem_27rem]",
itemClasses: [
"md:col-span-4 md:row-span-2 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-2 md:min-h-0 md:overflow-hidden",
]
},
4: {
gridCols: "md:grid-cols-6",
gridRows: "md:grid-rows-[17.5rem_17.5rem_17.5rem] 2xl:grid-rows-[21rem_21rem_21rem]",
itemClasses: [
"md:col-span-4 md:row-span-3 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-1 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-2 md:min-h-0 md:overflow-hidden",
"md:col-span-2 md:row-span-1 md:row-start-3 md:min-h-0 md:overflow-hidden",
]
},
},
};

View File

@@ -0,0 +1,151 @@
"use client";
import React, { Children, useCallback } from "react";
import { cls } from "@/lib/utils";
import CardStackTextBox from "../../CardStackTextBox";
import { useCardAnimation } from "../../hooks/useCardAnimation";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, CardAnimationType, TitleSegment } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
type TimelineVariant = "timeline" | "timeline-three-columns";
interface TimelineBaseProps {
children: React.ReactNode;
variant?: TimelineVariant;
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
title?: string;
titleSegments?: TitleSegment[];
description?: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout?: TextboxLayout;
useInvertedBackground?: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
ariaLabel?: string;
}
const TimelineBase = ({
children,
variant = "timeline",
uniformGridCustomHeightClasses = "min-h-80 2xl:min-h-90",
animationType,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Timeline section",
}: TimelineBaseProps) => {
const childrenArray = Children.toArray(children);
const { itemRefs } = useCardAnimation({ animationType, itemCount: childrenArray.length });
const getItemClasses = useCallback((index: number) => {
if (variant === "timeline-three-columns") {
// Pattern: start (0) → center (1) → end (2) → center (3) → start (4) → center (5) ...
const position = index % 4;
const alignmentClasses = cls(
position === 0 && "self-start md:self-start",
position === 1 && "self-end md:self-center",
position === 2 && "self-start md:self-end",
position === 3 && "self-end md:self-center"
);
return alignmentClasses;
}
// Default timeline variant - scattered/organic pattern
const alignmentClass =
index % 2 === 0 ? "self-start ml-0" : "self-end mr-0";
const marginClasses = cls(
index % 4 === 0 && "md:ml-0",
index % 4 === 1 && "md:mr-20",
index % 4 === 2 && "md:ml-15",
index % 4 === 3 && "md:mr-30"
);
return cls(alignmentClass, marginClasses);
}, [variant]);
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div
className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}
>
{(title || titleSegments || description) && (
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
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(
"relative z-10 flex flex-col gap-6 md:gap-15"
)}
>
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls("w-65 md:w-25", uniformGridCustomHeightClasses, getItemClasses(index))}
ref={(el) => { itemRefs.current[index] = el; }}
>
{child}
</div>
))}
</div>
</div>
</section>
);
};
TimelineBase.displayName = "TimelineBase";
export default React.memo(TimelineBase);

View File

@@ -0,0 +1,141 @@
"use client";
import React, { useEffect, useRef, memo, Children } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import CardStackTextBox from "../../CardStackTextBox";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, TitleSegment } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
gsap.registerPlugin(ScrollTrigger);
interface TimelineCardStackProps {
children: React.ReactNode;
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
ariaLabel?: string;
}
const TimelineCardStack = ({
children,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Timeline section",
}: TimelineCardStackProps) => {
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
const childrenArray = Children.toArray(children);
useEffect(() => {
const ctx = gsap.context(() => {
itemRefs.current.forEach((ref, position) => {
if (!ref) return;
const isLast = position === itemRefs.current.length - 1;
const timeline = gsap.timeline({
scrollTrigger: {
trigger: ref,
start: "center center",
end: "+=100%",
scrub: true,
},
});
timeline.set(ref, { willChange: "opacity" }).to(ref, {
ease: "none",
opacity: isLast ? 1 : 0,
});
});
});
return () => {
ctx.revert();
};
}, [childrenArray.length]);
return (
<section
className={cls(
"relative overflow-visible h-fit py-20 w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}>
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
<div className="w-full flex flex-col gap-[var(--width-25)] md:gap-[6.25vh]">
{Children.map(childrenArray, (child, index) => (
<div
key={index}
ref={(el) => {
itemRefs.current[index] = el;
}}
className="!sticky w-full card backdrop-blur-xs rounded-theme-capped h-[140vw] md:h-[75vh] top-[25vw] md:top-[12.5vh]"
>
{child}
</div>
))}
</div>
</div>
</section>
);
};
TimelineCardStack.displayName = "TimelineCardStack";
export default memo(TimelineCardStack);

View File

@@ -0,0 +1,169 @@
"use client";
import React, { Children, useCallback } from "react";
import { cls } from "@/lib/utils";
import CardStackTextBox from "../../CardStackTextBox";
import { useTimelineHorizontal, type MediaItem } from "../../hooks/useTimelineHorizontal";
import MediaContent from "@/components/shared/MediaContent";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig, TitleSegment, TextboxLayout, InvertedBackground } from "../../types";
interface TimelineHorizontalCardStackProps {
children: React.ReactNode;
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
mediaItems?: MediaItem[];
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
cardClassName?: string;
progressBarClassName?: string;
mediaContainerClassName?: string;
mediaClassName?: string;
ariaLabel?: string;
}
const TimelineHorizontalCardStack = ({
children,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
useInvertedBackground,
mediaItems,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
cardClassName = "",
progressBarClassName = "",
mediaContainerClassName = "",
mediaClassName = "",
ariaLabel = "Timeline section",
}: TimelineHorizontalCardStackProps) => {
const childrenArray = Children.toArray(children);
const itemCount = childrenArray.length;
const { activeIndex, progressRefs, handleItemClick, imageOpacity, currentMediaSrc } = useTimelineHorizontal({
itemCount,
mediaItems,
});
const getGridColumns = useCallback(() => {
if (itemCount === 2) return "md:grid-cols-2";
if (itemCount === 3) return "md:grid-cols-3";
return "md:grid-cols-4";
}, [itemCount]);
const getItemOpacity = useCallback(
(index: number) => {
return index <= activeIndex ? "opacity-100" : "opacity-50";
},
[activeIndex]
);
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-6", containerClassName)}>
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
{mediaItems && mediaItems.length > 0 && (
<div className={cls("relative card rounded-theme-capped overflow-hidden aspect-square md:aspect-[17/9]", mediaContainerClassName)}>
<div
className="absolute inset-6 z-1 transition-opacity duration-300 overflow-hidden"
style={{ opacity: imageOpacity }}
>
<MediaContent
imageSrc={currentMediaSrc.imageSrc}
videoSrc={currentMediaSrc.videoSrc}
imageAlt={mediaItems[activeIndex]?.imageAlt}
videoAriaLabel={mediaItems[activeIndex]?.videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", mediaClassName)}
/>
</div>
</div>
)}
<div className={cls("relative grid grid-cols-1 gap-6 md:gap-6", getGridColumns())}>
{Children.map(childrenArray, (child, index) => (
<div
key={index}
className={cls(
"card rounded-theme-capped p-6 flex flex-col justify-between gap-6 transition-all duration-300",
index === activeIndex ? "cursor-default" : "cursor-pointer hover:shadow-lg",
getItemOpacity(index),
cardClassName
)}
onClick={() => handleItemClick(index)}
>
{child}
<div className="relative w-full h-px overflow-hidden">
<div className="absolute z-0 w-full h-full bg-foreground/20" />
<div
ref={(el) => {
if (el !== null) {
progressRefs.current[index] = el;
}
}}
className={cls("absolute z-10 h-full w-full bg-foreground origin-left", progressBarClassName)}
style={{ transform: "scaleX(0)" }}
/>
</div>
</div>
))}
</div>
</div>
</section>
);
};
TimelineHorizontalCardStack.displayName = "TimelineHorizontalCardStack";
export default React.memo(TimelineHorizontalCardStack);

View File

@@ -0,0 +1,258 @@
"use client";
import React, { memo } from "react";
import MediaContent from "@/components/shared/MediaContent";
import CardStackTextBox from "../../CardStackTextBox";
import { usePhoneAnimations, type TimelinePhoneViewItem } from "../../hooks/usePhoneAnimations";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "../../types";
import type { TitleSegment } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
interface PhoneFrameProps {
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
phoneRef: (el: HTMLDivElement | null) => void;
className?: string;
}
const PhoneFrame = memo(({
imageSrc,
videoSrc,
imageAlt,
videoAriaLabel,
phoneRef,
className = "",
}: PhoneFrameProps) => (
<div
ref={phoneRef}
className={cls("card rounded-theme-capped p-1 overflow-hidden", className)}
>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName="w-full h-full object-cover rounded-theme-capped"
/>
</div>
));
PhoneFrame.displayName = "PhoneFrame";
interface TimelinePhoneViewProps {
items: TimelinePhoneViewItem[];
showTextBox?: boolean;
showDivider?: boolean;
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
desktopContainerClassName?: string;
mobileContainerClassName?: string;
desktopContentClassName?: string;
desktopWrapperClassName?: string;
mobileWrapperClassName?: string;
phoneFrameClassName?: string;
mobilePhoneFrameClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
ariaLabel?: string;
}
const TimelinePhoneView = ({
items,
showTextBox = true,
showDivider = false,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
useInvertedBackground,
className = "",
containerClassName = "",
textBoxClassName = "",
titleClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
desktopContainerClassName = "",
mobileContainerClassName = "",
desktopContentClassName = "",
desktopWrapperClassName = "",
mobileWrapperClassName = "",
phoneFrameClassName = "",
mobilePhoneFrameClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
ariaLabel = "Timeline phone view section",
}: TimelinePhoneViewProps) => {
const { imageRefs, mobileImageRefs } = usePhoneAnimations(items);
const sectionHeightStyle = { height: `${items.length * 100}vh` };
return (
<section
className={cls(
"relative py-20 overflow-hidden md:overflow-visible w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-full mx-auto flex flex-col gap-6", containerClassName)}>
{showTextBox && (
<div className="relative w-content-width mx-auto" >
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
/>
</div>
)}
{showDivider && (
<div className="relative w-content-width mx-auto h-px bg-accent md:hidden" />
)}
<div className="hidden md:flex relative" style={sectionHeightStyle}>
<div
className={cls(
"absolute top-0 left-0 flex flex-col w-[calc(var(--width-content-width)-var(--width-20)*2)] 2xl:w-[calc(var(--width-content-width)-var(--width-25)*2)] mx-auto right-0 z-10",
desktopContainerClassName
)}
style={sectionHeightStyle}
>
{items.map((item, index) => (
<div
key={`content-${index}`}
className={cls(
item.trigger,
"w-full mx-auto h-screen flex justify-center items-center",
desktopContentClassName
)}
>
<div className={desktopWrapperClassName}>
{item.content}
</div>
</div>
))}
</div>
<div className="sticky top-0 left-0 h-screen w-full overflow-hidden">
{items.map((item, itemIndex) => (
<div
key={`phones-${itemIndex}`}
className="h-screen w-full absolute top-0 left-0"
>
<div className="w-content-width mx-auto h-full flex flex-row justify-between items-center">
<PhoneFrame
key={`phone-${itemIndex}-1`}
imageSrc={item.imageOne}
videoSrc={item.videoOne}
imageAlt={item.imageAltOne}
videoAriaLabel={item.videoAriaLabelOne}
phoneRef={(el) => {
if (imageRefs.current) {
imageRefs.current[itemIndex * 2] = el;
}
}}
className={cls("w-20 2xl:w-25 h-[70vh]", phoneFrameClassName)}
/>
<PhoneFrame
key={`phone-${itemIndex}-2`}
imageSrc={item.imageTwo}
videoSrc={item.videoTwo}
imageAlt={item.imageAltTwo}
videoAriaLabel={item.videoAriaLabelTwo}
phoneRef={(el) => {
if (imageRefs.current) {
imageRefs.current[itemIndex * 2 + 1] = el;
}
}}
className={cls("w-20 2xl:w-25 h-[70vh]", phoneFrameClassName)}
/>
</div>
</div>
))}
</div>
</div>
<div className={cls("md:hidden flex flex-col gap-20", mobileContainerClassName)}>
{items.map((item, itemIndex) => (
<div
key={`mobile-item-${itemIndex}`}
className="flex flex-col gap-10"
>
<div className={mobileWrapperClassName}>
{item.content}
</div>
<div className="flex flex-row gap-6 justify-center">
<PhoneFrame
key={`mobile-phone-${itemIndex}-1`}
imageSrc={item.imageOne}
videoSrc={item.videoOne}
imageAlt={item.imageAltOne}
videoAriaLabel={item.videoAriaLabelOne}
phoneRef={(el) => {
if (mobileImageRefs.current) {
mobileImageRefs.current[itemIndex * 2] = el;
}
}}
className={cls("w-40 h-80", mobilePhoneFrameClassName)}
/>
<PhoneFrame
key={`mobile-phone-${itemIndex}-2`}
imageSrc={item.imageTwo}
videoSrc={item.videoTwo}
imageAlt={item.imageAltTwo}
videoAriaLabel={item.videoAriaLabelTwo}
phoneRef={(el) => {
if (mobileImageRefs.current) {
mobileImageRefs.current[itemIndex * 2 + 1] = el;
}
}}
className={cls("w-40 h-80", mobilePhoneFrameClassName)}
/>
</div>
</div>
))}
</div>
</div>
</section>
);
};
TimelinePhoneView.displayName = "TimelinePhoneView";
export default memo(TimelinePhoneView);

View File

@@ -0,0 +1,184 @@
"use client";
import React, { useEffect, useRef, memo } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import CardStackTextBox from "../../CardStackTextBox";
import { useCardAnimation } from "../../hooks/useCardAnimation";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "../../types";
import type { CardAnimationType } from "../../types";
import type { TitleSegment } from "../../types";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
gsap.registerPlugin(ScrollTrigger);
interface TimelineProcessFlowItem {
id: string;
content: React.ReactNode;
media: React.ReactNode;
reverse: boolean;
}
interface TimelineProcessFlowProps {
items: TimelineProcessFlowItem[];
title: string;
titleSegments?: TitleSegment[];
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
animationType: CardAnimationType;
useInvertedBackground?: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
textBoxClassName?: string;
textBoxTitleClassName?: string;
textBoxDescriptionClassName?: string;
textBoxTagClassName?: string;
textBoxButtonContainerClassName?: string;
textBoxButtonClassName?: string;
textBoxButtonTextClassName?: string;
itemClassName?: string;
mediaWrapperClassName?: string;
numberClassName?: string;
contentWrapperClassName?: string;
gapClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
}
const TimelineProcessFlow = ({
items,
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout,
animationType,
useInvertedBackground,
ariaLabel = "Timeline process flow section",
className = "",
containerClassName = "",
textBoxClassName = "",
textBoxTitleClassName = "",
textBoxDescriptionClassName = "",
textBoxTagClassName = "",
textBoxButtonContainerClassName = "",
textBoxButtonClassName = "",
textBoxButtonTextClassName = "",
itemClassName = "",
mediaWrapperClassName = "",
numberClassName = "",
contentWrapperClassName = "",
gapClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
}: TimelineProcessFlowProps) => {
const processLineRef = useRef<HTMLDivElement>(null);
const { itemRefs } = useCardAnimation({ animationType, itemCount: items.length });
useEffect(() => {
if (!processLineRef.current) return;
gsap.fromTo(
processLineRef.current,
{ yPercent: -100 },
{
yPercent: 0,
ease: "none",
scrollTrigger: {
trigger: ".timeline-line",
start: "top center",
end: "bottom center",
scrub: true,
},
}
);
return () => {
ScrollTrigger.getAll().forEach((trigger) => trigger.kill());
};
}, []);
return (
<section
className={cls(
"relative py-20 w-full",
useInvertedBackground === "invertDefault" && "bg-foreground",
className
)}
aria-label={ariaLabel}
>
<div className={cls("w-full flex flex-col gap-6", containerClassName)}>
<div className="relative w-content-width mx-auto">
<CardStackTextBox
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
textBoxClassName={textBoxClassName}
titleClassName={textBoxTitleClassName}
descriptionClassName={textBoxDescriptionClassName}
tagClassName={textBoxTagClassName}
buttonContainerClassName={textBoxButtonContainerClassName}
buttonClassName={textBoxButtonClassName}
buttonTextClassName={textBoxButtonTextClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
/>
</div>
<div className="relative w-full">
<div className="timeline-line pointer-events-none absolute top-0 right-[var(--width-10)] md:right-auto md:left-1/2 md:-translate-x-1/2 w-px h-full z-0 overflow-hidden bg-foreground">
<div className="w-full h-full bg-accent" ref={processLineRef} />
</div>
<ol className={cls("relative flex flex-col gap-10 md:gap-20 w-content-width mx-auto", gapClassName)}>
{items.map((item, index) => (
<li
key={item.id}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={cls(
"relative z-10 w-full flex flex-col gap-6 md:gap-0 md:flex-row justify-between",
item.reverse && "flex-col md:flex-row-reverse",
itemClassName
)}
>
<div
className={cls("relative w-70 md:w-30", mediaWrapperClassName)}
>
{item.media}
</div>
<div
className={cls(
"absolute top-1/2 right-[calc(var(--height-8)/-2)] md:right-auto md:left-1/2 md:-translate-x-1/2 -translate-y-1/2 h-8 aspect-square rounded-theme flex items-center justify-center z-10 primary-button",
numberClassName
)}
>
<p className="text-sm text-background">{item.id}</p>
</div>
<div className={cls("relative w-70 md:w-30", contentWrapperClassName)}>
{item.content}
</div>
</li>
))}
</ol>
</div>
</div>
</section>
);
};
TimelineProcessFlow.displayName = "TimelineProcessFlow";
export default memo(TimelineProcessFlow);

View File

@@ -0,0 +1,134 @@
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "@/types/button";
import type { TextboxLayout, InvertedBackground } from "@/providers/themeProvider/config/constants";
export type { ButtonConfig, TextboxLayout, InvertedBackground };
export type TitleSegment =
| { type: "text"; content: string }
| { type: "image"; src: string; alt?: string };
export interface TimelineCardStackItem {
id: number;
title: string;
description: string;
image: string;
imageAlt?: string;
}
export type GridVariant =
| "uniform-all-items-equal"
| "uniform-alternating-sizes"
| "uniform-alternating-sizes-inverted"
| "two-items-tall-short"
| "two-items-short-tall"
| "bento-grid"
| "bento-grid-inverted"
| "two-columns-alternating-heights"
| "asymmetric-60-wide-40-narrow"
| "three-columns-all-equal-width"
| "four-items-2x2-equal-grid"
| "one-large-right-three-stacked-left"
| "items-top-row-full-width-bottom"
| "full-width-top-items-bottom-row"
| "one-large-left-three-stacked-right"
| "timeline"
| "timeline-three-columns";
export type CardAnimationType =
| "none"
| "opacity"
| "slide-up"
| "scale-rotate"
| "blur-reveal";
export interface TextBoxProps {
title?: string;
titleSegments?: TitleSegment[];
description?: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
textboxLayout: TextboxLayout;
useInvertedBackground?: InvertedBackground;
textBoxClassName?: string;
titleClassName?: string;
titleImageWrapperClassName?: string;
titleImageClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
export interface CardStackProps extends TextBoxProps {
children: React.ReactNode;
mode?: "auto" | "buttons";
gridVariant?: GridVariant;
uniformGridCustomHeightClasses?: string;
gridRowsClassName?: string;
itemHeightClassesOverride?: string[];
animationType: CardAnimationType;
carouselThreshold?: number;
className?: string;
containerClassName?: string;
gridClassName?: string;
carouselClassName?: string;
carouselItemClassName?: string;
controlsClassName?: string;
ariaLabel?: string;
}
export interface GridLayoutProps extends TextBoxProps {
children: React.ReactNode;
itemCount: number;
gridVariant?: GridVariant;
uniformGridCustomHeightClasses?: string;
gridRowsClassName?: string;
itemHeightClassesOverride?: string[];
animationType: CardAnimationType;
className?: string;
containerClassName?: string;
gridClassName?: string;
ariaLabel: string;
}
export interface AutoCarouselProps extends TextBoxProps {
children: React.ReactNode;
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
speed?: number;
className?: string;
containerClassName?: string;
carouselClassName?: string;
itemClassName?: string;
ariaLabel: string;
showTextBox?: boolean;
dualMarquee?: boolean;
topMarqueeDirection?: "left" | "right";
bottomMarqueeDirection?: "left" | "right";
bottomCarouselClassName?: string;
marqueeGapClassName?: string;
}
export interface ButtonCarouselProps extends TextBoxProps {
children: React.ReactNode;
uniformGridCustomHeightClasses?: string;
animationType: CardAnimationType;
className?: string;
containerClassName?: string;
carouselClassName?: string;
carouselItemClassName?: string;
controlsClassName?: string;
ariaLabel: string;
}
export interface FullWidthCarouselProps extends TextBoxProps {
children: React.ReactNode;
className?: string;
containerClassName?: string;
carouselClassName?: string;
dotsClassName?: string;
ariaLabel: string;
}

View File

@@ -0,0 +1,101 @@
"use client";
import { memo } from "react";
import TextAnimation from "@/components/text/TextAnimation";
import EmailSignupForm from "@/components/form/EmailSignupForm";
import Tag from "@/components/shared/Tag";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { AnimationType } from "@/components/text/types";
import { LucideIcon } from "lucide-react";
interface ContactFormProps {
title: string;
description: string;
tag: string;
tagIcon?: LucideIcon;
useInvertedBackground: "noInvert" | "invertDefault";
inputPlaceholder?: string;
buttonText?: string;
termsText?: string;
onSubmit?: (email: string) => void;
centered?: boolean;
className?: string;
tagClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
formWrapperClassName?: string;
formClassName?: string;
inputClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
termsClassName?: string;
}
const ContactForm = ({
title,
description,
tag,
tagIcon,
useInvertedBackground,
inputPlaceholder = "Enter your email",
buttonText = "Sign Up",
termsText = "By clicking Sign Up you're confirming that you agree with our Terms and Conditions.",
onSubmit,
centered = false,
className = "",
tagClassName = "",
titleClassName = "",
descriptionClassName = "",
formWrapperClassName = "",
formClassName = "",
inputClassName = "",
buttonClassName = "",
buttonTextClassName = "",
termsClassName = "",
}: ContactFormProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
return (
<div className={cls("relative z-1 flex flex-col gap-4", centered && "items-center text-center", className)}>
<div className={cls("w-full flex flex-col gap-1", centered && "items-center")}>
<Tag text={tag} icon={tagIcon} useInvertedBackground={useInvertedBackground} className={tagClassName} />
<TextAnimation
type={theme.defaultTextAnimation as AnimationType}
text={title}
variant="trigger"
className={cls("text-4xl md:text-5xl font-medium leading-[1.175] text-balance", shouldUseLightText ? "text-background" : "text-foreground", centered && "w-full md:w-8/10", titleClassName)}
/>
<TextAnimation
type={theme.defaultTextAnimation as AnimationType}
text={description}
variant="words-trigger"
className={cls("text-base leading-[1.15] mb-1 text-balance", shouldUseLightText ? "text-background" : "text-foreground", centered && "w-full md:w-8/10", descriptionClassName)}
/>
</div>
<div className={cls("w-full flex flex-col gap-1", formWrapperClassName)}>
<EmailSignupForm
inputPlaceholder={inputPlaceholder}
buttonText={buttonText}
onSubmit={onSubmit}
className={formClassName}
inputClassName={inputClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
/>
<p className={cls("text-xs", shouldUseLightText ? "text-background/75" : "text-foreground/75", termsClassName)}>
{termsText}
</p>
</div>
</div>
);
};
ContactForm.displayName = "ContactForm";
export default memo(ContactForm);

View File

@@ -0,0 +1,77 @@
"use client";
import { memo, useState } from "react";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
interface EmailSignupFormProps {
inputPlaceholder?: string;
buttonText?: string;
onSubmit?: (email: string) => void;
className?: string;
inputClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const EmailSignupForm = memo(({
inputPlaceholder = "Enter your email",
buttonText = "Sign Up",
onSubmit,
className = "",
inputClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: EmailSignupFormProps) => {
const theme = useTheme();
const [email, setEmail] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (onSubmit) {
onSubmit(email);
}
};
const getButtonConfigProps = () => {
if (theme.defaultButtonVariant === "hover-bubble") {
return { bgClassName: "w-full md:w-auto" };
}
if (theme.defaultButtonVariant === "icon-arrow") {
return { className: "justify-between md:justify-center" };
}
return {};
};
return (
<form onSubmit={handleSubmit} className={cls("relative z-1 flex flex-col md:flex-row gap-3 md:gap-1 w-full card rounded-theme-capped md:rounded-theme p-1", className)}>
<input
type="email"
placeholder={inputPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className={cls(
"flex-1 px-4 text-base text-center md:text-left text-foreground placeholder:text-foreground/75 focus:outline-none focus:border-none truncate",
inputClassName
)}
aria-label="Email address"
/>
<Button
{...getButtonProps(
{ text: buttonText, props: getButtonConfigProps() },
0,
theme.defaultButtonVariant,
cls("w-full md:w-auto", buttonClassName),
buttonTextClassName
)}
/>
</form>
);
});
EmailSignupForm.displayName = "EmailSignupForm";
export default EmailSignupForm;

View File

@@ -0,0 +1,47 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface InputProps {
type?: string;
placeholder?: string;
value: string;
onChange: (value: string) => void;
required?: boolean;
disabled?: boolean;
ariaLabel?: string;
className?: string;
}
const Input = ({
type = "text",
placeholder = "",
value,
onChange,
required = false,
disabled = false,
ariaLabel,
className = "",
}: InputProps) => {
return (
<input
type={type}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
disabled={disabled}
aria-label={ariaLabel || placeholder}
className={cls(
"relative z-1 px-4 py-3 secondary-button rounded-theme text-base text-foreground placeholder:text-foreground/75 focus:outline-none",
disabled && "opacity-50 cursor-not-allowed",
className
)}
/>
);
};
Input.displayName = "Input";
export default memo(Input);

View File

@@ -0,0 +1,47 @@
"use client";
import { memo } from "react";
import { cls } from "@/lib/utils";
interface TextareaProps {
placeholder?: string;
value: string;
onChange: (value: string) => void;
rows?: number;
required?: boolean;
disabled?: boolean;
ariaLabel?: string;
className?: string;
}
const Textarea = ({
placeholder = "",
value,
onChange,
rows = 5,
required = false,
disabled = false,
ariaLabel,
className = "",
}: TextareaProps) => {
return (
<textarea
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
required={required}
disabled={disabled}
aria-label={ariaLabel || placeholder}
className={cls(
"relative z-1 px-4 py-3 secondary-button rounded-theme-capped text-base text-foreground placeholder:text-foreground/75 focus:outline-none resize-none",
disabled && "opacity-50 cursor-not-allowed",
className
)}
/>
);
};
Textarea.displayName = "Textarea";
export default memo(Textarea);

View File

@@ -0,0 +1,99 @@
"use client";
import React, { memo, useState } from "react";
import Input from "@/components/form/Input";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
interface FormField {
name: string;
type?: string;
placeholder?: string;
ariaLabel?: string;
required?: boolean;
}
interface WaitlistFormProps {
fields?: FormField[];
buttonText?: string;
onSubmit?: (data: Record<string, string>) => void;
className?: string;
inputsContainerClassName?: string;
inputClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const WaitlistForm = ({
fields = [
{ name: "email", type: "email", placeholder: "Your email", ariaLabel: "Email address", required: true },
{ name: "username", type: "text", placeholder: "Telegram username", ariaLabel: "Username", required: true }
],
buttonText = "Join waitlist",
onSubmit,
className = "",
inputsContainerClassName = "",
inputClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: WaitlistFormProps) => {
const theme = useTheme();
const [formData, setFormData] = useState<Record<string, string>>(
fields.reduce((acc, field) => ({ ...acc, [field.name]: "" }), {})
);
const handleInputChange = (name: string, value: string) => {
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (onSubmit) {
onSubmit(formData);
}
};
const getButtonConfigProps = () => {
if (theme.defaultButtonVariant === "hover-bubble") {
return { bgClassName: "w-full" };
}
if (theme.defaultButtonVariant === "icon-arrow") {
return { className: "justify-between" };
}
return {};
};
return (
<form onSubmit={handleSubmit} className={cls("relative z-1 flex flex-col gap-3 w-full", className)}>
<div className={cls("flex flex-col md:flex-row gap-3", inputsContainerClassName)}>
{fields.map((field) => (
<Input
key={field.name}
type={field.type || "text"}
placeholder={field.placeholder || ""}
value={formData[field.name] || ""}
onChange={(value) => handleInputChange(field.name, value)}
required={field.required !== false}
ariaLabel={field.ariaLabel || field.placeholder}
className={cls("w-full", inputClassName)}
/>
))}
</div>
<Button
{...getButtonProps(
{ text: buttonText, props: getButtonConfigProps() },
0,
theme.defaultButtonVariant,
cls("w-full", buttonClassName),
buttonTextClassName
)}
/>
</form>
);
};
WaitlistForm.displayName = "WaitlistForm";
export default memo(WaitlistForm);

View File

@@ -0,0 +1,57 @@
"use client";
import { cls } from "@/lib/utils";
interface HamburgerButtonProps {
isActive: boolean;
onClick: () => void;
className?: string;
activeBarClassName?: string;
inactiveBarClassName?: string;
ariaControls?: string;
}
const HamburgerButton = ({
isActive,
onClick,
className = "",
activeBarClassName = "bg-background",
inactiveBarClassName = "bg-foreground",
ariaControls = "navigation-menu",
}: HamburgerButtonProps) => {
return (
<button
onClick={onClick}
className={cls(
"pointer-events-auto cursor-pointer bg-transparent border-none flex justify-center items-center h-9 w-[var(--height-9)] aspect-square relative",
className
)}
aria-label={isActive ? "Close menu" : "Open menu"}
aria-expanded={isActive}
aria-controls={ariaControls}
>
<span
aria-hidden="true"
className={cls(
"transition-all duration-700 ease-[cubic-bezier(0.5,0.5,0,1)] w-[40%] h-0.25 absolute",
isActive
? `${activeBarClassName} translate-y-0 rotate-45`
: `${inactiveBarClassName} -translate-y-1 hover:translate-y-1`
)}
/>
<span
aria-hidden="true"
className={cls(
"transition-all duration-700 ease-[cubic-bezier(0.5,0.5,0,1)] w-[40%] h-0.25 absolute",
isActive
? `${activeBarClassName} translate-y-0 -rotate-45`
: `${inactiveBarClassName} translate-y-1 hover:-translate-y-1`
)}
/>
</button>
);
};
HamburgerButton.displayName = "HamburgerButton";
export default HamburgerButton;

View File

@@ -0,0 +1,47 @@
"use client";
// import Image from "next/image";
import { cls } from "@/lib/utils";
interface LogoProps {
// logoSrc?: string;
// logoAlt?: string;
brandName?: string;
// className?: string;
// imageClassName?: string;
textClassName?: string;
}
const Logo = ({
// logoSrc,
// logoAlt = "",
brandName = "Webild",
// className = "",
// imageClassName = "",
textClassName = ""
}: LogoProps) => {
// if (logoSrc) {
// return (
// <div className={cls("relative h-[var(--text-xl)] w-auto", className)}>
// <Image
// src={logoSrc}
// alt={logoAlt}
// width={100}
// height={24}
// className={cls("h-full w-auto object-contain", imageClassName)}
// unoptimized={logoSrc.startsWith('http') || logoSrc.startsWith('//')}
// />
// </div>
// );
// }
return (
<h2 className={cls("text-xl font-medium text-foreground", textClassName)}>
{brandName}
</h2>
);
};
Logo.displayName = "Logo";
export default Logo;

View File

@@ -0,0 +1,77 @@
"use client";
import Button from "../button/Button";
import ButtonTextUnderline from "../button/ButtonTextUnderline";
import Logo from "./Logo";
import { NavItem } from "@/types/navigation";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface NavbarLayoutFloatingInlineProps {
navItems: NavItem[];
// logoSrc?: string;
// logoAlt?: string;
brandName?: string;
button: ButtonConfig;
className?: string;
navItemClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const NavbarLayoutFloatingInline = ({
navItems,
// logoSrc,
// logoAlt = "",
brandName = "Webild",
button,
className = "",
navItemClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: NavbarLayoutFloatingInlineProps) => {
const theme = useTheme();
return (
<nav
role="navigation"
aria-label="Main navigation"
className="fixed z-[100] top-6 w-full transition-all duration-500 ease-in-out"
>
<div className={cls(
"w-content-width mx-auto",
"flex items-center justify-between",
"card rounded-theme",
"p-3 pl-6 h-fit relative",
className
)}>
<Logo brandName={brandName} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 hidden md:flex gap-6 items-center">
{navItems.map((item, index) => (
<ButtonTextUnderline
key={index}
text={item.name}
href={item.id}
className={cls("!text-base", navItemClassName)}
/>
))}
</div>
<Button
{...getButtonProps(
button,
0,
theme.defaultButtonVariant,
buttonClassName,
buttonTextClassName
)}
/>
</div>
</nav>
);
};
export default NavbarLayoutFloatingInline;

View File

@@ -0,0 +1,90 @@
"use client";
import ExpandingMenu from "../expandingMenu/ExpandingMenu";
import Button from "../../button/Button";
import Logo from "../Logo";
import { useScrollDetection } from "./useScrollDetection";
import { useMenuAnimation } from "./useMenuAnimation";
import { useResponsive } from "./useResponsive";
import type { NavItem } from "@/types/navigation";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface NavbarLayoutFloatingOverlayProps {
navItems: NavItem[];
// logoSrc?: string;
// logoAlt?: string;
className?: string;
brandName?: string;
button: ButtonConfig;
buttonClassName?: string;
buttonTextClassName?: string;
}
const NavbarLayoutFloatingOverlay = ({
navItems,
// logoSrc,
// logoAlt = "",
className = "",
brandName = "Webild",
button,
buttonClassName = "",
buttonTextClassName = "",
}: NavbarLayoutFloatingOverlayProps) => {
const theme = useTheme();
const isScrolled = useScrollDetection(50);
const { menuOpen, buttonZIndex, handleMenuToggle } =
useMenuAnimation();
const isMobile = useResponsive(768);
return (
<nav
role="navigation"
aria-label="Main navigation"
className="fixed z-[100] top-6 w-full transition-all duration-500 ease-in-out"
>
<div
className={cls(
"w-content-width mx-auto",
"flex items-center justify-between",
"card rounded-theme backdrop-blur-xs",
"px-6 md:pr-3",
className
)}
style={{
height: "calc(var(--vw-0_75) + var(--vw-0_75) + var(--height-9))",
}}
>
<Logo brandName={brandName} />
<div
className="flex items-center transition-all duration-500 ease-in-out"
style={{ paddingRight: "calc(var(--height-9) + var(--vw-0_75))" }}
>
{!isMobile && (
<div className="hidden md:flex">
<Button
{...getButtonProps(
button,
0,
theme.defaultButtonVariant,
cls(buttonZIndex, buttonClassName),
buttonTextClassName
)}
/>
</div>
)}
<ExpandingMenu
isOpen={menuOpen}
onToggle={handleMenuToggle}
navItems={navItems}
isScrolled={isScrolled}
/>
</div>
</div>
</nav>
);
};
export default NavbarLayoutFloatingOverlay;

View File

@@ -0,0 +1,40 @@
import { useState, useCallback, useEffect } from 'react';
export const useMenuAnimation = () => {
const [menuOpen, setMenuOpen] = useState(false);
const [buttonZIndex, setButtonZIndex] = useState('z-[100]');
const [animationTimeoutId, setAnimationTimeoutId] = useState<NodeJS.Timeout | null>(null);
const handleMenuToggle = useCallback(() => {
const isOpening = !menuOpen;
setMenuOpen(prev => !prev);
if (animationTimeoutId) {
clearTimeout(animationTimeoutId);
}
if (isOpening) {
setButtonZIndex('z-0');
} else {
const timeoutId = setTimeout(() => {
setButtonZIndex('z-[100]');
}, 800);
setAnimationTimeoutId(timeoutId);
}
}, [menuOpen, animationTimeoutId]);
useEffect(() => {
return () => {
if (animationTimeoutId) {
clearTimeout(animationTimeoutId);
}
};
}, [animationTimeoutId]);
return {
menuOpen,
buttonZIndex,
handleMenuToggle,
setMenuOpen
};
};

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
import { throttle } from '@/utils/throttle';
export const useResponsive = (breakpoint: number = 768) => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const handleResize = throttle(() => {
setIsMobile(window.innerWidth < breakpoint);
}, 150);
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [breakpoint]);
return isMobile;
};

View File

@@ -0,0 +1,18 @@
import { useState, useEffect } from 'react';
import { throttle } from '@/utils/throttle';
export const useScrollDetection = (threshold: number = 50) => {
const [isScrolled, setIsScrolled] = useState(false);
useEffect(() => {
const handleScroll = throttle(() => {
setIsScrolled(window.scrollY > threshold);
}, 100);
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [threshold]);
return isScrolled;
};

View File

@@ -0,0 +1,87 @@
"use client";
import { useState, useCallback } from "react";
import MobileMenu from "../mobileMenu/MobileMenu";
import ButtonTextUnderline from "@/components/button/ButtonTextUnderline";
import Logo from "../Logo";
import { Plus } from "lucide-react";
import { NavbarProps } from "@/types/navigation";
import { useScrollState } from "./useScrollState";
import { cls } from "@/lib/utils";
const SCROLL_THRESHOLD = 50;
const NavbarStyleApple = ({
navItems,
// logoSrc,
// logoAlt = "",
brandName = "Webild",
}: NavbarProps) => {
const isScrolled = useScrollState(SCROLL_THRESHOLD);
const [menuOpen, setMenuOpen] = useState(false);
const handleMenuToggle = useCallback(() => {
setMenuOpen((prev) => !prev);
}, []);
const handleMobileNavClick = useCallback(() => {
setMenuOpen(false);
}, []);
return (
<nav
className={cls(
"fixed z-[1000] top-0 left-0 w-full transition-all duration-500 ease-in-out",
isScrolled
? "bg-background/80 backdrop-blur-sm h-15"
: "bg-background/0 backdrop-blur-0 h-20"
)}
>
<div className="flex items-center justify-between h-full w-content-width mx-auto">
<div className="flex items-center transition-all duration-500 ease-in-out">
<Logo brandName={brandName} />
</div>
<div
className="hidden md:flex items-center gap-6 transition-all duration-500 ease-in-out"
role="navigation"
>
{navItems.map((item, index) => (
<ButtonTextUnderline
key={index}
text={item.name}
href={item.id}
className="!text-base"
/>
))}
</div>
<button
className="flex md:hidden shrink-0 h-8 aspect-square rounded-theme bg-foreground items-center justify-center cursor-pointer"
onClick={handleMenuToggle}
aria-label="Toggle menu"
aria-expanded={menuOpen}
aria-controls="mobile-menu"
>
<Plus
className={cls(
"w-1/2 h-1/2 text-background transition-transform duration-300",
menuOpen ? "rotate-45" : "rotate-0"
)}
strokeWidth={1.5}
aria-hidden="true"
/>
</button>
</div>
<MobileMenu
menuOpen={menuOpen}
onMenuToggle={handleMenuToggle}
navItems={navItems}
onNavClick={handleMobileNavClick}
/>
</nav>
);
};
export default NavbarStyleApple;

View File

@@ -0,0 +1,28 @@
import { useState, useEffect, useRef } from 'react';
export const useScrollState = (threshold: number = 50) => {
const [isScrolled, setIsScrolled] = useState(false);
const rafRef = useRef<number | undefined>(undefined);
useEffect(() => {
const handleScroll = () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
setIsScrolled(window.scrollY > threshold);
});
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [threshold]);
return isScrolled;
};

View File

@@ -0,0 +1,40 @@
/* Overlay - active state */
[data-navigation-status="active"] .centered-nav__overlay {
opacity: 0.15;
visibility: visible;
}
/* Content expand - grid animation */
.centered-nav__content {
grid-template-rows: 0fr;
}
[data-navigation-status="active"] .centered-nav__content {
grid-template-rows: 1fr;
}
/* Inner container - height hack for grid animation */
.centered-nav__inner {
height: 10000%;
}
/* Link content slide up animation */
.centered-nav__link-content {
transition: transform 0.6s cubic-bezier(0.65, 0, 0, 1);
transform: translateY(150%);
transition-delay: inherit;
}
[data-navigation-status="active"] .centered-nav__link-content {
transform: translateY(0);
}
/* Separator width animation */
.centered-nav__separator {
width: 0;
transition: width 0.6s cubic-bezier(0.65, 0, 0, 1);
}
[data-navigation-status="active"] .centered-nav__separator {
width: 100%;
}

View File

@@ -0,0 +1,155 @@
"use client";
import { useState, useEffect, useCallback, Fragment } from "react";
import Logo from "../Logo";
import HamburgerButton from "../HamburgerButton";
import Button from "@/components/button/Button";
import { NavItem } from "@/types/navigation";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useButtonClick } from "@/components/button/useButtonClick";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
import "./NavbarStyleCentered.css";
import { ArrowUpRight } from "lucide-react";
interface NavLinkProps {
item: NavItem;
index: number;
onClose: () => void;
}
const NavLink = ({ item, index, onClose }: NavLinkProps) => {
const handleClick = useButtonClick(item.id, onClose);
return (
<li
className="group m-0 p-0 list-none overflow-clip"
style={{ transitionDelay: `${index * 0.05}s` }}
>
<button
type="button"
className="centered-nav__link relative flex justify-between items-center no-underline w-full text-left bg-transparent border-none cursor-pointer"
onClick={handleClick}
>
<div className="centered-nav__link-content flex justify-between items-center gap-3 w-full">
<p className="m-0 text-xl md:text-2xl text-foreground truncate group-hover:ml-3 transition-[margin] duration-300">{item.name}</p>
<ArrowUpRight className="h-[var(--text-xl)] md:h-[var(--text-2xl)] w-auto text-foreground group-hover:rotate-45 group-hover:mr-3 transition-all duration-300" strokeWidth={1.5} />
</div>
</button>
</li>
);
};
interface NavbarStyleCenteredProps {
navItems: NavItem[];
button: ButtonConfig;
// logoSrc?: string;
// logoAlt?: string;
brandName?: string;
className?: string;
}
const NavbarStyleCentered = ({
navItems,
button,
// logoSrc,
// logoAlt = "",
brandName = "Webild",
className = "",
}: NavbarStyleCenteredProps) => {
const [isActive, setIsActive] = useState(false);
const theme = useTheme();
const getButtonConfigProps = () => {
if (theme.defaultButtonVariant === "hover-bubble") {
return { bgClassName: "w-full" };
}
if (theme.defaultButtonVariant === "icon-arrow") {
return { className: "justify-between" };
}
return {};
};
const handleToggle = useCallback(() => {
setIsActive((prev) => !prev);
}, []);
const handleClose = useCallback(() => {
setIsActive(false);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isActive) {
setIsActive(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isActive]);
return (
<nav
role="navigation"
aria-label="Main navigation"
data-navigation-status={isActive ? "active" : "not-active"}
className="fixed inset-0 z-[1000] pointer-events-none"
>
<div
className="centered-nav__overlay absolute inset-0 bg-foreground pointer-events-auto opacity-0 invisible transition-all duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]"
onClick={handleClose}
/>
<div className={cls("pointer-events-auto absolute top-6 left-1/2 -translate-x-1/2 w-content-width md:top-8 md:w-35 flex flex-col justify-start items-stretch rounded-theme-capped", className)} >
<div className="absolute! inset-0 card backdrop-blur-xs rounded-theme-capped" />
<div className="relative z-10 flex justify-between items-center py-3 px-6">
<Logo
brandName={brandName}
/>
<HamburgerButton
isActive={isActive}
onClick={handleToggle}
activeBarClassName="bg-foreground"
inactiveBarClassName="bg-foreground"
/>
</div>
<div className="centered-nav__content relative overflow-hidden rounded-b-theme-capped grid transition-[grid-template-rows] duration-600 ease-[cubic-bezier(0.625,0.05,0,1)]">
<div className="centered-nav__inner flex flex-col justify-start items-center w-full relative overflow-hidden gap-6">
<div className="w-full px-6" >
<ul className="relative w-full card p-6 rounded-theme-capped flex flex-col gap-3 justify-start items-stretch m-0 list-none">
{navItems.map((item, index) => (
<Fragment key={item.id}>
<NavLink item={item} index={index} onClose={handleClose} />
{index < navItems.length - 1 && <div className="centered-nav__separator h-px bg-accent/50" />}
</Fragment>
))}
</ul>
</div>
<div className="w-full px-6 pb-6">
<Button
{...getButtonProps(
{
...button,
onClick: () => {
button.onClick?.();
handleClose();
},
props: { ...button.props, ...getButtonConfigProps() }
},
0,
theme.defaultButtonVariant,
"w-full"
)}
/>
</div>
</div>
</div>
</div>
</nav>
);
};
NavbarStyleCentered.displayName = "NavbarStyleCentered";
export default NavbarStyleCentered;

View File

@@ -0,0 +1,58 @@
/* Tile clip-path animation */
.navbar-fullscreen__tile {
clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%);
transition: clip-path 1s cubic-bezier(.9, 0, .1, 1);
}
[data-navigation-status="active"] .navbar-fullscreen__tile {
clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
}
/* Link initial state and animation */
.navbar-fullscreen__link {
transform: translateY(100%) rotate(5deg);
transition: transform 0.75s cubic-bezier(.7, 0, .3, 1);
}
/* Link staggered delays (closing) */
.navbar-fullscreen__li:nth-child(1) .navbar-fullscreen__link { transition-delay: 0.2s; }
.navbar-fullscreen__li:nth-child(2) .navbar-fullscreen__link { transition-delay: 0.15s; }
.navbar-fullscreen__li:nth-child(3) .navbar-fullscreen__link { transition-delay: 0.1s; }
.navbar-fullscreen__li:nth-child(4) .navbar-fullscreen__link { transition-delay: 0.05s; }
.navbar-fullscreen__li:nth-child(5) .navbar-fullscreen__link { transition-delay: 0s; }
/* Link animation (Navigation Open) */
[data-navigation-status="active"] .navbar-fullscreen__link {
transform: translateY(0%) rotate(0.001deg);
}
/* Link staggered delays (opening) */
[data-navigation-status="active"] .navbar-fullscreen__li:nth-child(1) .navbar-fullscreen__link { transition-delay: 0.3s; }
[data-navigation-status="active"] .navbar-fullscreen__li:nth-child(2) .navbar-fullscreen__link { transition-delay: 0.35s; }
[data-navigation-status="active"] .navbar-fullscreen__li:nth-child(3) .navbar-fullscreen__link { transition-delay: 0.4s; }
[data-navigation-status="active"] .navbar-fullscreen__li:nth-child(4) .navbar-fullscreen__link { transition-delay: 0.45s; }
[data-navigation-status="active"] .navbar-fullscreen__li:nth-child(5) .navbar-fullscreen__link { transition-delay: 0.5s; }
/* Link text duplicate effect */
.navbar-fullscreen__link-text {
text-shadow: 0 1.1em 0;
transition: transform 0.5s cubic-bezier(.7, 0, .3, 1);
transform: translateY(0%) rotate(0.001deg);
}
.navbar-fullscreen__link:hover .navbar-fullscreen__link-text {
transform: translateY(-100%) rotate(0.001deg);
}
/* Hover dim effect on siblings */
.navbar-fullscreen__li {
transition: opacity 0.5s cubic-bezier(.7, 0, .3, 1);
}
.navbar-fullscreen__ul:has(.navbar-fullscreen__li:hover) .navbar-fullscreen__li {
opacity: 0.15;
}
.navbar-fullscreen__ul:has(.navbar-fullscreen__li:hover) .navbar-fullscreen__li:hover {
opacity: 1;
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Logo from "../Logo";
import HamburgerButton from "../HamburgerButton";
import { NavItem } from "@/types/navigation";
import { cls } from "@/lib/utils";
import { useButtonClick } from "@/components/button/useButtonClick";
import "./NavbarStyleFullscreen.css";
interface NavLinkProps {
item: NavItem;
onClose: () => void;
}
const NavLink = ({ item, onClose }: NavLinkProps) => {
const handleClick = useButtonClick(item.id, onClose);
return (
<button
type="button"
className="navbar-fullscreen__link text-background font-normal leading-[1.15] no-underline text-9xl bg-transparent border-none cursor-pointer"
onClick={handleClick}
>
<span className="navbar-fullscreen__link-text block relative">{item.name}</span>
</button>
);
};
interface NavbarStyleFullscreenProps {
navItems: NavItem[];
// logoSrc?: string;
// logoAlt?: string;
brandName?: string;
bottomLeftText?: string;
bottomRightText?: string;
topBarClassName?: string;
}
const NavbarStyleFullscreen = ({
navItems,
// logoSrc,
// logoAlt = "",
brandName = "Webild",
bottomLeftText = "Global Community",
bottomRightText = "hello@example.com",
topBarClassName = "",
}: NavbarStyleFullscreenProps) => {
const [isActive, setIsActive] = useState(false);
const handleToggle = useCallback(() => {
setIsActive((prev) => !prev);
}, []);
const handleClose = useCallback(() => {
setIsActive(false);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isActive) {
setIsActive(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isActive]);
return (
<nav
role="navigation"
aria-label="Main navigation"
data-navigation-status={isActive ? "active" : "not-active"}
className="fixed inset-0 z-[100] pointer-events-none"
>
<div className={cls(
"absolute z-1 w-content-width left-1/2 -translate-x-1/2 top-6 flex justify-between items-center",
topBarClassName
)}>
<Logo
brandName={brandName}
textClassName={`transition-colors duration-700 ease-[cubic-bezier(0.5,0.5,0,1)] ${isActive ? "text-background" : "text-foreground"}`}
/>
<HamburgerButton isActive={isActive} onClick={handleToggle} />
</div>
<div
id="navigation-menu"
className="navbar-fullscreen__tile pointer-events-auto bg-foreground backdrop-blur absolute inset-0 flex flex-col justify-center items-center"
>
<ul className="navbar-fullscreen__ul flex flex-col items-center m-0 p-0 list-none">
{navItems.map((item) => (
<li key={item.id} className="navbar-fullscreen__li flex justify-center items-center m-0 p-0 relative overflow-hidden">
<NavLink item={item} onClose={handleClose} />
</li>
))}
</ul>
<div className="absolute bottom-0 w-content-width left-1/2 -translate-x-1/2 flex justify-between items-center py-10">
<p className="text-background/50 mb-0 text-base relative">{bottomLeftText}</p>
<p className="text-background/50 mb-0 text-base relative">{bottomRightText}</p>
</div>
</div>
</nav>
);
};
NavbarStyleFullscreen.displayName = "NavbarStyleFullscreen";
export default NavbarStyleFullscreen;

View File

@@ -0,0 +1,58 @@
"use client";
import Button from "../button/Button";
import Logo from "./Logo";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
interface NavbarStyleMinimalProps {
// logoSrc?: string;
// logoAlt?: string;
brandName?: string;
button: ButtonConfig;
className?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const NavbarStyleMinimal = ({
// logoSrc,
// logoAlt = "",
brandName = "Webild",
button,
className = "",
buttonClassName = "",
buttonTextClassName = "",
}: NavbarStyleMinimalProps) => {
const theme = useTheme();
return (
<nav
role="navigation"
aria-label="Main navigation"
className="fixed z-[100] top-6 w-full transition-all duration-500 ease-in-out"
>
<div className={cls(
"w-content-width mx-auto",
"flex items-center justify-between",
className
)}>
<Logo brandName={brandName} />
<Button
{...getButtonProps(
button,
0,
theme.defaultButtonVariant,
buttonClassName,
buttonTextClassName
)}
/>
</div>
</nav>
);
};
export default NavbarStyleMinimal;

View File

@@ -0,0 +1,148 @@
"use client";
import { useCallback } from 'react';
import { useResponsiveMenuWidth } from './useResponsiveMenuWidth';
import { useButtonClick } from '@/components/button/useButtonClick';
interface NavItem {
id: string;
name: string;
}
interface ExpandingMenuProps {
isOpen: boolean;
onToggle: () => void;
navItems: NavItem[];
isScrolled?: boolean;
}
const ExpandingMenu = ({
isOpen,
onToggle,
navItems,
isScrolled = false
}: ExpandingMenuProps) => {
const { isMounted, menuWidth } = useResponsiveMenuWidth();
const handleNavClick = useCallback(() => {
onToggle();
}, [onToggle]);
return (
<div
className={`
rounded-theme-capped absolute top-3 right-3
transition-[top] duration-500 ease-in-out
${isScrolled ? '' : ''}
`}>
<div
aria-hidden="true"
className={`
primary-button
backdrop-blur-xs
transition-all duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
bg-foreground rounded-theme-capped absolute top-0 right-0
${isOpen
? 'w-full h-full'
: 'h-9 w-[var(--height-9)]'
}
`}
/>
<div className={`
relative p-6 flex flex-col gap-6
transition-all duration-500 ease-[cubic-bezier(0.5,0.5,0,1)]
pointer-events-auto origin-[100%_0]
${isOpen
? 'scale-100 opacity-100 visible'
: 'scale-[0.15] opacity-0 invisible'
}
`}
style={{
transition: 'all 0.5s cubic-bezier(0.5, 0.5, 0, 1), transform 0.7s cubic-bezier(0.5, 0.5, 0, 1)',
width: isMounted ? menuWidth : 'var(--width-20)'
}}
>
<p className="text-xl text-background" aria-hidden="true">Menu</p>
<ul
role="menu"
className="relative list-none flex flex-col gap-3 m-0 p-0"
>
{navItems.map((item) => {
const MenuButton = () => {
const handleClick = useButtonClick(item.id, handleNavClick);
return (
<button
aria-label={`Navigate to ${item.name}`}
className={`
text-background flex justify-between items-center
no-underline bg-none border-none cursor-pointer w-full
font-inherit group
`}
onClick={handleClick}
>
<span className="text-base">
{item.name}
</span>
<div className="bg-current rounded-theme-capped shrink-0 h-2 aspect-square" />
</button>
);
};
return (
<li
key={item.id}
role="menuitem"
className="m-0 p-0 list-none"
>
<MenuButton />
</li>
);
})}
</ul>
</div>
<button
aria-label={isOpen ? 'Close menu' : 'Open menu'}
aria-expanded={isOpen}
aria-controls="navigation-menu"
className={`
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
pointer-events-auto cursor-pointer rounded-theme-capped
flex justify-center items-center
h-9 w-[var(--height-9)] aspect-square absolute top-0 right-0
bg-transparent border-none
${isOpen
? '-translate-x-3 translate-y-3'
: 'translate-x-0 translate-y-0'
}
`}
onClick={onToggle}
>
<span
aria-hidden="true"
className={`
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
bg-background w-[40%] h-0.25 absolute
${isOpen
? 'translate-y-0 rotate-45'
: '-translate-y-1 hover:translate-y-1'
}
`} />
<span
aria-hidden="true"
className={`
transition-transform duration-700 ease-[cubic-bezier(0.5,0.5,0,1)]
bg-background w-[40%] h-0.25 absolute
${isOpen
? 'translate-y-0 -rotate-45'
: 'translate-y-1 hover:-translate-y-1'
}
`} />
</button>
</div>
);
};
export default ExpandingMenu;

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
export const useResponsiveMenuWidth = () => {
const [isMounted, setIsMounted] = useState(false);
const [menuWidth, setMenuWidth] = useState('var(--width-20)');
useEffect(() => {
setIsMounted(true);
const handleResize = () => {
setMenuWidth(
window.innerWidth >= 768
? 'var(--width-20)'
: 'calc(var(--width-80) - var(--vw-0_75) * 2)'
);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return { isMounted, menuWidth };
};

View File

@@ -0,0 +1,79 @@
"use client";
import { Fragment } from 'react';
import { ArrowRight, Plus } from 'lucide-react';
import { NavItem } from '@/types/navigation';
import { useMenuAnimation } from './useMenuAnimation';
import { useButtonClick } from '@/components/button/useButtonClick';
interface MobileMenuProps {
menuOpen: boolean;
onMenuToggle: () => void;
navItems: NavItem[];
onNavClick: (id: string) => void;
children?: React.ReactNode;
}
const MobileMenu = ({
menuOpen,
onMenuToggle,
navItems,
onNavClick,
children
}: MobileMenuProps) => {
const menuRef = useMenuAnimation(menuOpen);
return (
<div
id="mobile-menu"
className="md:hidden z-10 fixed top-3 left-1/2 -translate-x-1/2 h-fit rounded-theme-capped card p-6 flex flex-col gap-6 opacity-0"
style={{ width: 'calc(100vw - var(--vw-0_75) * 2)' }}
ref={menuRef}
role="navigation"
aria-label="Mobile navigation menu"
>
<div className="w-full flex justify-between items-center">
<p className="text-xl text-foreground">Menu</p>
<button
className="shrink-0 h-8 aspect-square rounded-theme bg-foreground flex items-center justify-center cursor-pointer"
onClick={onMenuToggle}
aria-label="Close menu"
>
<Plus className="w-1/2 h-1/2 text-background rotate-45" strokeWidth={1.5} aria-hidden="true" />
</button>
</div>
<div className="flex flex-col gap-4">
{navItems.map((item, index) => {
const NavButton = () => {
const handleClick = useButtonClick(item.id, () => onNavClick(item.id));
return (
<button
className="w-full h-fit flex justify-between items-center cursor-pointer"
onClick={handleClick}
aria-label={`Navigate to ${item.name}`}
>
<p className="text-base font-medium">{item.name}</p>
<ArrowRight strokeWidth={1.5} className="h-[var(--text-base)] w-auto text-foreground" aria-hidden="true" />
</button>
);
};
return (
<Fragment key={index}>
<NavButton />
{index < navItems.length - 1 && (
<div className="w-full h-px bg-gradient-to-r from-transparent via-foreground/20 to-transparent" />
)}
</Fragment>
);
})}
</div>
{children && (
<div className="flex gap-3 items-center">{children}</div>
)}
</div>
);
};
export default MobileMenu;

View File

@@ -0,0 +1,40 @@
import { useRef, useEffect } from 'react';
import { gsap } from 'gsap';
export const useMenuAnimation = (menuOpen: boolean) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (menuRef.current) {
if (menuOpen) {
gsap.to(menuRef.current, {
y: "0%",
opacity: 1,
pointerEvents: "auto",
duration: 0.8,
ease: "power3.out"
});
} else {
gsap.to(menuRef.current, {
y: "-135%",
opacity: 1,
pointerEvents: "none",
duration: 0.8,
ease: "power3.inOut"
});
}
}
}, [menuOpen]);
useEffect(() => {
if (menuRef.current) {
gsap.set(menuRef.current, {
y: "-135%",
opacity: 1,
pointerEvents: "none"
});
}
}, []);
return menuRef;
};

View File

@@ -0,0 +1,45 @@
"use client";
import { useState, useEffect, useRef, ReactNode } from "react";
interface AnimationContainerProps {
children: ReactNode;
className?: string;
animationDuration?: number;
animationType?: "full" | "fade";
style?: React.CSSProperties;
}
const AnimationContainer = ({
children,
className = "w-full h-fit flex flex-col gap-6",
animationDuration = 800,
animationType = "full",
style,
}: AnimationContainerProps) => {
const animationClass =
animationType === "full"
? "animation-container"
: "animation-container-fade";
const [activeClass, setActiveClass] = useState(animationClass);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const timer = setTimeout(() => {
setActiveClass("");
}, animationDuration);
return () => clearTimeout(timer);
}, [animationDuration]);
return (
<div
ref={containerRef}
className={`${className} ${activeClass}`.trim()}
style={style}
>
{children}
</div>
);
};
export default AnimationContainer;

View File

@@ -0,0 +1,101 @@
"use client";
import TextAnimation from "@/components/text/TextAnimation";
import { cls, shouldUseInvertedText } from "@/lib/utils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { LucideIcon } from "lucide-react";
interface Metric {
icon: LucideIcon;
label: string;
value: string;
}
interface AboutMetricProps {
title: string;
metrics: Metric[];
useInvertedBackground: "noInvert" | "invertDefault";
ariaLabel?: string;
className?: string;
containerClassName?: string;
titleClassName?: string;
metricsContainerClassName?: string;
metricCardClassName?: string;
metricIconClassName?: string;
metricLabelClassName?: string;
metricValueClassName?: string;
}
const AboutMetric = ({
title,
metrics,
useInvertedBackground,
ariaLabel = "About metrics section",
className = "",
containerClassName = "",
titleClassName = "",
metricsContainerClassName = "",
metricCardClassName = "",
metricIconClassName = "",
metricLabelClassName = "",
metricValueClassName = "",
}: AboutMetricProps) => {
const theme = useTheme();
const shouldUseLightText = shouldUseInvertedText(useInvertedBackground, theme.cardStyle);
const gridColsMap = {
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const gridCols = gridColsMap[metrics.length as keyof typeof gridColsMap] || "md:grid-cols-4";
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20 w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("w-content-width mx-auto flex flex-col gap-8", containerClassName)}>
<TextAnimation
type={theme.defaultTextAnimation}
text={title}
variant="words-trigger"
className={cls("text-2xl md:text-5xl font-medium leading-[1.175]", useInvertedBackground === "invertDefault" && "text-background", titleClassName)}
/>
<div className={cls("grid grid-cols-1 gap-6", gridCols, metricsContainerClassName)}>
{metrics.map((metric, index) => {
const Icon = metric.icon;
return (
<div
key={index}
className={cls(
"h-fit card rounded-theme-capped px-6 py-8 md:py-10 flex flex-col items-center justify-center gap-3",
metricCardClassName
)}
>
<div className="relative z-1 w-full flex items-center justify-center gap-2">
<div className={cls("h-8 primary-button aspect-square rounded-theme flex items-center justify-center", metricIconClassName)}>
<Icon className="h-4/10 text-background" strokeWidth={1.5} />
</div>
<h3 className={cls("text-xl truncate", shouldUseLightText && "text-background", metricLabelClassName)}>
{metric.label}
</h3>
</div>
<div className="relative z-1 w-full flex items-center justify-center">
<h4 className={cls("text-6xl font-medium truncate", shouldUseLightText && "text-background", metricValueClassName)}>
{metric.value}
</h4>
</div>
</div>
);
})}
</div>
</div>
</section>
);
};
AboutMetric.displayName = "AboutMetric";
export default AboutMetric;

View File

@@ -0,0 +1,121 @@
"use client";
import { Fragment } from "react";
import Image from "next/image";
import Button from "@/components/button/Button";
import { cls } from "@/lib/utils";
import { getButtonProps } from "@/lib/buttonUtils";
import { useTheme } from "@/providers/themeProvider/ThemeProvider";
import type { ButtonConfig } from "@/types/button";
import type { InvertedBackground } from "@/providers/themeProvider/config/constants";
type HeadingSegment =
| { type: "text"; content: string }
| { type: "image"; src: string; alt?: string };
interface InlineImageSplitTextAboutProps {
heading: HeadingSegment[];
buttons?: ButtonConfig[];
useInvertedBackground: InvertedBackground;
ariaLabel?: string;
className?: string;
containerClassName?: string;
headingClassName?: string;
imageWrapperClassName?: string;
imageClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
}
const InlineImageSplitTextAbout = ({
heading,
buttons,
useInvertedBackground,
ariaLabel = "About section",
className = "",
containerClassName = "",
headingClassName = "",
imageWrapperClassName = "",
imageClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
}: InlineImageSplitTextAboutProps) => {
const theme = useTheme();
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20 w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div
className={cls(
"w-content-width mx-auto flex flex-col gap-6 items-center",
containerClassName
)}
>
<h2
className={cls(
"text-4xl md:text-5xl font-medium text-center leading-[1.15] text-balance",
useInvertedBackground === "invertDefault" && "text-background",
headingClassName
)}
>
{heading.map((segment, index) => {
const imageIndex = heading
.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",
imageWrapperClassName
)}
>
<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",
imageClassName
)}
unoptimized={segment.src.startsWith("http") || segment.src.startsWith("//")}
aria-hidden={!segment.alt || segment.alt === ""}
/>
</div>
</span>
);
return (
<Fragment key={index}>
{index > 0 && " "}
{element}
</Fragment>
);
})}
</h2>
{buttons && buttons.length > 0 && (
<div className={cls("flex gap-4", buttonContainerClassName)}>
{buttons.slice(0, 2).map((button, index) => (
<Button key={index} {...getButtonProps(button, index, theme.defaultButtonVariant, cls("px-8", buttonClassName), cls("text-base", buttonTextClassName))} />
))}
</div>
)}
</div>
</section>
);
};
InlineImageSplitTextAbout.displayName = "InlineImageSplitTextAbout";
export default InlineImageSplitTextAbout;

View File

@@ -0,0 +1,98 @@
"use client";
import MediaContent from "@/components/shared/MediaContent";
import TextBox from "@/components/Textbox";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import type { ButtonConfig } from "@/types/button";
interface MediaAboutProps {
title: string;
description: string;
tag?: string;
tagIcon?: LucideIcon;
buttons?: ButtonConfig[];
imageSrc?: string;
videoSrc?: string;
imageAlt?: string;
videoAriaLabel?: string;
useInvertedBackground: "noInvert" | "invertDefault";
ariaLabel?: string;
className?: string;
textBoxClassName?: string;
titleClassName?: string;
descriptionClassName?: string;
tagClassName?: string;
buttonContainerClassName?: string;
buttonClassName?: string;
buttonTextClassName?: string;
mediaWrapperClassName?: string;
mediaClassName?: string;
}
const MediaAbout = ({
title,
description,
tag,
tagIcon,
buttons,
imageSrc,
videoSrc,
imageAlt,
videoAriaLabel,
useInvertedBackground,
ariaLabel = "About section",
className = "",
textBoxClassName = "",
titleClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
mediaWrapperClassName = "",
mediaClassName = "",
}: MediaAboutProps) => {
return (
<section
aria-label={ariaLabel}
className={cls("relative py-20 w-full", useInvertedBackground === "invertDefault" && "bg-foreground", className)}
>
<div className={cls("relative w-content-width mx-auto aspect-square md:aspect-video rounded-theme-capped overflow-hidden", mediaWrapperClassName)}>
<MediaContent
imageSrc={imageSrc}
videoSrc={videoSrc}
imageAlt={imageAlt}
videoAriaLabel={videoAriaLabel}
imageClassName={cls("w-full h-full object-cover", mediaClassName)}
/>
<div className="absolute inset-0 z-0 bg-background/40 backdrop-blur-xs pointer-events-none select-none rounded-theme-capped" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="relative z-10 flex items-center justify-center h-full w-content-width md:w-45 mx-auto">
<TextBox
title={title}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
className={cls("flex flex-col gap-3 md:gap-1", textBoxClassName)}
titleClassName={cls("text-6xl font-medium text-balance", titleClassName)}
descriptionClassName={cls("text-base md:text-lg leading-[1.2]", descriptionClassName)}
tagClassName={cls("px-3 py-1 text-sm rounded-theme card text-foreground inline-flex items-center gap-2 mb-3", tagClassName)}
buttonContainerClassName={cls("flex gap-4 mt-3", buttonContainerClassName)}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
center={true}
/>
</div>
</div>
</div>
</section>
);
};
MediaAbout.displayName = "MediaAbout";
export default MediaAbout;

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