Initial commit

This commit is contained in:
kudinDmitriyUp
2026-06-26 20:05:08 +00:00
commit 1f8edaa525
352 changed files with 43918 additions and 0 deletions

13
src/App.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import HomePage from './pages/HomePage';
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
</Route>
</Routes>
);
}

96
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,96 @@
import FooterBrand from '@/components/sections/footer/FooterBrand';
import NavbarCentered from '@/components/ui/NavbarCentered';
import SectionErrorBoundary from "@/components/ui/SectionErrorBoundary";
import SiteBackgroundSlot from "@/components/ui/SiteBackgroundSlot";
import { Outlet } from 'react-router-dom';
import { StyleProvider } from "@/components/ui/StyleProvider";
export default function Layout() {
const navItems = [
{
"name": "Home",
"href": "#hero"
},
{
"name": "Rooms",
"href": "#rooms"
},
{
"name": "Amenities",
"href": "#features"
},
{
"name": "Contact",
"href": "#contact"
},
{
"name": "Metrics",
"href": "#metrics"
},
{
"name": "Testimonials",
"href": "#testimonials"
},
{
"name": "Faq",
"href": "#faq"
}
];
return (
<StyleProvider buttonVariant="elastic" siteBackground="gridDots" heroBackground="gradientBars">
<SiteBackgroundSlot />
<SectionErrorBoundary name="navbar">
<NavbarCentered
logo="Grand Plaza"
ctaButton={{
text: "Book Now",
href: "#contact",
}}
navItems={navItems} />
</SectionErrorBoundary>
<main className="flex-grow">
<Outlet />
</main>
<SectionErrorBoundary name="footer">
<FooterBrand
brand="Grand Plaza Hotel"
columns={[
{
items: [
{
label: "About Us",
href: "#",
},
{
label: "Our Rooms",
href: "#",
},
{
label: "Careers",
href: "#",
},
],
},
{
items: [
{
label: "Privacy Policy",
href: "#",
},
{
label: "Terms of Service",
href: "#",
},
{
label: "Help Center",
href: "#",
},
],
},
]}
/>
</SectionErrorBoundary>
</StyleProvider>
);
}

View File

@@ -0,0 +1,126 @@
import { useEffect } from "react";
import { X, Plus, Minus, Trash2 } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import Button from "@/components/ui/Button";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type CartItem = {
id: string;
name: string;
price: string;
quantity: number;
imageSrc: string;
};
type ProductCartProps = {
isOpen: boolean;
onClose: () => void;
items: CartItem[];
total: string;
onQuantityChange?: (id: string, quantity: number) => void;
onRemove?: (id: string) => void;
onCheckout?: () => void;
};
const ProductCart = ({ isOpen, onClose, items, total, onQuantityChange, onRemove, onCheckout }: ProductCartProps) => {
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
document.body.style.overflow = isOpen ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [isOpen]);
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-1001">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="absolute inset-0 bg-foreground/50"
onClick={onClose}
/>
<motion.aside
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ duration: 0.3, ease: [0.25, 0.1, 0.25, 1] }}
className="card absolute right-0 top-0 flex flex-col p-5 h-screen w-screen md:w-96"
>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Cart ({items.length})</h2>
<button onClick={onClose} className="card flex items-center justify-center size-8 rounded cursor-pointer" aria-label="Close cart">
<X className="size-4 text-foreground" strokeWidth={1.5} />
</button>
</div>
<div className="mt-5 h-px w-full bg-foreground/5" />
<div className="flex-1 py-5 min-h-0 overflow-y-auto">
{items.length === 0 ? (
<p className="py-20 text-center text-sm text-foreground/50">Your cart is empty</p>
) : (
<div className="flex flex-col gap-5">
{items.map((item) => (
<div key={item.id} className="flex gap-4">
<div className="shrink-0 size-24 overflow-hidden rounded">
<ImageOrVideo imageSrc={item.imageSrc} className="size-full object-cover" />
</div>
<div className="flex flex-1 flex-col justify-between min-w-0">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-semibold text-foreground truncate">{item.name}</h3>
<p className="shrink-0 text-base font-semibold text-foreground">{item.price}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => item.quantity > 1 && onQuantityChange?.(item.id, item.quantity - 1)}
className="card flex items-center justify-center size-8 rounded cursor-pointer"
>
<Minus className="size-4 text-foreground" strokeWidth={1.5} />
</button>
<span className="min-w-5 text-center text-sm font-semibold text-foreground">{item.quantity}</span>
<button
onClick={() => onQuantityChange?.(item.id, item.quantity + 1)}
className="card flex items-center justify-center size-8 rounded cursor-pointer"
>
<Plus className="size-4 text-foreground" strokeWidth={1.5} />
</button>
<button
onClick={() => onRemove?.(item.id)}
className="card flex items-center justify-center ml-auto size-8 rounded cursor-pointer"
>
<Trash2 className="size-4 text-foreground" strokeWidth={1.5} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex flex-col gap-5">
<div className="h-px w-full bg-foreground/5" />
<div className="flex items-center justify-between">
<span className="text-base font-semibold text-foreground">Total</span>
<span className="text-base font-semibold text-foreground">{total}</span>
</div>
<Button text="Checkout" onClick={onCheckout} variant="primary" className="w-full" />
</div>
</motion.aside>
</div>
)}
</AnimatePresence>
);
};
export default ProductCart;
export type { CartItem };

View File

@@ -0,0 +1,142 @@
import { Star, ArrowUpRight, Loader2 } from "lucide-react";
import { cls } from "@/lib/utils";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import useProducts from "@/hooks/useProducts";
import type { ProductVariant } from "./ProductDetailCard";
type CatalogProduct = {
id: string;
name: string;
price: string;
imageSrc: string;
category?: string;
rating?: number;
reviewCount?: string;
onClick?: () => void;
};
type ProductCatalogProps = {
products?: CatalogProduct[];
searchValue?: string;
onSearchChange?: (value: string) => void;
filters?: ProductVariant[];
};
const ProductCatalog = ({ products: productsProp, searchValue = "", onSearchChange, filters }: ProductCatalogProps) => {
const { products: fetchedProducts, isLoading } = useProducts();
const products: CatalogProduct[] = productsProp && productsProp.length > 0
? productsProp
: fetchedProducts.map((p) => ({
id: p.id,
name: p.name,
price: p.price,
imageSrc: p.imageSrc,
category: p.brand,
rating: p.rating,
reviewCount: p.reviewCount,
onClick: p.onProductClick,
}));
if (isLoading && (!productsProp || productsProp.length === 0)) {
return (
<section className="mx-auto py-20 w-content-width">
<div className="flex justify-center">
<Loader2 className="size-8 text-foreground animate-spin" strokeWidth={1.5} />
</div>
</section>
);
}
return (
<section className="mx-auto py-20 w-content-width">
{(onSearchChange || (filters && filters.length > 0)) && (
<div className="flex flex-col gap-5 mb-5 md:flex-row md:items-end">
{onSearchChange && (
<div className="flex flex-1 flex-col gap-2 min-w-32">
<label className="text-sm font-semibold text-foreground">Search</label>
<input
type="text"
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search products..."
className="card px-4 h-9 w-full md:w-80 text-base text-foreground bg-transparent rounded focus:outline-none"
/>
</div>
)}
{filters && filters.length > 0 && (
<div className="flex gap-5 items-end">
{filters.map((filter) => (
<div key={filter.label} className="flex flex-col gap-2 min-w-32">
<label className="text-sm font-semibold text-foreground">{filter.label}</label>
<div className="secondary-button flex items-center px-3 h-9 rounded">
<select
value={filter.selected}
onChange={(e) => filter.onChange(e.target.value)}
className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none"
>
{filter.options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
))}
</div>
)}
</div>
)}
{products.length === 0 ? (
<p className="py-20 text-center text-sm text-foreground/50">No products found</p>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{products.map((product) => (
<button
key={product.id}
onClick={product.onClick}
className="card group h-full flex flex-col gap-3 p-3 text-left rounded cursor-pointer"
>
<div className="relative aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={product.imageSrc} className="size-full object-cover transition-transform duration-500 group-hover:scale-105" />
<div className="absolute inset-0 flex items-center justify-center transition-all duration-300 group-hover:bg-background/20 group-hover:backdrop-blur-xs">
<div className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 scale-75 transition-all duration-300 group-hover:opacity-100 group-hover:scale-100">
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
{product.category && (
<span className="secondary-button w-fit px-2 py-0.5 text-sm text-secondary-cta-text rounded">{product.category}</span>
)}
<div className="flex flex-col gap-1">
<h3 className="text-xl font-semibold text-foreground truncate">{product.name}</h3>
{product.rating && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={cls("size-4 text-accent", i < Math.floor(product.rating || 0) ? "fill-accent" : "opacity-20")}
strokeWidth={1.5}
/>
))}
</div>
{product.reviewCount && (
<span className="text-sm text-foreground">({product.reviewCount})</span>
)}
</div>
)}
</div>
<p className="text-2xl font-semibold text-foreground">{product.price}</p>
</div>
</button>
))}
</div>
)}
</section>
);
};
export default ProductCatalog;
export type { CatalogProduct };

View File

@@ -0,0 +1,137 @@
import { useState } from "react";
import { Star } from "lucide-react";
import { cls } from "@/lib/utils";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import Button from "@/components/ui/Button";
import Transition from "@/components/ui/Transition";
type ProductVariant = {
label: string;
options: string[];
selected: string;
onChange: (value: string) => void;
};
type ProductDetailCardProps = {
name: string;
price: string;
salePrice?: string;
images: string[];
description?: string;
rating?: number;
ribbon?: string;
inventoryStatus?: "in-stock" | "out-of-stock";
inventoryQuantity?: number;
sku?: string;
variants?: ProductVariant[];
quantity?: ProductVariant;
onAddToCart?: () => void;
onBuyNow?: () => void;
};
const ProductDetailCard = ({ name, price, salePrice, images, description, rating = 0, ribbon, inventoryStatus, inventoryQuantity, sku, variants, quantity, onAddToCart, onBuyNow }: ProductDetailCardProps) => {
const [selectedImage, setSelectedImage] = useState(0);
return (
<section className="mx-auto py-20 w-content-width">
<div className="flex flex-col gap-5 md:flex-row">
<div className="relative md:w-1/2">
<Transition key={selectedImage} className="card aspect-square overflow-hidden rounded" transitionType="fade" whileInView={false}>
<ImageOrVideo imageSrc={images[selectedImage]} className="size-full object-cover" />
</Transition>
{images.length > 1 && (
<div className="absolute right-3 top-0 bottom-0 flex flex-col gap-3 py-3 overflow-y-auto mask-fade-y">
{images.map((src, i) => (
<button
key={i}
onClick={() => setSelectedImage(i)}
className="group card relative shrink-0 size-16 overflow-hidden rounded cursor-pointer"
>
<ImageOrVideo imageSrc={src} className="size-full object-cover transition-transform duration-300 group-hover:scale-110" />
<div className={cls(
"absolute top-1 right-1 primary-button size-3 rounded-full transition-transform duration-300",
selectedImage === i ? "scale-100" : "scale-0 group-hover:scale-100"
)} />
</button>
))}
</div>
)}
</div>
<div className="card flex flex-col gap-5 p-5 md:w-1/2 rounded">
<div className="flex items-start justify-between gap-5">
<h2 className="flex-1 text-2xl font-semibold text-foreground md:text-3xl">{name}</h2>
{ribbon && <span className="secondary-button shrink-0 px-3 py-1 text-sm font-semibold rounded text-secondary-cta-text">{ribbon}</span>}
</div>
<div className="h-px w-full bg-foreground/5" />
<div className="flex items-center justify-between">
<p className="text-xl font-semibold text-foreground md:text-2xl">
{salePrice ? (
<>
<span className="text-foreground/75 line-through mr-1">{price}</span>
<span>{salePrice}</span>
</>
) : (
price
)}
</p>
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className={cls("size-5 text-accent", i < Math.floor(rating) ? "fill-accent" : "opacity-20")} strokeWidth={1.5} />
))}
</div>
</div>
{(inventoryStatus || inventoryQuantity || sku) && (
<div className="flex flex-wrap gap-3 text-sm">
{inventoryStatus && (
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">
{inventoryStatus === "in-stock" ? "In Stock" : "Out of Stock"}
</span>
)}
{inventoryQuantity && (
<span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">{inventoryQuantity} available</span>
)}
{sku && <span className="secondary-button px-2 py-1 rounded text-secondary-cta-text">SKU: {sku}</span>}
</div>
)}
{description && <p className="text-sm text-foreground/75 md:text-base">{description}</p>}
{variants && variants.length > 0 && (
<div className="flex flex-wrap gap-5">
{variants.map((variant) => (
<div key={variant.label} className="flex flex-1 flex-col gap-2 min-w-32">
<label className="text-sm font-semibold text-foreground">{variant.label}</label>
<div className="secondary-button flex items-center px-3 h-9 rounded">
<select value={variant.selected} onChange={(e) => variant.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
{variant.options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
))}
</div>
)}
{quantity && (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-foreground">{quantity.label}</label>
<div className="secondary-button flex items-center px-3 h-9 w-24 rounded">
<select value={quantity.selected} onChange={(e) => quantity.onChange(e.target.value)} className="w-full text-base text-secondary-cta-text bg-transparent cursor-pointer focus:outline-none">
{quantity.options.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
)}
<div className="flex flex-col mt-auto gap-3 pt-5">
<Button text="Add To Cart" onClick={onAddToCart} variant="primary" className="w-full" />
<Button text="Buy Now" onClick={onBuyNow} variant="secondary" className="w-full" />
</div>
</div>
</div>
</section>
);
};
export default ProductDetailCard;
export type { ProductVariant };

View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
const DefaultsLayout = () => {
useEffect(() => {
document.body.classList.add('use-defaults');
return () => document.body.classList.remove('use-defaults');
}, []);
return <Outlet />;
};
export default DefaultsLayout;

View File

@@ -0,0 +1,178 @@
import { useRef, useEffect } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Button from "@/components/ui/Button";
gsap.registerPlugin(ScrollTrigger);
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import TextAnimation from "@/components/ui/TextAnimation";
type TrailMedia = { imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never };
type AboutCursorTrailProps = {
tag: string;
title: string;
media: TrailMedia[];
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const AboutCursorTrail = ({
tag,
title,
media,
primaryButton,
secondaryButton,
textAnimation,
}: AboutCursorTrailProps) => {
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const mm = gsap.matchMedia();
mm.add("(min-width: 992px)", () => {
const images = Array.from(wrapper.querySelectorAll<HTMLElement>('[data-trail="item"]'));
if (!images.length) return;
let index = 0;
let lastX = 0;
let lastY = 0;
let fadeTimeout: ReturnType<typeof setTimeout>;
let isActive = false;
const threshold = window.innerWidth / 15;
function onMove(e: MouseEvent) {
if (!isActive) return;
const rect = wrapper!.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (Math.hypot(x - lastX, y - lastY) > threshold) {
if (index >= images.length) {
gsap.to(images[(index - images.length) % images.length], { autoAlpha: 0, scale: 0.2, duration: 0.8, ease: "expo.out" });
}
const img = images[index % images.length];
gsap.set(img, { x: x - img.offsetWidth / 2, y: y - img.offsetHeight / 2, zIndex: index, force3D: true });
gsap.fromTo(img, { autoAlpha: 0, scale: 0.8 }, { autoAlpha: 1, scale: 1, duration: 0.2, overwrite: true });
lastX = x;
lastY = y;
index++;
clearTimeout(fadeTimeout);
fadeTimeout = setTimeout(() => images.forEach(img =>
gsap.to(img, { autoAlpha: 0, scale: 0.2, duration: 0.8, ease: "expo.out" })
), 350);
}
}
function startTrail() {
if (isActive || !wrapper) return;
isActive = true;
wrapper.addEventListener("mousemove", onMove);
}
function stopTrail() {
if (!isActive || !wrapper) return;
isActive = false;
wrapper.removeEventListener("mousemove", onMove);
clearTimeout(fadeTimeout);
images.forEach(img => {
gsap.killTweensOf(img);
gsap.set(img, { autoAlpha: 0, scale: 1 });
});
}
const trigger = ScrollTrigger.create({
trigger: wrapper,
start: "top bottom",
end: "bottom top",
onEnter: startTrail,
onEnterBack: startTrail,
onLeave: stopTrail,
onLeaveBack: stopTrail,
});
return () => {
stopTrail();
trigger.kill();
};
});
mm.add("(max-width: 991px)", () => {
const images = Array.from(wrapper.querySelectorAll<HTMLElement>('[data-trail="item"]'));
if (!images.length) return;
let index = 0;
let lastScrollY = window.scrollY;
let fadeTimeout: ReturnType<typeof setTimeout>;
const threshold = 100;
function onScroll() {
const rect = wrapper!.getBoundingClientRect();
if (rect.top > window.innerHeight || rect.bottom < 0) return;
const scrollDelta = Math.abs(window.scrollY - lastScrollY);
if (scrollDelta > threshold) {
if (index >= images.length) {
gsap.to(images[(index - images.length) % images.length], { autoAlpha: 0, scale: 0.2, duration: 0.8, ease: "expo.out" });
}
const img = images[index % images.length];
const x = Math.random() * (rect.width - img.offsetWidth);
const y = Math.random() * (rect.height - img.offsetHeight);
gsap.set(img, { x, y, zIndex: index, force3D: true });
gsap.fromTo(img, { autoAlpha: 0, scale: 0.8 }, { autoAlpha: 1, scale: 1, duration: 0.2, overwrite: true });
lastScrollY = window.scrollY;
index++;
clearTimeout(fadeTimeout);
fadeTimeout = setTimeout(() => images.forEach(img =>
gsap.to(img, { autoAlpha: 0, scale: 0.2, duration: 0.8, ease: "expo.out" })
), 500);
}
}
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
clearTimeout(fadeTimeout);
};
});
return () => mm.revert();
}, []);
return (
<section aria-label="About section" className="relative py-60">
<div ref={wrapperRef} data-trail="wrapper" className="w-full h-full absolute inset-0 z-0">
{media.map((item, i) => (
<div key={i} data-trail="item" className="invisible rounded w-[15em] h-[20em] absolute overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="object-cover w-full h-full" />
</div>
))}
</div>
<div className="relative z-10 flex flex-col items-center gap-2 w-content-width mx-auto text-center pointer-events-none">
<h3 className="text-3xl md:text-5xl font-medium">{tag}</h3>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-7xl md:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3 pointer-events-auto">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" />
</div>
</div>
</section>
);
};
export default AboutCursorTrail;

View File

@@ -0,0 +1,92 @@
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { resolveIcon } from "@/utils/resolve-icon";
type AboutFeaturesSplitProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: { icon: string | LucideIcon; title: string; description: string }[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutFeaturesSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
imageSrc,
videoSrc,
textAnimation,
}: AboutFeaturesSplitProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10 mx-auto w-content-width">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col md:flex-row md:items-stretch gap-5">
<div className="flex flex-col justify-center gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 w-full md:w-4/10 2xl:w-35/100 card rounded">
{items.map((item, index) => {
const ItemIcon = resolveIcon(item.icon);
return (
<div key={item.title}>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-center shrink-0 mb-1 size-10 primary-button rounded">
<ItemIcon className="h-2/5 w-2/5 text-primary-cta-text" strokeWidth={1.5} />
</div>
<h3 className="text-2xl font-semibold">{item.title}</h3>
<p className="text-base leading-snug">{item.description}</p>
</div>
{index < items.length - 1 && (
<div className="mt-4 xl:mt-5 2xl:mt-6 border-b border-accent/40" />
)}
</div>
);
})}
</div>
<div className="p-px w-full md:w-6/10 2xl:w-7/10 h-80 md:h-auto card rounded overflow-hidden">
<div className="relative size-full">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="absolute inset-0 object-cover rounded" />
</div>
</div>
</div>
</div>
</section>
);
};
export default AboutFeaturesSplit;

View File

@@ -0,0 +1,74 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutMediaOverlayProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutMediaOverlay = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
textAnimation,
}: AboutMediaOverlayProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="relative flex items-center justify-center py-8 md:py-12 mx-auto w-content-width rounded overflow-hidden">
<div className="absolute inset-0">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
<div className="absolute inset-0 bg-foreground/40 backdrop-blur-xs pointer-events-none select-none" />
</div>
<div className="relative z-10 flex items-center justify-center px-5 py-10 mx-auto min-h-100 md:min-h-120 md:w-1/2 w-content-width">
<div className="flex flex-col items-center gap-2 text-center">
<motion.span
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-15%" }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="mb-1 px-3 py-1 text-sm card rounded"
>
{tag}
</motion.span>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance text-primary-cta-text"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl leading-snug text-balance text-primary-cta-text"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
</div>
</div>
</section>
);
};
export default AboutMediaOverlay;

View File

@@ -0,0 +1,88 @@
import { useRef } from "react";
import { motion, useScroll, useTransform } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutParallaxProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
badge?: string;
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ frontImageSrc: string; frontVideoSrc?: never } | { frontVideoSrc: string; frontImageSrc?: never }) &
({ backImageSrc: string; backVideoSrc?: never } | { backVideoSrc: string; backImageSrc?: never });
const AboutParallax = ({ tag, title, description, primaryButton, secondaryButton, frontImageSrc, frontVideoSrc, backImageSrc, backVideoSrc, badge, textAnimation }: AboutParallaxProps) => {
const sectionRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start end", "end start"],
});
const fgY = useTransform(scrollYProgress, [0, 1], ["120px", "-120px"]);
const bgY = useTransform(scrollYProgress, [0, 1], ["-60px", "60px"]);
const bgScale = useTransform(scrollYProgress, [0, 1], [1, 1.15]);
return (
<section
ref={sectionRef}
aria-label="About section"
className="relative py-20"
>
<div className="mx-auto w-content-width">
<div className="flex flex-col md:flex-row items-center gap-8 md:gap-16">
<div className="w-full md:w-45/100 flex flex-col gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="w-full md:w-55/100 relative h-100 md:h-125 xl:h-150 2xl:h-175">
<div className="absolute top-0 right-0 w-75/100 h-full overflow-hidden rounded">
<motion.div className="w-full h-full" style={{ y: bgY, scale: bgScale }}>
<ImageOrVideo imageSrc={backImageSrc} videoSrc={backVideoSrc} className="rounded" />
</motion.div>
{badge && (
<span className="absolute top-2 right-2 xl:top-3 xl:right-3 2xl:top-4 2xl:right-4 px-3 py-1.5 text-xs text-foreground font-medium card rounded">
{badge}
</span>
)}
</div>
<motion.div
className="absolute top-15/100 left-0 w-65/100 h-70/100 z-10 overflow-hidden rounded"
style={{ y: fgY }}
>
<ImageOrVideo imageSrc={frontImageSrc} videoSrc={frontVideoSrc} className="rounded" />
</motion.div>
</div>
</div>
</div>
</section>
);
};
export default AboutParallax;

View File

@@ -0,0 +1,114 @@
import { useRef } from "react";
import { motion, useScroll, useTransform } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutParallaxHighlightProps = {
tag: string;
title: string;
titleHighlight?: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
badge?: string;
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ frontImageSrc: string; frontVideoSrc?: never } | { frontVideoSrc: string; frontImageSrc?: never }) &
({ backImageSrc: string; backVideoSrc?: never } | { backVideoSrc: string; backImageSrc?: never });
const AboutParallaxHighlight = ({ tag, title, titleHighlight, description, primaryButton, secondaryButton, frontImageSrc, frontVideoSrc, backImageSrc, backVideoSrc, badge, textAnimation }: AboutParallaxHighlightProps) => {
const sectionRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start end", "end start"],
});
const fgY = useTransform(scrollYProgress, [0, 1], ["120px", "-120px"]);
const bgY = useTransform(scrollYProgress, [0, 1], ["-60px", "60px"]);
const bgScale = useTransform(scrollYProgress, [0, 1], [1, 1.15]);
return (
<section
ref={sectionRef}
aria-label="About section"
className="relative py-20"
>
<div className="mx-auto w-content-width">
<div className="flex flex-col md:flex-row items-center gap-8 md:gap-16">
<div className="w-full md:w-45/100 flex flex-col gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<motion.h2
className="text-7xl 2xl:text-8xl leading-[1.15] font-normal text-balance"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-20%" }}
transition={{ staggerChildren: 0.04 }}
>
{(() => {
const titleWords = title.split(" ");
const highlightWords = titleHighlight ? titleHighlight.split(" ") : [];
const allWords = [...titleWords, ...highlightWords];
return allWords.map((word, i) => {
const isHighlight = i >= titleWords.length;
return (
<span key={i}>
{i > 0 && " "}
<motion.span
className={isHighlight ? "inline-block font-serif italic" : "inline-block"}
variants={{
hidden: { opacity: 0, y: "50%" },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{word}
</motion.span>
</span>
);
});
})()}
</motion.h2>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="w-full md:w-55/100 relative h-100 md:h-125 xl:h-150 2xl:h-175">
<div className="absolute top-0 right-0 w-75/100 h-full overflow-hidden rounded">
<motion.div className="w-full h-full" style={{ y: bgY, scale: bgScale }}>
<ImageOrVideo imageSrc={backImageSrc} videoSrc={backVideoSrc} className="rounded" />
</motion.div>
{badge && (
<span className="absolute top-2 right-2 xl:top-3 xl:right-3 2xl:top-4 2xl:right-4 px-3 py-1.5 text-xs text-foreground font-medium card rounded">
{badge}
</span>
)}
</div>
<motion.div
className="absolute top-15/100 left-0 w-65/100 h-70/100 z-10 overflow-hidden rounded"
style={{ y: fgY }}
>
<ImageOrVideo imageSrc={frontImageSrc} videoSrc={frontVideoSrc} className="rounded" />
</motion.div>
</div>
</div>
</div>
</section>
);
};
export default AboutParallaxHighlight;

View File

@@ -0,0 +1,60 @@
import { Quote } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutTestimonialProps = {
tag: string;
quote: string;
author: string;
role: string;
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutTestimonial = ({
tag,
quote,
author,
role,
imageSrc,
videoSrc,
textAnimation,
}: AboutTestimonialProps) => {
return (
<section aria-label="Testimonial section" className="py-20">
<div className="grid grid-cols-1 md:grid-cols-5 gap-5 mx-auto w-content-width">
<div className="relative md:col-span-3 p-10 md:p-20 card rounded">
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
</div>
<div className="relative flex flex-col justify-center gap-5 h-full">
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
<p>{tag}</p>
</div>
<TextAnimation
text={quote}
variant={textAnimation}
gradientText={false}
tag="h1"
className="text-4xl md:text-5xl leading-[1.15] font-semibold text-balance"
/>
<div className="flex items-center gap-2 min-w-0">
<span className="text-base font-medium truncate">{author}</span>
<span className="text-accent shrink-0"></span>
<span className="text-base font-medium truncate">{role}</span>
</div>
</div>
</div>
<ScrollReveal variant="fade-blur" className="p-px md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</ScrollReveal>
</div>
</section>
);
};
export default AboutTestimonial;

View File

@@ -0,0 +1,107 @@
import { useRef } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import type { LucideIcon } from "lucide-react";
import { Quote } from "lucide-react";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import TextAnimation from "@/components/ui/TextAnimation";
import { useButtonClick } from "@/hooks/useButtonClick";
import { resolveIcon } from "@/utils/resolve-icon";
type SocialLink = {
icon: string | LucideIcon;
label: string;
href?: string;
onClick?: () => void;
};
type AboutTestimonialParallaxProps = {
tag: string;
quote: string;
author: string;
role: string;
socialLinks?: SocialLink[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const SocialLinkButton = ({ icon, label, href, onClick }: SocialLink) => {
const Icon = resolveIcon(icon);
const handleClick = useButtonClick(href, onClick);
return (
<a
href={href}
onClick={handleClick}
className="flex items-center justify-center gap-2 h-9 px-3 text-sm rounded-full cursor-pointer backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20 text-primary-cta-text font-medium hover:bg-primary-cta-text/25 transition-all duration-300 ease-out"
>
<Icon className="size-4" strokeWidth={1.5} />
<span>{label}</span>
</a>
);
};
const AboutTestimonialParallax = ({
tag,
quote,
author,
role,
imageSrc,
videoSrc,
socialLinks,
textAnimation,
}: AboutTestimonialParallaxProps) => {
const imageRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: imageRef,
offset: ["start end", "end start"],
});
const imageScale = useTransform(scrollYProgress, [0, 0.6], [1.3, 1]);
return (
<section aria-label="About section" className="py-20">
<div className="grid grid-cols-1 md:grid-cols-5 gap-5 mx-auto w-content-width">
<div className="relative md:col-span-3 p-10 md:p-20 card rounded">
<div className="absolute flex items-center justify-center -top-7 -left-7 md:-top-8 md:-left-8 size-14 md:size-16 primary-button rounded">
<Quote className="h-5/10 text-primary-cta-text" strokeWidth={1.5} />
</div>
<div className="relative flex flex-col justify-center gap-5 h-full">
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
<p>{tag}</p>
</div>
<TextAnimation
text={quote}
variant={textAnimation}
gradientText={false}
tag="h1"
className="text-4xl md:text-5xl leading-[1.15] font-semibold text-balance"
/>
<div className="flex items-center gap-2 min-w-0">
<span className="text-base font-medium truncate">{author}</span>
<span className="text-accent shrink-0"></span>
<span className="text-base font-medium truncate">{role}</span>
</div>
</div>
</div>
<div ref={imageRef} className="p-px md:col-span-2 aspect-square md:aspect-auto md:h-full card rounded overflow-hidden relative">
<motion.div style={{ scale: imageScale }} className="w-full h-full origin-center">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</motion.div>
{socialLinks && socialLinks.length > 0 && (
<div className="absolute inset-x-0 bottom-0 flex flex-wrap items-end justify-center gap-3 p-6 xl:p-7 2xl:p-8">
{socialLinks.map((link, index) => (
<SocialLinkButton key={index} {...link} />
))}
</div>
)}
</div>
</div>
</section>
);
};
export default AboutTestimonialParallax;

View File

@@ -0,0 +1,39 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
interface AboutTextProps {
title: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const AboutText = ({
title,
primaryButton,
secondaryButton,
textAnimation,
}: AboutTextProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-2 items-center">
<TextAnimation
text={title}
variant={textAnimation}
gradientText={false}
tag="h2"
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap gap-3 justify-center mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
</section>
);
};
export default AboutText;

View File

@@ -0,0 +1,94 @@
import { useRef } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type AboutTextFillProps = {
tag?: string;
title: string;
description?: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const AboutTextFill = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
textAnimation,
}: AboutTextFillProps) => {
const sectionRef = useRef<HTMLDivElement>(null);
const words = title.split(" ");
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start 0.8", "start 0.2"],
});
const Word = ({ word, index }: { word: string; index: number }) => {
const start = index / words.length;
const end = (index + 1) / words.length;
const opacity = useTransform(scrollYProgress, [start, end], [0.15, 1]);
return (
<motion.span className="inline-block" style={{ opacity }}>
{word}
</motion.span>
);
};
return (
<section
ref={sectionRef}
aria-label="About section"
className="py-20"
>
<div className="flex flex-col gap-8 md:gap-10 mx-auto w-content-width">
<div className="flex flex-col items-center gap-2">
{tag && (
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
)}
<h2 className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance">
{words.map((word, i) => (
<span key={i}>
{i > 0 && " "}
<Word word={word} index={i} />
</span>
))}
</h2>
{description && (
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
)}
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="overflow-hidden rounded aspect-square md:aspect-video">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="w-full h-full object-cover" />
</div>
</div>
</section>
);
};
export default AboutTextFill;

View File

@@ -0,0 +1,60 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
interface AboutTextSplitProps {
title: string;
descriptions: string[];
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const AboutTextSplit = ({
title,
descriptions,
primaryButton,
secondaryButton,
textAnimation,
}: AboutTextSplitProps) => {
return (
<section aria-label="About section" className="py-20">
<div className="flex flex-col gap-20 mx-auto w-content-width">
<div className="flex flex-col md:flex-row gap-3 md:gap-15">
<div className="w-full md:w-1/2">
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance"
/>
</div>
<div className="flex flex-col gap-2 w-full md:w-1/2">
{descriptions.map((desc, index) => (
<TextAnimation
key={index}
text={desc}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-xl md:text-2xl leading-snug text-balance"
/>
))}
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
</div>
<div className="w-full border-b border-foreground/5" />
</div>
</section>
);
};
export default AboutTextSplit;

View File

@@ -0,0 +1,91 @@
import ScrollReveal from "@/components/ui/ScrollReveal";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type BlogArticleProps = {
category: string;
title: string;
excerpt?: string;
content: string;
imageSrc: string;
authorName: string;
authorImageSrc: string;
date: string;
readingTime?: string;
backButton?: { text: string; href: string };
};
const BlogArticle = ({
category,
title,
excerpt,
content,
imageSrc,
authorName,
authorImageSrc,
date,
readingTime,
backButton = { text: "Back to Blog", href: "/blog" },
}: BlogArticleProps) => {
return (
<article aria-label="Blog article" className="py-20">
<div className="flex flex-col gap-10">
<ScrollReveal variant="fade-blur">
<div className="flex flex-col gap-3 w-content-width md:max-w-4xl mx-auto">
<div className="flex items-center gap-2 px-3 py-1 mb-1 text-sm text-foreground/75 card rounded w-fit">
<a
href={backButton.href}
className="hover:text-foreground transition-colors"
>
{backButton.text}
</a>
<span>/</span>
<span className="text-foreground">{category}</span>
</div>
<h1 className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance">
{title}
</h1>
{excerpt && (
<p className="text-lg md:text-xl leading-snug text-balance">
{excerpt}
</p>
)}
<div className="flex items-center gap-3 mt-2 md:mt-3">
<ImageOrVideo
imageSrc={authorImageSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col min-w-0">
<span className="text-base text-foreground font-semibold leading-snug truncate">{authorName}</span>
<span className="text-base text-foreground/75 leading-snug truncate">
{date}
{readingTime && ` · ${readingTime}`}
</span>
</div>
</div>
</div>
</ScrollReveal>
<ScrollReveal variant="fade-blur">
<div className="w-content-width md:max-w-4xl mx-auto aspect-video card rounded overflow-hidden p-2 xl:p-3 2xl:p-4">
<ImageOrVideo
imageSrc={imageSrc}
className="size-full object-cover"
/>
</div>
</ScrollReveal>
<ScrollReveal variant="fade-blur">
<div
className="w-content-width md:max-w-4xl mx-auto flex flex-col gap-6 [&>h1]:text-4xl [&>h1]:font-semibold [&>h1]:mt-4 [&>h2]:text-3xl [&>h2]:font-semibold [&>h2]:mt-4 [&>h3]:text-2xl [&>h3]:font-semibold [&>h3]:mt-2 [&>h4]:text-xl [&>h4]:font-semibold [&>h4]:mt-2 [&>p]:text-base [&>p]:leading-relaxed [&>p]:text-foreground/85 [&_strong]:font-semibold [&_em]:italic [&_u]:underline [&>ul]:flex [&>ul]:flex-col [&>ul]:gap-2 [&>ul]:list-disc [&>ul]:pl-6 [&>ul]:text-base [&>ul]:leading-relaxed [&>ul]:text-foreground/85 [&>ol]:flex [&>ol]:flex-col [&>ol]:gap-2 [&>ol]:list-decimal [&>ol]:pl-6 [&>ol]:text-base [&>ol]:leading-relaxed [&>ol]:text-foreground/85"
dangerouslySetInnerHTML={{ __html: content }}
/>
</ScrollReveal>
</div>
</article>
);
};
export default BlogArticle;

View File

@@ -0,0 +1,162 @@
import { ArrowUpRight, Loader2 } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { useButtonClick } from "@/hooks/useButtonClick";
import useBlogPosts from "@/hooks/useBlogPosts";
type BlogItem = {
category: string;
title: string;
excerpt: string;
authorName: string;
authorImageSrc: string;
date: string;
imageSrc: string;
href?: string;
onClick?: () => void;
};
const BlogCardItem = ({ item }: { item: BlogItem }) => {
const handleClick = useButtonClick(item.href, item.onClick);
return (
<article
className="card group flex flex-col justify-between gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
onClick={handleClick}
>
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
<div className="flex flex-col gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{item.category}</p>
</div>
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
</div>
<div className="flex items-center gap-3 mt-2 md:mt-3">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col min-w-0">
<span className="text-base text-foreground font-semibold leading-snug truncate">{item.authorName}</span>
<span className="text-base text-foreground/75 leading-snug truncate">{item.date}</span>
</div>
</div>
</div>
<div className="relative aspect-square rounded overflow-hidden button-secondary shadow shadow-foreground/5">
<ImageOrVideo
imageSrc={item.imageSrc}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
<button
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
onClick={handleClick}
>
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</button>
</div>
</div>
</article>
);
};
type BlogMediaCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const BlogMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
textAnimation,
}: BlogMediaCardsProps) => {
const { posts, isLoading } = useBlogPosts();
const isFromApi = posts.length > 0;
const items = isFromApi
? posts.map((p) => ({
category: p.category,
title: p.title,
excerpt: p.excerpt,
authorName: p.authorName,
authorImageSrc: p.authorAvatar,
date: p.date,
imageSrc: p.imageSrc,
onClick: p.onBlogClick ?? (p.slug ? () => { window.location.href = `/blog/${p.slug}`; } : undefined),
}))
: itemsProp;
if (isLoading && !itemsProp) {
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!items || items.length === 0) {
return null;
}
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant={textAnimation}>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default BlogMediaCards;

View File

@@ -0,0 +1,161 @@
import { ArrowUpRight, Loader2 } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { useButtonClick } from "@/hooks/useButtonClick";
import useBlogPosts from "@/hooks/useBlogPosts";
type BlogItem = {
category: string;
title: string;
excerpt: string;
authorName: string;
authorImageSrc: string;
date: string;
imageSrc: string;
href?: string;
onClick?: () => void;
};
const BlogCardItem = ({ item }: { item: BlogItem }) => {
const handleClick = useButtonClick(item.href, item.onClick);
return (
<article
className="card group flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
onClick={handleClick}
>
<div className="relative aspect-4/3 rounded overflow-hidden button-secondary shadow shadow-foreground/5">
<ImageOrVideo
imageSrc={item.imageSrc}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
<button
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
onClick={handleClick}
>
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</button>
</div>
</div>
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
<div className="flex flex-col gap-2">
<div className="px-3 py-1 mb-1 text-sm primary-button text-primary-cta-text rounded w-fit">
<p>{item.category}</p>
</div>
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
</div>
<div className="flex items-center gap-3 mt-2 md:mt-3">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col min-w-0">
<span className="text-base text-foreground font-semibold leading-snug truncate">{item.authorName}</span>
<span className="text-base text-foreground/75 leading-snug truncate">{item.date}</span>
</div>
</div>
</div>
</article>
);
};
type BlogSimpleCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const BlogSimpleCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
textAnimation,
}: BlogSimpleCardsProps) => {
const { posts, isLoading } = useBlogPosts();
const isFromApi = posts.length > 0;
const items = isFromApi
? posts.map((p) => ({
category: p.category,
title: p.title,
excerpt: p.excerpt,
authorName: p.authorName,
authorImageSrc: p.authorAvatar,
date: p.date,
imageSrc: p.imageSrc,
onClick: p.onBlogClick ?? (p.slug ? () => { window.location.href = `/blog/${p.slug}`; } : undefined),
}))
: itemsProp;
if (isLoading && !itemsProp) {
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!items || items.length === 0) {
return null;
}
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant={textAnimation}>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default BlogSimpleCards;

View File

@@ -0,0 +1,165 @@
import { ArrowUpRight, Loader2 } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { useButtonClick } from "@/hooks/useButtonClick";
import useBlogPosts from "@/hooks/useBlogPosts";
type BlogItem = {
title: string;
excerpt: string;
authorName: string;
authorImageSrc: string;
date: string;
tags: string[];
imageSrc: string;
href?: string;
onClick?: () => void;
};
const BlogCardItem = ({ item }: { item: BlogItem }) => {
const handleClick = useButtonClick(item.href, item.onClick);
return (
<article
className="card group flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 rounded cursor-pointer"
onClick={handleClick}
>
<div className="relative aspect-4/3 rounded overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 flex items-center justify-center group-hover:bg-background/20 group-hover:backdrop-blur-xs transition-all duration-300">
<button
className="primary-button flex items-center justify-center size-12 rounded-full opacity-0 group-hover:opacity-100 scale-75 group-hover:scale-100 transition-all duration-300 cursor-pointer"
onClick={handleClick}
>
<ArrowUpRight className="size-5 text-primary-cta-text" strokeWidth={2} />
</button>
</div>
</div>
<div className="flex flex-1 flex-col justify-between gap-2 p-3 xl:p-3.5 2xl:p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 min-w-0 mb-1">
<ImageOrVideo
imageSrc={item.authorImageSrc}
className="size-6 rounded-full object-cover shrink-0"
/>
<span className="text-sm text-foreground/75 truncate">
{item.authorName} {item.date}
</span>
</div>
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-base leading-snug text-balance">{item.excerpt}</p>
</div>
<div className="flex flex-wrap gap-2 mt-2 md:mt-3">
{item.tags.map((tag, index) => (
<div key={index} className="px-3 py-1 text-sm primary-button text-primary-cta-text rounded">
<p>{tag}</p>
</div>
))}
</div>
</div>
</article>
);
};
type BlogTagCardsProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items?: BlogItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const BlogTagCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items: itemsProp,
textAnimation,
}: BlogTagCardsProps) => {
const { posts, isLoading } = useBlogPosts();
const isFromApi = posts.length > 0;
const items = isFromApi
? posts.map((p) => ({
title: p.title,
excerpt: p.excerpt,
authorName: p.authorName,
authorImageSrc: p.authorAvatar,
date: p.date,
tags: [p.category],
imageSrc: p.imageSrc,
onClick: p.onBlogClick ?? (p.slug ? () => { window.location.href = `/blog/${p.slug}`; } : undefined),
}))
: itemsProp;
if (isLoading && !itemsProp) {
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex justify-center">
<Loader2 className="size-8 animate-spin text-foreground" strokeWidth={1.5} />
</div>
</section>
);
}
if (!items || items.length === 0) {
return null;
}
return (
<section aria-label="Blog section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant={textAnimation}>
<GridOrCarousel>
{items.map((item, index) => (
<BlogCardItem key={index} item={item} />
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default BlogTagCards;

View File

@@ -0,0 +1,65 @@
import type { LucideIcon } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { useButtonClick } from "@/hooks/useButtonClick";
type ContactOption = {
icon: LucideIcon;
label: string;
href: string;
};
type ContactBarProps = {
tag: string;
title: string;
options: ContactOption[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const ContactOptionButton = ({ icon: Icon, label, href }: ContactOption) => {
const handleClick = useButtonClick(href);
return (
<button
onClick={handleClick}
className="flex flex-col items-center gap-3 group cursor-pointer"
>
<div className="size-22 md:size-26 2xl:size-30 rounded-full primary-button flex items-center justify-center">
<Icon className="size-4/10 text-primary-cta-text" strokeWidth={1.5} />
</div>
<p className="text-sm md:text-base text-foreground/60 group-hover:text-foreground transition-colors">{label}</p>
</button>
);
};
const ContactBar = ({ tag, title, options, textAnimation }: ContactBarProps) => {
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<ScrollReveal variant="slide-up">
<div className="flex flex-col md:flex-row items-center justify-between gap-8 md:gap-10 card rounded p-6 md:p-10">
<div className="flex flex-col gap-2 md:max-w-1/2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance"
/>
</div>
<div className="flex items-center gap-6 md:gap-10">
{options.map((opt) => (
<ContactOptionButton key={opt.label} {...opt} />
))}
</div>
</div>
</ScrollReveal>
</div>
</section>
);
};
export default ContactBar;

View File

@@ -0,0 +1,101 @@
import { useState } from "react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import TextAnimation from "@/components/ui/TextAnimation";
import { sendContactEmail } from "@/lib/api/email";
type ContactCenterProps = {
tag: string;
title: string;
description: string;
inputPlaceholder: string;
buttonText: string;
onSubmit?: (email: string) => void;
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const ContactCenter = ({
tag,
title,
description,
inputPlaceholder,
buttonText,
onSubmit,
textAnimation,
}: ContactCenterProps) => {
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await sendContactEmail({ email });
onSubmit?.(email);
setEmail("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<ScrollReveal variant="fade" className="flex items-center justify-center py-20 card rounded">
<div className="flex flex-col items-center w-full md:w-1/2 gap-2 px-5">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-9/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-9/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
<form
onSubmit={handleSubmit}
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 md:mt-3 card rounded"
>
<input
type="email"
placeholder={inputPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
aria-label="Email address"
/>
<button
type="submit"
disabled={isLoading}
className="flex items-center justify-center h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Sending..." : buttonText}
</button>
</form>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default ContactCenter;

View File

@@ -0,0 +1,48 @@
import ScrollReveal from "@/components/ui/ScrollReveal";
import TextAnimation from "@/components/ui/TextAnimation";
import Button from "@/components/ui/Button";
const ContactCta = ({
tag,
text,
primaryButton,
secondaryButton,
textAnimation,
}: {
tag: string;
text: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
}) => {
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<ScrollReveal variant="fade">
<div className="flex flex-col items-center gap-8 md:gap-10 py-20 px-8 rounded card">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={text}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-5xl 2xl:text-6xl leading-[1.15] font-semibold text-center text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
</div>
</div>
</ScrollReveal>
</div>
</section>
);
};
export default ContactCta;

View File

@@ -0,0 +1,159 @@
import { useRef, useState } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { sendContactEmail } from "@/lib/api/email";
type ContactParallaxCardProps = {
title: string;
inputs: { name: string; type: string; placeholder: string; required?: boolean }[];
textarea?: { name: string; placeholder: string; rows?: number; required?: boolean };
buttonText: string;
footerText: string;
footerLink: { text: string; href: string; imageSrc: string };
onSubmit?: (data: Record<string, string>) => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const ContactParallaxCard = ({
title,
imageSrc,
videoSrc,
inputs,
textarea,
buttonText,
footerText,
footerLink,
onSubmit,
}: ContactParallaxCardProps) => {
const sectionRef = useRef<HTMLDivElement>(null);
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
inputs.forEach((input) => {
initial[input.name] = "";
});
if (textarea) {
initial[textarea.name] = "";
}
return initial;
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await sendContactEmail({ formData });
onSubmit?.(formData);
const reset: Record<string, string> = {};
inputs.forEach((input) => {
reset[input.name] = "";
});
if (textarea) {
reset[textarea.name] = "";
}
setFormData(reset);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
} finally {
setIsLoading(false);
}
};
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start end", "end start"],
});
const bgY = useTransform(scrollYProgress, [0, 1], ["-10%", "10%"]);
return (
<section
ref={sectionRef}
aria-label="Contact section"
className="relative overflow-hidden h-[90svh] my-20"
>
<motion.div className="absolute inset-0" style={{ y: bgY }}>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-[120%] object-cover rounded-none"
/>
<div className="absolute inset-0 bg-black/30" />
</motion.div>
<div className="relative z-10 flex items-center h-full px-8 md:px-12">
<div className="mx-auto w-content-width">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="w-full md:w-1/2 lg:w-5/12 rounded border border-primary-cta-text/10 bg-primary-cta-text/10 backdrop-blur-xl p-6 md:p-10"
>
<h2 className="mb-6 text-4xl md:text-5xl font-semibold text-white">
{title}
</h2>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{inputs.map((input) => (
<input
key={input.name}
type={input.type}
placeholder={input.placeholder}
value={formData[input.name] || ""}
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
required={input.required}
aria-label={input.placeholder}
className="w-full h-13 rounded border border-primary-cta-text/15 bg-primary-cta-text/10 px-5 text-base text-white placeholder:text-white/40 focus:border-accent/50 focus:outline-none"
/>
))}
</div>
{textarea && (
<textarea
placeholder={textarea.placeholder}
value={formData[textarea.name] || ""}
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
required={textarea.required}
rows={textarea.rows || 5}
aria-label={textarea.placeholder}
className="w-full resize-none rounded border border-primary-cta-text/15 bg-primary-cta-text/10 px-5 py-3 text-base text-white placeholder:text-white/40 focus:border-accent/50 focus:outline-none"
/>
)}
<button
type="submit"
disabled={isLoading}
className="flex items-center justify-center w-full h-13 px-6 text-base font-medium rounded bg-background text-foreground cursor-pointer transition-colors hover:bg-background/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Sending..." : buttonText}
</button>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
</form>
<div className="mt-6 flex items-center justify-between border-t border-primary-cta-text/10 pt-6">
<p className="text-base text-white/75">{footerText}</p>
<a
href={footerLink.href}
className="flex items-center gap-2 rounded-full bg-primary-cta-text/15 backdrop-blur-sm p-1.5 pr-5 text-sm font-medium text-white whitespace-nowrap transition-colors hover:bg-primary-cta-text/25"
>
<ImageOrVideo
imageSrc={footerLink.imageSrc}
className="h-8 w-8 rounded-full object-cover"
/>
{footerLink.text}
</a>
</div>
</motion.div>
</div>
</div>
</section>
);
};
export default ContactParallaxCard;

View File

@@ -0,0 +1,110 @@
import { useState } from "react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { sendContactEmail } from "@/lib/api/email";
type ContactSplitEmailProps = {
tag: string;
title: string;
description: string;
inputPlaceholder: string;
buttonText: string;
onSubmit?: (email: string) => void;
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const ContactSplitEmail = ({
tag,
title,
description,
inputPlaceholder,
buttonText,
onSubmit,
imageSrc,
videoSrc,
textAnimation,
}: ContactSplitEmailProps) => {
const [email, setEmail] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await sendContactEmail({ email });
onSubmit?.(email);
setEmail("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<ScrollReveal variant="slide-up" className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="flex items-center justify-center py-15 md:py-20 card rounded">
<div className="flex flex-col items-center w-full gap-2 px-5">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
<form
onSubmit={handleSubmit}
className="flex flex-col md:flex-row w-full md:w-8/10 2xl:w-6/10 gap-3 md:gap-1 p-1 mt-2 md:mt-3 card rounded"
>
<input
type="email"
placeholder={inputPlaceholder}
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="flex-1 px-5 py-3 md:py-0 text-base text-center md:text-left bg-transparent placeholder:opacity-75 focus:outline-none truncate"
aria-label="Email address"
/>
<button
type="submit"
disabled={isLoading}
className="flex items-center justify-center h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Sending..." : buttonText}
</button>
</form>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
</div>
</div>
<div className="h-100 md:h-auto md:aspect-square card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</div>
</ScrollReveal>
</div>
</section>
);
};
export default ContactSplitEmail;

View File

@@ -0,0 +1,157 @@
import { useState } from "react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { sendContactEmail } from "@/lib/api/email";
type InputField = {
name: string;
type: string;
placeholder: string;
required?: boolean;
};
type TextareaField = {
name: string;
placeholder: string;
rows?: number;
required?: boolean;
};
type ContactSplitFormProps = {
tag: string;
title: string;
description: string;
inputs: InputField[];
textarea?: TextareaField;
buttonText: string;
onSubmit?: (data: Record<string, string>) => void;
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const ContactSplitForm = ({
tag,
title,
description,
inputs,
textarea,
buttonText,
onSubmit,
imageSrc,
videoSrc,
textAnimation,
}: ContactSplitFormProps) => {
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
inputs.forEach((input) => {
initial[input.name] = "";
});
if (textarea) {
initial[textarea.name] = "";
}
return initial;
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await sendContactEmail({ formData });
onSubmit?.(formData);
const reset: Record<string, string> = {};
inputs.forEach((input) => {
reset[input.name] = "";
});
if (textarea) {
reset[textarea.name] = "";
}
setFormData(reset);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<ScrollReveal variant="slide-up" className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="p-6 md:p-10 card rounded">
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl leading-snug text-balance"
/>
</div>
<div className="flex flex-col gap-3">
{inputs.map((input) => (
<input
key={input.name}
type={input.type}
placeholder={input.placeholder}
value={formData[input.name] || ""}
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
required={input.required}
aria-label={input.placeholder}
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
/>
))}
{textarea && (
<textarea
placeholder={textarea.placeholder}
value={formData[textarea.name] || ""}
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
required={textarea.required}
rows={textarea.rows || 5}
aria-label={textarea.placeholder}
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
/>
)}
<button
type="submit"
disabled={isLoading}
className="flex items-center justify-center w-full h-10 px-6 text-sm primary-button text-primary-cta-text rounded cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Sending..." : buttonText}
</button>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
</div>
</form>
</div>
<div className="h-100 md:h-auto card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover rounded" />
</div>
</ScrollReveal>
</div>
</section>
);
};
export default ContactSplitForm;

View File

@@ -0,0 +1,204 @@
import { useRef, useState } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import type { LucideIcon } from "lucide-react";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import TextAnimation from "@/components/ui/TextAnimation";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { sendContactEmail } from "@/lib/api/email";
import { useButtonClick } from "@/hooks/useButtonClick";
import { resolveIcon } from "@/utils/resolve-icon";
type InputField = {
name: string;
type: string;
placeholder: string;
required?: boolean;
};
type TextareaField = {
name: string;
placeholder: string;
rows?: number;
required?: boolean;
};
type CtaLink = {
icon: string | LucideIcon;
label: string;
href?: string;
onClick?: () => void;
};
type ContactSplitFormParallaxProps = {
tag: string;
title: string;
description: string;
inputs: InputField[];
textarea?: TextareaField;
buttonText: string;
onSubmit?: (data: Record<string, string>) => void;
ctaLinks?: CtaLink[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const CtaLinkButton = ({ icon, label, href, onClick }: CtaLink) => {
const Icon = resolveIcon(icon);
const handleClick = useButtonClick(href, onClick);
return (
<a
href={href}
onClick={handleClick}
className="flex items-center justify-center gap-2 h-9 px-3 text-sm rounded-full cursor-pointer backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20 text-primary-cta-text font-semibold hover:bg-primary-cta-text/25 transition-all duration-300 ease-out"
>
<Icon className="size-4" strokeWidth={1.5} />
<span>{label}</span>
</a>
);
};
const ContactSplitFormParallax = ({
tag,
title,
description,
inputs,
textarea,
buttonText,
onSubmit,
imageSrc,
videoSrc,
ctaLinks,
textAnimation,
}: ContactSplitFormParallaxProps) => {
const imageRef = useRef<HTMLDivElement>(null);
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
inputs.forEach((input) => {
initial[input.name] = "";
});
if (textarea) {
initial[textarea.name] = "";
}
return initial;
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.SyntheticEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
await sendContactEmail({ formData });
onSubmit?.(formData);
const reset: Record<string, string> = {};
inputs.forEach((input) => {
reset[input.name] = "";
});
if (textarea) {
reset[textarea.name] = "";
}
setFormData(reset);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send. Please try again.");
} finally {
setIsLoading(false);
}
};
const { scrollYProgress } = useScroll({
target: imageRef,
offset: ["start end", "end start"],
});
const imageScale = useTransform(scrollYProgress, [0, 0.6], [1.3, 1]);
return (
<section aria-label="Contact section" className="py-20">
<div className="w-content-width mx-auto">
<ScrollReveal variant="fade" className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="p-6 md:p-10 card rounded">
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-2 text-center">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl leading-snug text-balance"
/>
</div>
<div className="flex flex-col gap-3">
{inputs.map((input) => (
<input
key={input.name}
type={input.type}
placeholder={input.placeholder}
value={formData[input.name] || ""}
onChange={(e) => setFormData({ ...formData, [input.name]: e.target.value })}
required={input.required}
aria-label={input.placeholder}
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none card rounded"
/>
))}
{textarea && (
<textarea
placeholder={textarea.placeholder}
value={formData[textarea.name] || ""}
onChange={(e) => setFormData({ ...formData, [textarea.name]: e.target.value })}
required={textarea.required}
rows={textarea.rows || 5}
aria-label={textarea.placeholder}
className="w-full px-5 py-3 text-base bg-transparent placeholder:opacity-75 focus:outline-none resize-none card rounded"
/>
)}
<button
type="submit"
disabled={isLoading}
className="flex items-center justify-center w-full h-10 px-6 text-sm rounded primary-button text-primary-cta-text cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Sending..." : buttonText}
</button>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
</div>
</form>
</div>
<div ref={imageRef} className="relative h-100 md:h-auto card rounded overflow-hidden">
<motion.div style={{ scale: imageScale }} className="w-full h-full origin-center">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="md:absolute md:inset-0 size-full object-cover" />
</motion.div>
{ctaLinks && ctaLinks.length > 0 && (
<div className="absolute inset-0 flex flex-wrap items-end justify-center gap-3 p-6 xl:p-7 2xl:p-8">
{ctaLinks.map((link, index) => (
<CtaLinkButton key={index} {...link} />
))}
</div>
)}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default ContactSplitFormParallax;

View File

@@ -0,0 +1,109 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
const FaqSimple = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur" className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{items.map((item, index) => (
<div
key={index}
onClick={() => handleToggle(index)}
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
>
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-1 text-base leading-snug">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</ScrollReveal>
</div>
</section>
);
};
export default FaqSimple;

View File

@@ -0,0 +1,136 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
const FaqSimpleHighlight = ({
tag,
title,
titleHighlight,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: {
tag: string;
title: string;
titleHighlight?: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<motion.h2
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-normal text-center text-balance"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-20%" }}
transition={{ staggerChildren: 0.04 }}
>
{(() => {
const titleWords = title.split(" ");
const highlightWords = titleHighlight ? titleHighlight.split(" ") : [];
const allWords = [...titleWords, ...highlightWords];
return allWords.map((word, i) => {
const isHighlight = i >= titleWords.length;
return (
<span key={i}>
{i > 0 && " "}
<motion.span
className={isHighlight ? "inline-block font-serif italic" : "inline-block"}
variants={{
hidden: { opacity: 0, y: "50%" },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{word}
</motion.span>
</span>
);
});
})()}
</motion.h2>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up" className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{items.map((item, index) => (
<div
key={index}
onClick={() => handleToggle(index)}
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
>
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
<h3 className="text-lg md:text-xl font-normal leading-snug">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-1 text-base leading-snug">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</ScrollReveal>
</div>
</section>
);
};
export default FaqSimpleHighlight;

View File

@@ -0,0 +1,124 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
type FaqSplitMediaProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const FaqSplitMedia = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
imageSrc,
videoSrc,
textAnimation,
}: FaqSplitMediaProps) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-5 gap-5">
<ScrollReveal variant="fade-blur" className="card relative md:col-span-2 h-80 md:h-auto rounded overflow-hidden">
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 size-full object-cover"
/>
</ScrollReveal>
<ScrollReveal variant="fade-blur" delay={0.1} className="md:col-span-3 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{items.map((item, index) => (
<div
key={index}
onClick={() => handleToggle(index)}
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
>
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-1 text-base leading-snug">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</ScrollReveal>
</div>
</div>
</section>
);
};
export default FaqSplitMedia;

View File

@@ -0,0 +1,111 @@
import { useState } from "react";
import Button from "@/components/ui/Button";
import SelectorButton from "@/components/ui/SelectorButton";
import ScrollReveal from "@/components/ui/ScrollReveal";
import TextAnimation from "@/components/ui/TextAnimation";
import Transition from "@/components/ui/Transition";
import Accordion from "@/components/ui/Accordion";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type FaqItem = {
question: string;
answer: string;
};
type FaqCategory = {
name: string;
items: FaqItem[];
};
interface FaqTabbedAccordionProps {
tag: string;
title: string;
description: string;
categories: FaqCategory[];
textAnimation: "slide-up" | "fade-blur" | "fade";
cta?: {
name: string;
role: string;
buttonText: string;
buttonHref: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
}
const FaqTabbedAccordion = ({
tag,
title,
description,
categories,
cta,
textAnimation,
}: FaqTabbedAccordionProps) => {
const [activeCategory, setActiveCategory] = useState(categories[0]?.name || "");
const currentItems = categories.find((c) => c.name === activeCategory)?.items || [];
const accordionItems = currentItems.map((item) => ({ title: item.question, content: item.answer }));
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto">
<div className="card rounded flex flex-col gap-6 md:gap-10 p-6 md:p-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
<SelectorButton
options={categories.map((c) => ({ value: c.name, label: c.name }))}
activeValue={activeCategory}
onValueChange={setActiveCategory}
className="mt-2 md:mt-3"
/>
</div>
<ScrollReveal variant="fade-blur">
<Transition key={activeCategory} whileInView={false} className="">
<Accordion items={accordionItems} />
</Transition>
</ScrollReveal>
{cta && (
<>
<div className="w-full h-px bg-foreground/5" />
<div className="flex flex-col md:flex-row md:items-center gap-6 justify-between">
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={cta.imageSrc}
videoSrc={cta.videoSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col min-w-0">
<span className="text-base text-foreground font-semibold leading-snug truncate">{cta.name}</span>
<span className="text-base text-foreground/75 leading-snug truncate">{cta.role}</span>
</div>
</div>
<Button text={cta.buttonText} href={cta.buttonHref} variant="primary" />
</div>
</>
)}
</div>
</div>
</section>
);
};
export default FaqTabbedAccordion;

View File

@@ -0,0 +1,124 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { Plus } from "lucide-react";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import { cls } from "@/lib/utils";
type FaqItem = {
question: string;
answer: string;
};
const FaqTwoColumn = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FaqItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}) => {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const handleToggle = (index: number) => {
setActiveIndex(activeIndex === index ? null : index);
};
const halfLength = Math.ceil(items.length / 2);
const firstColumn = items.slice(0, halfLength);
const secondColumn = items.slice(halfLength);
const renderAccordionItem = (item: FaqItem, index: number) => (
<div
key={index}
onClick={() => handleToggle(index)}
className="p-3 xl:p-3.5 2xl:p-4 rounded card cursor-pointer select-none"
>
<div className="flex items-center justify-between gap-3 xl:gap-3.5 2xl:gap-4">
<h3 className="text-lg md:text-xl font-medium leading-snug">{item.question}</h3>
<div className="flex shrink-0 items-center justify-center size-8 md:size-9 rounded primary-button">
<Plus
className={cls(
"size-3.5 md:size-4 text-primary-cta-text transition-transform duration-300",
activeIndex === index && "rotate-45"
)}
strokeWidth={2}
/>
</div>
</div>
<AnimatePresence initial={false}>
{activeIndex === index && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="overflow-hidden"
>
<p className="pt-1 text-base leading-snug">{item.answer}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
return (
<section aria-label="FAQ section" className="py-20">
<div className="w-content-width mx-auto flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade" className="card rounded p-6 xl:p-10">
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4">
<div className="flex flex-1 flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{firstColumn.map((item, index) => renderAccordionItem(item, index))}
</div>
{secondColumn.length > 0 && (
<div className="flex flex-1 flex-col gap-3 xl:gap-3.5 2xl:gap-4">
{secondColumn.map((item, index) => renderAccordionItem(item, index + halfLength))}
</div>
)}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FaqTwoColumn;

View File

@@ -0,0 +1,130 @@
import { useEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
gsap.registerPlugin(ScrollTrigger);
type FeatureItem = {
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesAlternatingSplitProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesAlternatingSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesAlternatingSplitProps) => {
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
useEffect(() => {
const ctx = gsap.context(() => {
itemRefs.current.forEach((ref, position) => {
if (!ref) return;
const isLast = position === itemRefs.current.length - 1;
gsap.timeline({
scrollTrigger: {
trigger: ref,
start: "center center",
end: "+=100%",
scrub: true,
},
})
.set(ref, { willChange: "opacity" })
.to(ref, {
ease: "none",
opacity: isLast ? 1 : 0,
});
});
});
return () => ctx.revert();
}, [items.length]);
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col gap-5 md:gap-[6vh] w-content-width mx-auto">
{items.map((item, index) => (
<div
key={item.title}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={cls("sticky top-[25vw] md:top-[12.5vh] h-[140vw] md:h-[75vh] flex flex-col gap-6 md:gap-10 p-6 md:p-10 card rounded", index % 2 === 0 ? "md:flex-row" : "md:flex-row-reverse")}
>
<div className="flex flex-col justify-center w-full md:w-1/2 gap-2">
<div className="flex items-center justify-center size-9 mb-1 text-sm rounded primary-button text-primary-cta-text">
<p>{index + 1}</p>
</div>
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
<p className="text-base md:text-lg leading-snug text-balance">{item.description}</p>
{(item.primaryButton || item.secondaryButton) && (
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" />}
</div>
)}
</div>
<div className="w-full md:w-1/2 aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default FeaturesAlternatingSplit;

View File

@@ -0,0 +1,109 @@
import { ArrowUpRight } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { useButtonClick } from "@/hooks/useButtonClick";
type FeatureItem = {
title: string;
tags: string[];
href?: string;
onClick?: () => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const ArrowButton = ({ href, onClick }: { href?: string; onClick?: () => void }) => {
const handleClick = useButtonClick(href, onClick);
return (
<a
href={href}
onClick={handleClick}
className="group/arrow flex items-center justify-center shrink-0 size-9 primary-button rounded-full cursor-pointer transition-transform duration-300 hover:scale-110"
>
<ArrowUpRight className="size-4 text-primary-cta-text transition-transform duration-300 group-hover/arrow:rotate-45" strokeWidth={2} />
</a>
);
};
interface FeaturesArrowCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesArrowCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesArrowCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded group">
<div className="relative aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
<div className="absolute top-3 right-3 xl:top-3.5 xl:right-3.5 2xl:top-4 2xl:right-4">
<ArrowButton href={item.href} onClick={item.onClick} />
</div>
</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
<div className="flex flex-wrap items-center gap-2 mt-2 md:mt-3">
{item.tags.map((itemTag) => (
<div key={itemTag} className="flex items-center h-9 px-3 text-sm card rounded">
<p>{itemTag}</p>
</div>
))}
</div>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesArrowCards;

View File

@@ -0,0 +1,100 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { resolveIcon } from "@/utils/resolve-icon";
import type { LucideIcon } from "lucide-react";
type AttributeDetail = {
icon: string | LucideIcon;
label: string;
value: string | number;
};
type FeatureItem = {
title: string;
tags: string;
badge?: string | null;
details: AttributeDetail[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesAttributeCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesAttributeCards = ({ tag, title, description, primaryButton, secondaryButton, items, textAnimation }: FeaturesAttributeCardsProps) => {
return (
<section aria-label="Features attribute cards section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-3 gap-5">
{items.map((item) => (
<div key={item.title} className="group flex flex-col gap-2 xl:gap-3 2xl:gap-4 h-full rounded">
<div className="relative aspect-4/3 overflow-hidden rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded group-hover:scale-105 transition-transform duration-500" />
{item.badge && (
<span className="absolute top-2 left-2 xl:top-3 xl:left-3 2xl:top-4 2xl:left-4 px-3 py-1 text-sm text-foreground font-medium card rounded">
{item.badge}
</span>
)}
</div>
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-semibold leading-snug">{item.title}</h3>
<p className="text-base leading-snug">{item.tags}</p>
<div className="flex items-center gap-3 text-base mt-0.5">
{item.details.map((detail) => {
const IconComponent = resolveIcon(detail.icon);
return (
<span key={detail.label} className="flex items-center gap-1">
<IconComponent className="size-[1em]" strokeWidth={1.5} />
{detail.label}: {detail.value}
</span>
);
})}
</div>
</div>
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesAttributeCards;

View File

@@ -0,0 +1,105 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
import InfoCardMarquee from "@/components/ui/InfoCardMarquee";
import TiltedStackCards from "@/components/ui/TiltedStackCards";
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
import OrbitingIcons from "@/components/ui/OrbitingIcons";
import IconTextMarquee from "@/components/ui/IconTextMarquee";
import ChatMarquee from "@/components/ui/ChatMarquee";
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
import MediaStack from "@/components/ui/MediaStack";
import type { LucideIcon } from "lucide-react";
type IconInput = string | LucideIcon;
type FeatureCard = { title: string; description: string } & (
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
| { bentoComponent: "tilted-stack-cards"; stackCards: [{ icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }, { icon: IconInput; title: string; subtitle: string; detail: string }] }
| { bentoComponent: "animated-bar-chart" }
| { bentoComponent: "orbiting-icons"; centerIcon: IconInput; orbitIcons: IconInput[] }
| { bentoComponent: "icon-text-marquee"; centerIcon: IconInput; marqueeTexts: string[] }
| { bentoComponent: "chat-marquee"; aiIcon: IconInput; userIcon: IconInput; exchanges: { userMessage: string; aiResponse: string }[]; placeholder: string }
| { bentoComponent: "checklist-timeline"; heading: string; subheading: string; checklistItems: [{ label: string; detail: string }, { label: string; detail: string }, { label: string; detail: string }]; completedLabel: string }
| { bentoComponent: "media-stack"; mediaItems: [{ imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }, { imageSrc?: string; videoSrc?: string }] }
);
const getBentoComponent = (feature: FeatureCard) => {
switch (feature.bentoComponent) {
case "info-card-marquee": return <InfoCardMarquee items={feature.infoCards} />;
case "tilted-stack-cards": return <TiltedStackCards items={feature.stackCards} />;
case "animated-bar-chart": return <AnimatedBarChart />;
case "orbiting-icons": return <OrbitingIcons centerIcon={feature.centerIcon} items={feature.orbitIcons} />;
case "icon-text-marquee": return <IconTextMarquee centerIcon={feature.centerIcon} texts={feature.marqueeTexts} />;
case "chat-marquee": return <ChatMarquee aiIcon={feature.aiIcon} userIcon={feature.userIcon} exchanges={feature.exchanges} placeholder={feature.placeholder} />;
case "checklist-timeline": return <ChecklistTimeline heading={feature.heading} subheading={feature.subheading} items={feature.checklistItems} completedLabel={feature.completedLabel} />;
case "media-stack": return <MediaStack items={feature.mediaItems} />;
}
};
const FeaturesBento = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
textAnimation,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: FeatureCard[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}) => (
<section aria-label="Features bento section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<GridOrCarousel>
{features.map((feature) => (
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded h-full">
<div className="relative h-72 overflow-hidden rounded p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5">{getBentoComponent(feature)}</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
<p className="text-base leading-snug">{feature.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
export default FeaturesBento;

View File

@@ -0,0 +1,85 @@
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import TextAnimation from "@/components/ui/TextAnimation";
import ScrollReveal from "@/components/ui/ScrollReveal";
import Button from "@/components/ui/Button";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesBentoGridProps {
tag: string;
title: string;
description: string;
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesBentoGrid = ({
tag,
title,
description,
features,
primaryButton,
secondaryButton,
textAnimation,
}: FeaturesBentoGridProps) => {
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur" className="w-content-width mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
{features.map((feature, index) => (
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
<p className="text-base leading-snug text-balance">{feature.description}</p>
</div>
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesBentoGrid;

View File

@@ -0,0 +1,114 @@
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import TextAnimation from "@/components/ui/TextAnimation";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesBentoGridCtaProps {
tag: string;
title: string;
description: string;
features: [FeatureItem, FeatureItem, FeatureItem, FeatureItem];
ctaButton?: {
text: string;
href: string;
avatarSrc?: string;
avatarLabel?: string;
};
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesBentoGridCta = ({
tag,
title,
description,
features,
ctaButton,
textAnimation,
}: FeaturesBentoGridCtaProps) => {
const colSpans = ["md:col-span-5", "md:col-span-7", "md:col-span-7", "md:col-span-5"];
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{ctaButton && (
<ScrollReveal variant="fade" delay={0.2}>
<a
href={ctaButton.href}
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
>
{ctaButton.avatarSrc && (
<div className="flex items-center">
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
<img
src={ctaButton.avatarSrc}
className="w-9 h-9 rounded-full object-cover"
alt=""
/>
</div>
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
<div className="overflow-hidden flex items-center">
<span className="text-primary-cta-text text-sm font-semibold mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
+
</span>
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
<span className="w-9 h-9 rounded-full flex items-center justify-center">
<span className="text-foreground text-xs font-bold">{ctaButton.avatarLabel || "You"}</span>
</span>
</div>
</div>
</div>
</div>
)}
<span className="text-base font-semibold whitespace-nowrap">{ctaButton.text}</span>
</a>
</ScrollReveal>
)}
</div>
<ScrollReveal variant="fade" className="w-content-width mx-auto">
<div className="grid grid-cols-1 md:grid-cols-12 gap-5">
{features.map((feature, index) => (
<div key={feature.title} className={cls(colSpans[index], "flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded")}>
<div className="h-60 xl:h-72 2xl:h-80 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
<ImageOrVideo imageSrc={feature.imageSrc} videoSrc={feature.videoSrc} />
</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
<p className="text-base leading-snug text-balance">{feature.description}</p>
</div>
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesBentoGridCta;

View File

@@ -0,0 +1,88 @@
import TextAnimation from "@/components/ui/TextAnimation";
import Button from "@/components/ui/Button";
import BorderGlow from "@/components/ui/BorderGlow";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
import type { LucideIcon } from "lucide-react";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
icon: string | LucideIcon;
title: string;
description: string;
};
interface FeaturesBorderGlowProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesBorderGlow = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
textAnimation,
}: FeaturesBorderGlowProps) => {
return (
<section aria-label="Features border glow section" className="flex flex-col gap-8 md:gap-10 py-20">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade">
<GridOrCarousel>
{features.map((feature) => {
const FeatureIcon = resolveIcon(feature.icon);
return (
<div key={feature.title} className="relative flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 mt-0.5 h-full min-h-60 md:min-h-70 2xl:min-h-80 card rounded">
<div className="flex items-center justify-center size-12 md:size-14 2xl:size-16 primary-button rounded-full">
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-semibold leading-snug text-balance">{feature.title}</h3>
<p className="text-base leading-snug text-balance">{feature.description}</p>
</div>
<BorderGlow />
</div>
);
})}
</GridOrCarousel>
</ScrollReveal>
</section>
);
};
export default FeaturesBorderGlow;

View File

@@ -0,0 +1,108 @@
import ScrollReveal from "@/components/ui/ScrollReveal";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import Button from "@/components/ui/Button";
import { cls } from "@/lib/utils";
type CarouselItem = {
label: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type FeaturesCarouselMarqueeProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: CarouselItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const TAG_STYLES = [
{ pos: "-top-3 -left-2", rotate: "-rotate-3" },
{ pos: "-bottom-3 -right-2", rotate: "rotate-3" },
{ pos: "-top-3 -right-3", rotate: "rotate-6" },
{ pos: "-bottom-3 -left-3", rotate: "-rotate-3" },
{ pos: "-top-2 left-4", rotate: "-rotate-6" },
{ pos: "-bottom-2 right-4", rotate: "rotate-3" },
];
const FeaturesCarouselMarquee = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesCarouselMarqueeProps) => {
const duplicated = [...items, ...items, ...items, ...items];
return (
<section aria-label="Features carousel section" className="pt-20 pb-14">
<div className="flex flex-col gap-2 md:gap-4">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<div className="w-full overflow-hidden mask-fade-x-medium py-6">
<div className="flex w-max animate-marquee-horizontal items-center" style={{ animationDuration: "60s" }}>
{duplicated.map((item, i) => {
const ts = TAG_STYLES[i % TAG_STYLES.length];
return (
<div
key={i}
className="relative shrink-0 w-60 md:w-65 2xl:w-70 aspect-3/4 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-visible"
>
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="w-full h-full rounded-lg object-cover"
/>
<div
className={cls(
"absolute px-5 py-2.5 rounded-full card z-10 flex items-center justify-center",
ts.pos,
ts.rotate
)}
>
<h3 className="text-base font-normal leading-none text-foreground">{item.label}</h3>
</div>
</div>
);
})}
</div>
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesCarouselMarquee;

View File

@@ -0,0 +1,87 @@
import { Check, X } from "lucide-react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ScrollReveal from "@/components/ui/ScrollReveal";
interface FeaturesComparisonProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
negativeItems: string[];
positiveItems: string[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesComparison = ({
tag,
title,
description,
primaryButton,
secondaryButton,
negativeItems,
positiveItems,
textAnimation,
}: FeaturesComparisonProps) => {
return (
<section aria-label="Features comparison section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade" className="grid grid-cols-1 md:grid-cols-2 w-content-width md:w-6/10 mx-auto gap-5">
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded opacity-50">
{negativeItems.map((item) => (
<div key={item} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 secondary-button rounded">
<X className="size-3 text-foreground" strokeWidth={2} />
</div>
<span className="text-base">{item}</span>
</div>
))}
</div>
<div className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded">
{positiveItems.map((item) => (
<div key={item} className="flex items-start gap-3">
<div className="flex items-center justify-center shrink-0 size-6 primary-button rounded">
<Check className="size-3 text-primary-cta-text" strokeWidth={2} />
</div>
<span className="text-base">{item}</span>
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesComparison;

View File

@@ -0,0 +1,96 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
tags: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesDetailedCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesDetailedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesDetailedCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col w-content-width mx-auto gap-5">
{items.map((item) => (
<ScrollReveal
variant="fade-blur"
key={item.title}
className="flex flex-col md:grid md:grid-cols-2 mx-auto gap-6 md:gap-20 p-6 md:p-10 card rounded group"
>
<div className="flex flex-col justify-between gap-2">
<h3 className="text-4xl md:text-5xl font-semibold leading-[1.15] text-balance">{item.title}</h3>
<div className="flex flex-col-reverse md:flex-col gap-3">
<div className="flex flex-wrap gap-3">
{item.tags.map((itemTag) => (
<div key={itemTag} className="px-3 py-1 text-sm card rounded w-fit">
<p>{itemTag}</p>
</div>
))}
</div>
<p className="text-lg md:text-xl leading-snug text-balance">{item.description}</p>
</div>
</div>
<div className="aspect-square md:aspect-5/4 rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
);
};
export default FeaturesDetailedCards;

View File

@@ -0,0 +1,102 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type StepItem = {
tag: string;
title: string;
subtitle: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesDetailedStepsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
steps: StepItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesDetailedSteps = ({
tag,
title,
description,
primaryButton,
secondaryButton,
steps,
textAnimation,
}: FeaturesDetailedStepsProps) => {
return (
<section aria-label="Features detailed steps section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<div className="flex flex-col w-content-width mx-auto gap-5">
{steps.map((step, index) => {
const stepNumber = String(index + 1).padStart(2, "0");
return (
<ScrollReveal
variant="fade"
key={step.title}
className="flex flex-col md:flex-row justify-between 2xl:w-8/10 mx-auto gap-6 p-6 md:p-10 card rounded overflow-hidden"
>
<div className="flex flex-col justify-between w-full md:w-1/2">
<div className="flex flex-col gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{step.tag}</p>
</div>
<h3 className="text-7xl md:text-8xl font-semibold leading-[1.15] text-balance">{step.title}</h3>
</div>
<div className="block md:hidden w-full h-px my-5 bg-accent/20" />
<div className="flex flex-col gap-2">
<h4 className="text-2xl md:text-3xl font-semibold leading-snug text-balance">{step.subtitle}</h4>
<p className="text-base md:text-lg leading-snug text-balance">{step.description}</p>
</div>
</div>
<div className="flex flex-col w-full md:w-35/100 gap-10">
<span className="hidden md:block self-end text-7xl md:text-8xl font-semibold text-accent">{stepNumber}</span>
<div className={cls("aspect-square rounded overflow-hidden", index % 2 === 0 ? "rotate-3" : "-rotate-3")}>
<ImageOrVideo imageSrc={step.imageSrc} videoSrc={step.videoSrc} />
</div>
</div>
</ScrollReveal>
);
})}
</div>
</div>
</section>
);
};
export default FeaturesDetailedSteps;

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { cls } from "@/lib/utils";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
const gridVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.05 }
},
exit: { opacity: 0 }
};
const itemVariants = {
hidden: { opacity: 0, scale: 0.9, y: 10 },
visible: {
opacity: 1,
scale: 1,
y: 0,
transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] as const }
}
};
type FilterItem = {
name: string;
category: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type FeaturesFilterGridProps = {
tag: string;
title: string;
description: string;
categories: string[];
items: FilterItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const FeaturesFilterGrid = ({
tag,
title,
description,
categories,
items,
textAnimation,
}: FeaturesFilterGridProps) => {
const allCategories = ["All", ...categories];
const [activeFilter, setActiveFilter] = useState<string>("All");
const filteredItems = items.filter(item =>
activeFilter === "All" || item.category === activeFilter
);
const handleFilter = (category: string) => {
if (category === activeFilter) return;
setActiveFilter(category);
};
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
<div role="group" className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{allCategories.map((category) => (
<button
key={category}
aria-pressed={activeFilter === category}
onClick={() => handleFilter(category)}
className="relative flex items-center justify-center h-10 px-6 text-sm rounded cursor-pointer card overflow-hidden"
>
<div
className={cls(
"absolute inset-0 primary-button rounded transition-opacity duration-300",
activeFilter === category ? "opacity-100" : "opacity-0"
)}
/>
<span className={cls(
"relative z-10 transition-colors duration-300",
activeFilter === category ? "text-primary-cta-text" : ""
)}>
{category}
</span>
</button>
))}
</div>
</div>
<div className="w-content-width mx-auto">
<AnimatePresence mode="wait">
<motion.div
key={activeFilter}
variants={gridVariants}
initial="hidden"
animate="visible"
exit="exit"
aria-live="polite"
role="list"
className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 xl:gap-4 2xl:gap-5"
>
{filteredItems.map((item) => (
<motion.div key={item.name} variants={itemVariants} role="listitem">
<div className="p-1 xl:p-2 2xl:p-3 card rounded overflow-hidden shadow-xl">
<div className="relative">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="w-full aspect-3/4 object-cover rounded"
/>
<div className="absolute bottom-1 xl:bottom-2 2xl:bottom-3 inset-x-1 xl:inset-x-2 2xl:inset-x-3 card rounded p-1 xl:p-2 2xl:p-3">
<h4 className="text-2xl md:text-3xl text-center truncate">{item.name}</h4>
</div>
</div>
</div>
</motion.div>
))}
</motion.div>
</AnimatePresence>
</div>
</div>
</section>
);
};
export default FeaturesFilterGrid;

View File

@@ -0,0 +1,119 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
descriptions: string[];
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesFlipCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeatureFlipCard = ({ item }: { item: FeatureItem }) => {
const [isFlipped, setIsFlipped] = useState(false);
return (
<div
className="relative w-full cursor-pointer perspective-[3000px]"
onClick={() => setIsFlipped(!isFlipped)}
>
<div
data-flipped={isFlipped}
className="relative w-full h-full transition-transform duration-500 transform-3d data-[flipped=true]:transform-[rotateY(180deg)]"
>
<div className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden">
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
<Plus className="size-4 text-primary-cta-text" strokeWidth={2} />
</div>
</div>
<div className="relative overflow-hidden aspect-4/5 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
</div>
</div>
<div className="absolute inset-0 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded backface-hidden transform-[rotateY(180deg)]">
<div className="flex items-start justify-between gap-5 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
<div className="flex items-center justify-center shrink-0 size-9 primary-button rounded-full">
<Plus className="size-4 rotate-45 text-primary-cta-text" strokeWidth={2} />
</div>
</div>
<div className="flex-1 flex flex-col gap-2 p-3 xl:p-3.5 2xl:p-4 bg-foreground/5 shadow shadow-foreground/5 rounded">
{item.descriptions.map((desc, index) => (
<p key={index} className="text-base md:text-lg leading-snug text-balance">{desc}</p>
))}
</div>
</div>
</div>
</div>
);
};
const FeaturesFlipCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesFlipCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<GridOrCarousel>
{items.map((item) => (
<FeatureFlipCard key={item.title} item={item} />
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesFlipCards;

View File

@@ -0,0 +1,107 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import AvatarGroup from "@/components/ui/AvatarGroup";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type BottomFeatureItem = FeatureItem & {
primaryButton: { text: string; href: string };
avatarsSrc?: string[];
avatarsLabel?: string;
};
interface FeaturesGridSplitProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
topItems: [FeatureItem, FeatureItem];
bottomItem: BottomFeatureItem;
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesGridSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
topItems,
bottomItem,
textAnimation,
}: FeaturesGridSplitProps) => {
return (
<section aria-label="Features section" className="flex flex-col gap-8 md:gap-10 py-20">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="w-content-width mx-auto flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
<ScrollReveal variant="fade" className="grid grid-cols-1 md:grid-cols-2 gap-3 xl:gap-3.5 2xl:gap-4">
{topItems.map((item) => (
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
<div className="aspect-square rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-lg leading-snug text-balance">{item.description}</p>
</div>
</div>
))}
</ScrollReveal>
<ScrollReveal variant="fade">
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
<div className="flex flex-col gap-1 justify-center md:w-1/2 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-3xl font-semibold leading-snug text-balance">{bottomItem.title}</h3>
<p className="text-lg leading-snug text-balance">{bottomItem.description}</p>
<div className="flex flex-wrap items-center gap-3 mt-2 md:mt-3">
<Button text={bottomItem.primaryButton.text} href={bottomItem.primaryButton.href} variant="primary" />
{bottomItem.avatarsSrc && bottomItem.avatarsSrc.length > 0 && (
<AvatarGroup avatarsSrc={bottomItem.avatarsSrc} size="md" label={bottomItem.avatarsLabel} />
)}
</div>
</div>
<div className="md:w-1/2 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
<ImageOrVideo imageSrc={bottomItem.imageSrc} videoSrc={bottomItem.videoSrc} />
</div>
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesGridSplit;

View File

@@ -0,0 +1,107 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import AvatarGroup from "@/components/ui/AvatarGroup";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type BottomFeatureItem = FeatureItem & {
primaryButton: { text: string; href: string };
avatarsSrc?: string[];
avatarsLabel?: string;
};
type FeaturesGridSplitLargeProps = {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
topItems: [FeatureItem, FeatureItem];
bottomItem: BottomFeatureItem;
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const FeaturesGridSplitLarge = ({
tag,
title,
description,
primaryButton,
secondaryButton,
topItems,
bottomItem,
textAnimation,
}: FeaturesGridSplitLargeProps) => {
return (
<section aria-label="Features section" className="flex flex-col gap-8 md:gap-10 py-20">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="w-content-width mx-auto flex flex-col gap-3 xl:gap-3.5 2xl:gap-4">
<ScrollReveal variant="fade" className="grid grid-cols-1 md:grid-cols-2 gap-3 xl:gap-3.5 2xl:gap-4">
{topItems.map((item) => (
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
<div className="aspect-square rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-5xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-lg leading-snug text-balance">{item.description}</p>
</div>
</div>
))}
</ScrollReveal>
<ScrollReveal variant="fade">
<div className="flex flex-col md:flex-row gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
<div className="flex flex-col gap-1 justify-center md:w-1/2 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-5xl font-semibold leading-snug text-balance">{bottomItem.title}</h3>
<p className="text-lg leading-snug text-balance">{bottomItem.description}</p>
<div className="flex flex-wrap items-center gap-3 mt-2 md:mt-3">
<Button text={bottomItem.primaryButton.text} href={bottomItem.primaryButton.href} variant="primary" />
{bottomItem.avatarsSrc && bottomItem.avatarsSrc.length > 0 && (
<AvatarGroup avatarsSrc={bottomItem.avatarsSrc} size="md" label={bottomItem.avatarsLabel} />
)}
</div>
</div>
<div className="md:w-1/2 rounded overflow-hidden bg-foreground/5 shadow shadow-foreground/5">
<ImageOrVideo imageSrc={bottomItem.imageSrc} videoSrc={bottomItem.videoSrc} />
</div>
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesGridSplitLarge;

View File

@@ -0,0 +1,89 @@
import TextAnimation from "@/components/ui/TextAnimation";
import Button from "@/components/ui/Button";
import HoverPattern from "@/components/ui/HoverPattern";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
import type { LucideIcon } from "lucide-react";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
icon: string | LucideIcon;
title: string;
description: string;
};
interface FeaturesIconCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesIconCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
textAnimation,
}: FeaturesIconCardsProps) => {
return (
<section aria-label="Features icon cards section" className="flex flex-col gap-8 md:gap-10 py-20">
<div className="flex flex-col items-center gap-2 w-content-width mx-auto">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<GridOrCarousel>
{features.map((feature) => {
const FeatureIcon = resolveIcon(feature.icon);
return (
<div key={feature.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
<HoverPattern className="flex items-center justify-center aspect-square rounded bg-foreground/5 shadow shadow-foreground/5">
<div className="relative z-10 flex items-center justify-center size-12 md:size-14 2xl:size-16 primary-button rounded shadow">
<FeatureIcon className="size-4 text-primary-cta-text" strokeWidth={1.5} />
</div>
</HoverPattern>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-2xl font-semibold leading-snug">{feature.title}</h3>
<p className="text-base leading-snug">{feature.description}</p>
</div>
</div>
);
})}
</GridOrCarousel>
</ScrollReveal>
</section>
);
};
export default FeaturesIconCards;

View File

@@ -0,0 +1,113 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
href?: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesImageBentoProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesImageBento = ({ tag, title, description, primaryButton, secondaryButton, items, textAnimation }: FeaturesImageBentoProps) => {
const gridClasses = [
"md:col-span-2",
"md:col-span-4",
"md:col-span-3",
"md:col-span-3",
"md:col-span-2",
"md:col-span-2",
"md:col-span-2",
];
const staggerDelays = [
0,
0.1,
0,
0.1,
0,
0.1,
0.2,
];
return (
<section aria-label="Features image bento section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
{items.map((item, index) => {
const content = (
<div className="relative h-80 xl:h-100 2xl:h-120 overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="rounded group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-x-5 bottom-5 xl:inset-x-6 xl:bottom-6 2xl:inset-x-7 2xl:bottom-7 flex flex-col text-background">
<span className="text-2xl font-semibold leading-snug truncate">{item.title}</span>
<span className="text-base leading-snug truncate">{item.description}</span>
</div>
</div>
);
return (
<ScrollReveal key={index} variant="fade-blur" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
{item.href ? (
<a href={item.href} className="block overflow-hidden rounded">
{content}
</a>
) : (
<div className="overflow-hidden rounded">
{content}
</div>
)}
</ScrollReveal>
);
})}
</div>
</div>
</section>
);
};
export default FeaturesImageBento;

View File

@@ -0,0 +1,83 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesMediaCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<GridOrCarousel >
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
<div className="aspect-square rounded overflow-hidden button-secondary shadow shadow-foreground/5">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h3 className="text-2xl font-semibold leading-snug">{item.title}</h3>
<p className="text-base leading-snug">{item.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesMediaCards;

View File

@@ -0,0 +1,103 @@
import type { LucideIcon } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import LoopCarousel from "@/components/ui/LoopCarousel";
import Button from "@/components/ui/Button";
import { useButtonClick } from "@/hooks/useButtonClick";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
title: string;
description: string;
buttonIcon: string | LucideIcon;
buttonHref?: string;
buttonOnClick?: () => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaCarouselProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeatureMediaCarouselCard = ({ item }: { item: FeatureItem }) => {
const handleClick = useButtonClick(item.buttonHref, item.buttonOnClick);
const Icon = resolveIcon(item.buttonIcon);
return (
<div className="relative overflow-hidden aspect-square md:aspect-3/2 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
<div className="absolute inset-x-4 bottom-4 xl:inset-x-5 xl:bottom-5 2xl:inset-x-6 2xl:bottom-6 flex items-center justify-between gap-5 p-4 xl:p-5 2xl:p-6 card rounded backdrop-blur-sm">
<div className="flex flex-col gap-1 min-w-0">
<h3 className="text-2xl font-semibold leading-snug truncate">{item.title}</h3>
<p className="text-base leading-snug truncate">{item.description}</p>
</div>
<a
href={item.buttonHref}
onClick={handleClick}
aria-label="View more"
className="flex items-center justify-center shrink-0 size-9 cursor-pointer primary-button rounded-full"
>
<Icon className="size-4 text-primary-cta-text" strokeWidth={2} />
</a>
</div>
</div>
);
};
const FeaturesMediaCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesMediaCarouselProps) => {
return (
<section aria-label="Features section" className="w-full py-20">
<div className="flex flex-col w-full gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<LoopCarousel>
{items.map((item) => (
<FeatureMediaCarouselCard key={item.title} item={item} />
))}
</LoopCarousel>
</div>
</section>
);
};
export default FeaturesMediaCarousel;

View File

@@ -0,0 +1,86 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type FeaturesMediaColumnsProps = {
tag?: string;
title: string;
description?: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const FeaturesMediaColumns = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesMediaColumnsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
{tag && (
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
)}
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
{description && (
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
)}
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-2 gap-5">
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 h-full">
<div className="aspect-square rounded overflow-hidden button-secondary shadow shadow-foreground/5">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-3xl font-semibold leading-snug">{item.title}</h3>
<p className="text-base leading-snug">{item.description}</p>
</div>
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesMediaColumns;

View File

@@ -0,0 +1,84 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaGridProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesMediaGrid = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesMediaGridProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-4 xl:gap-5 2xl:gap-6 h-full">
<div className="aspect-square overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-3xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-base leading-snug text-balance">{item.description}</p>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesMediaGrid;

View File

@@ -0,0 +1,85 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesMediaSimpleProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesMediaSimple = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesMediaSimpleProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded">
<div className="aspect-6/7 rounded overflow-hidden button-secondary shadow shadow-foreground/5">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="w-full h-full object-cover" />
</div>
<div className="p-3 xl:p-3.5 2xl:p-4">
<p className="text-2xl leading-tight">
<span className="font-semibold text-foreground">{item.title}. </span>
<span className="text-foreground/50">{item.description}</span>
</p>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesMediaSimple;

View File

@@ -0,0 +1,62 @@
import { useRef } from "react";
import { motion, useScroll, useTransform } from "motion/react";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type FeaturesParallaxShowcaseProps = {
title: string;
leftImageSrc: string;
rightImageSrc: string;
} & ({ backgroundSrc: string; backgroundVideoSrc?: never } | { backgroundVideoSrc: string; backgroundSrc?: never });
const FeaturesParallaxShowcase = ({
title,
backgroundSrc,
backgroundVideoSrc,
leftImageSrc,
rightImageSrc,
}: FeaturesParallaxShowcaseProps) => {
const sectionRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start end", "end start"],
});
const leftY = useTransform(scrollYProgress, [0, 1], ["-10%", "20%"]);
const leftRotate = useTransform(scrollYProgress, [0, 1], [-13, 8]);
const rightY = useTransform(scrollYProgress, [0, 1], ["-5%", "25%"]);
const rightRotate = useTransform(scrollYProgress, [0, 1], [6, -7]);
return (
<section
ref={sectionRef}
aria-label="Parallax showcase section"
className="relative w-full h-svh overflow-hidden my-20"
>
<div className="absolute inset-0">
<ImageOrVideo imageSrc={backgroundSrc} videoSrc={backgroundVideoSrc} className="w-full h-full object-cover" />
</div>
<div className="relative z-10 w-full pt-16 md:pt-22 text-center">
<h2 className="text-7xl md:text-8xl text-white font-serif">
{title}
</h2>
</div>
<motion.div
style={{ y: leftY, rotate: leftRotate }}
className="absolute left-[5%] md:left-[12%] top-[28%] md:top-[2%] w-[50vw] md:w-[26vw] max-w-[420px] drop-shadow-2xl pointer-events-none"
>
<ImageOrVideo imageSrc={leftImageSrc} className="w-full h-auto" />
</motion.div>
<motion.div
style={{ y: rightY, rotate: rightRotate }}
className="absolute right-[5%] md:right-[12%] top-[60%] md:top-[12%] w-[45vw] md:w-[20vw] max-w-[340px] drop-shadow-2xl pointer-events-none"
>
<ImageOrVideo imageSrc={rightImageSrc} className="w-full h-auto" />
</motion.div>
</section>
);
};
export default FeaturesParallaxShowcase;

View File

@@ -0,0 +1,112 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type ResultItem = {
treatment: string;
detail: string;
beforeSrc: string;
afterSrc: string;
};
interface FeaturesResultsComparisonProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: ResultItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const ImageLabel = ({ text, side }: { text: string; side: "left" | "right" }) => (
<div
className={cls(
"absolute bottom-3 xl:bottom-3.5 2xl:bottom-4 px-3 py-1 w-fit text-sm card rounded",
side === "left" ? "left-3 xl:left-3.5 2xl:left-4" : "right-3 xl:right-3.5 2xl:right-4"
)}
>
<p className="font-medium text-foreground">{text}</p>
</div>
);
const FeaturesResultsComparison = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesResultsComparisonProps) => {
const duplicated = [...items, ...items, ...items, ...items];
return (
<section aria-label="Results section" className="pt-20 pb-10">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<div className="w-content-width mx-auto overflow-hidden mask-fade-x-medium">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
{duplicated.map((item, i) => (
<div key={i} className="shrink-0 w-80 md:w-120 2xl:w-140 mb-10 mr-3 md:mr-5 flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 card rounded">
<div className="relative flex w-full aspect-3/2">
<div className="relative overflow-hidden w-1/2 rounded-l-lg rounded-r-none">
<ImageOrVideo imageSrc={item.beforeSrc} className="absolute inset-0 object-cover w-full h-full rounded-l rounded-r-none" />
<ImageLabel text="Before" side="left" />
</div>
<div className="absolute z-10 left-1/2 top-0 bottom-0 w-0.5 bg-background -translate-x-1/2" />
<div className="relative overflow-hidden w-1/2 rounded-r-lg rounded-l-none">
<ImageOrVideo imageSrc={item.afterSrc} className="absolute inset-0 object-cover w-full h-full rounded-r rounded-l-none" />
<ImageLabel text="After" side="right" />
</div>
</div>
<div className="flex flex-col gap-1 p-3 xl:p-3.5 2xl:p-4">
<h4 className="truncate text-2xl font-semibold leading-snug">
{item.treatment}
</h4>
<p className="text-base leading-snug">
{item.detail}
</p>
</div>
</div>
))}
</div>
</div>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesResultsComparison;

View File

@@ -0,0 +1,105 @@
import { Info } from "lucide-react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import Button from "@/components/ui/Button";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesRevealCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesRevealCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesRevealCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary" />}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="slide-up">
<GridOrCarousel>
{items.map((item, index) => (
<div key={item.title} className="group relative overflow-hidden aspect-6/7 rounded">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="absolute inset-0" />
<div className="absolute top-4 left-4 xl:top-6 xl:left-6 2xl:top-8 2xl:left-8 z-20 perspective-[1000px]">
<div className="relative size-8 transform-3d transition-transform duration-400 group-hover:rotate-y-180">
<div className="absolute inset-0 flex items-center justify-center text-sm rounded bg-background backface-hidden text-foreground">
<p>{index + 1}</p>
</div>
<div className="absolute inset-0 flex items-center justify-center rounded bg-background backface-hidden rotate-y-180">
<Info className="h-1/2 w-1/2 text-foreground" strokeWidth={1.5} />
</div>
</div>
</div>
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-2 bottom-2 xl:inset-x-3 xl:bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
<div className="relative flex flex-col gap-0 group-hover:gap-1 xl:group-hover:gap-2 2xl:group-hover:gap-3 p-2 xl:p-3 2xl:p-4 transition-all duration-400">
<div className="absolute inset-0 -z-10 card rounded translate-y-full opacity-0 transition-all duration-400 ease-out group-hover:translate-y-0 group-hover:opacity-100" />
<h3 className="text-2xl font-semibold leading-snug text-white transition-colors duration-400 group-hover:text-foreground">
{item.title}
</h3>
<div className="grid grid-rows-[0fr] transition-all duration-400 ease-out group-hover:grid-rows-[1fr]">
<p className="overflow-hidden text-sm leading-snug text-foreground opacity-0 transition-opacity duration-400 group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesRevealCards;

View File

@@ -0,0 +1,110 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type FeatureItem = {
title: string;
description: string;
href: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesRevealCardsBentoProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesRevealCardsBento = ({ tag, title, description, primaryButton, secondaryButton, items, textAnimation }: FeaturesRevealCardsBentoProps) => {
const gridClasses = [
"md:col-span-2",
"md:col-span-4",
"md:col-span-3",
"md:col-span-3",
"md:col-span-2",
"md:col-span-2",
"md:col-span-2",
];
const staggerDelays = [
0,
0.1,
0,
0.1,
0,
0.1,
0.2,
];
return (
<section aria-label="Features reveal cards bento section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="w-content-width mx-auto grid grid-cols-1 md:grid-cols-6 gap-3">
{items.map((item, index) => (
<ScrollReveal key={item.title} variant="fade" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
<a href={item.href} className="block relative overflow-hidden rounded">
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="rounded group-hover:scale-105 transition-transform duration-500"
/>
</div>
<div className="absolute -inset-x-px -bottom-px h-2/5 backdrop-blur-xl mask-fade-top-overlay" aria-hidden="true" />
<div className="absolute inset-x-3 bottom-3 2xl:inset-x-4 2xl:bottom-4 z-10">
<div className="relative flex flex-col gap-1 md:gap-0 md:group-hover:gap-1 p-3 2xl:p-4 transition-all duration-400">
<div className="absolute inset-0 -z-10 card rounded translate-y-0 opacity-100 md:translate-y-full md:opacity-0 transition-all duration-400 ease-out md:group-hover:translate-y-0 md:group-hover:opacity-100" />
<h3 className="text-2xl font-semibold leading-snug text-foreground md:text-white transition-colors duration-400 md:group-hover:text-foreground">
{item.title}
</h3>
<div className="grid grid-rows-[1fr] md:grid-rows-[0fr] transition-all duration-400 ease-out md:group-hover:grid-rows-[1fr]">
<p className="overflow-hidden text-base leading-snug text-foreground opacity-100 md:opacity-0 transition-opacity duration-400 md:group-hover:opacity-100">
{item.description}
</p>
</div>
</div>
</div>
</a>
</ScrollReveal>
))}
</div>
</div>
</section>
);
};
export default FeaturesRevealCardsBento;

View File

@@ -0,0 +1,233 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Button from "@/components/ui/Button";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { cls } from "@/lib/utils";
gsap.registerPlugin(ScrollTrigger);
type FeatureItem = {
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
} & (
| { leftImageSrc: string; leftVideoSrc?: never }
| { leftVideoSrc: string; leftImageSrc?: never }
) & (
| { rightImageSrc: string; rightVideoSrc?: never }
| { rightVideoSrc: string; rightImageSrc?: never }
);
interface FeaturesStickyCardsProps {
items: FeatureItem[];
}
const CardFrame = ({
imageSrc,
videoSrc,
cardRef,
className = "",
}: {
imageSrc?: string;
videoSrc?: string;
cardRef: (el: HTMLDivElement | null) => void;
className?: string;
}) => (
<div ref={cardRef} className={cls("card rounded p-1 overflow-hidden", className)}>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="w-full h-full object-cover rounded"
/>
</div>
);
const FeaturesStickyCards = ({
items,
}: FeaturesStickyCardsProps) => {
const imageRefs = useRef<(HTMLDivElement | null)[]>([]);
const mobileImageRefs = useRef<(HTMLDivElement | null)[]>([]);
const triggerRefs = useRef<(HTMLDivElement | null)[]>([]);
useLayoutEffect(() => {
const mm = gsap.matchMedia();
const getAnimationConfig = (itemIndex: number, isLeftCard: boolean) => {
const isOddItem = itemIndex % 2 === 1;
if (isLeftCard) {
return {
from: { xPercent: -225, rotation: -45 },
to: { rotation: isOddItem ? 10 : -10 },
};
} else {
return {
from: { xPercent: 225, rotation: 45 },
to: { rotation: isOddItem ? -10 : 10 },
};
}
};
const animateCards = (isMobile: boolean) => {
items.forEach((_, itemIndex) => {
[0, 1].forEach((cardIndex) => {
const refIndex = itemIndex * 2 + cardIndex;
const element = isMobile
? mobileImageRefs.current[refIndex]
: imageRefs.current[refIndex];
if (element) {
const isLeftCard = cardIndex === 0;
const fromConfig = isMobile
? {
xPercent: isLeftCard ? -150 : 150,
rotation: isLeftCard ? -25 : 25,
}
: getAnimationConfig(itemIndex, isLeftCard).from;
const toConfig = isMobile
? {
xPercent: 0,
rotation: 0,
duration: 1,
scrollTrigger: {
trigger: element,
start: "top 90%",
end: "top 50%",
scrub: 1,
},
}
: {
xPercent: 0,
rotation: getAnimationConfig(itemIndex, isLeftCard).to.rotation,
scrollTrigger: {
trigger: triggerRefs.current[itemIndex],
start: "top bottom",
end: "top top",
scrub: 1,
},
};
gsap.fromTo(element, fromConfig, toConfig);
}
});
});
};
mm.add("(max-width: 767px)", () => animateCards(true));
mm.add("(min-width: 768px)", () => animateCards(false));
return () => {
mm.revert();
imageRefs.current = [];
mobileImageRefs.current = [];
triggerRefs.current = [];
};
}, [items]);
const sectionHeightStyle = { height: `${items.length * 100}vh` };
return (
<section aria-label="Features sticky cards section" className="py-20 overflow-hidden md:overflow-visible">
<div className="flex flex-col gap-8">
<div className="hidden md:flex relative" style={sectionHeightStyle}>
<div
className="absolute top-0 left-0 flex flex-col w-6/10 mx-auto right-0 z-10"
style={sectionHeightStyle}
>
{items.map((item, index) => (
<div
key={index}
ref={(el) => { triggerRefs.current[index] = el; }}
className="w-full mx-auto h-screen flex justify-center items-center"
>
<div className="flex flex-col items-center gap-2">
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
<p>{index + 1}</p>
</div>
<h3 className="text-5xl md:text-6xl font-semibold text-center text-balance">{item.title}</h3>
<p className="md:max-w-6/10 text-lg leading-snug text-center">{item.description}</p>
{(item.primaryButton || item.secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
</div>
))}
</div>
<div className="sticky top-0 left-0 h-screen w-full overflow-hidden">
{items.map((item, itemIndex) => (
<div key={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">
<CardFrame
imageSrc={item.leftImageSrc}
videoSrc={item.leftVideoSrc}
cardRef={(el) => {
imageRefs.current[itemIndex * 2] = el;
}}
className="w-25/100 xl:w-27/100 2xl:w-29/100 h-[70vh]"
/>
<CardFrame
imageSrc={item.rightImageSrc}
videoSrc={item.rightVideoSrc}
cardRef={(el) => {
imageRefs.current[itemIndex * 2 + 1] = el;
}}
className="w-25/100 xl:w-27/100 2xl:w-28/100 h-[70vh]"
/>
</div>
</div>
))}
</div>
</div>
<div className="md:hidden flex flex-col gap-20 w-content-width mx-auto">
{items.map((item, itemIndex) => (
<div key={itemIndex} className="flex flex-col gap-8">
<div className="flex flex-col items-center gap-2">
<div className="flex flex-col items-center justify-center text-sm card rounded h-8 w-8 mb-1">
<p>{itemIndex + 1}</p>
</div>
<h3 className="text-4xl md:text-5xl font-semibold text-center text-balance">{item.title}</h3>
<p className="text-base md:text-lg leading-snug text-center">{item.description}</p>
{(item.primaryButton || item.secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{item.primaryButton && <Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" />}
{item.secondaryButton && <Button text={item.secondaryButton.text} href={item.secondaryButton.href} variant="secondary" animationDelay={0.1} />}
</div>
)}
</div>
<div className="flex flex-row gap-3 justify-center">
<CardFrame
imageSrc={item.leftImageSrc}
videoSrc={item.leftVideoSrc}
cardRef={(el) => {
mobileImageRefs.current[itemIndex * 2] = el;
}}
className="w-1/2 aspect-9/16"
/>
<CardFrame
imageSrc={item.rightImageSrc}
videoSrc={item.rightVideoSrc}
cardRef={(el) => {
mobileImageRefs.current[itemIndex * 2 + 1] = el;
}}
className="w-1/2 aspect-9/16"
/>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default FeaturesStickyCards;

View File

@@ -0,0 +1,91 @@
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import GridOrCarousel from "@/components/ui/GridOrCarousel";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FeatureItem = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesTaggedCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesTaggedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesTaggedCardsProps) => {
return (
<section aria-label="Features section" className="py-20">
<div className="flex flex-col gap-8 md:gap-10">
<div className="flex flex-col items-center w-content-width mx-auto gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<ScrollReveal variant="fade-blur">
<GridOrCarousel>
{items.map((item) => (
<div key={item.title} className="flex flex-col gap-3 xl:gap-3.5 2xl:gap-4 p-3 xl:p-3.5 2xl:p-4 h-full card rounded group">
<div className="relative aspect-square rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="transition-transform duration-500 ease-in-out group-hover:scale-105" />
<div className="absolute top-3 right-3 xl:top-3.5 xl:right-3.5 2xl:top-4 2xl:right-4 px-3 py-1 text-sm card rounded w-fit">
<p>{item.tag}</p>
</div>
</div>
<div className="flex flex-col justify-between flex-1 gap-1 p-3 xl:p-3.5 2xl:p-4">
<div className="flex flex-col gap-1">
<h3 className="text-2xl font-semibold leading-snug text-balance">{item.title}</h3>
<p className="text-base leading-snug text-balance">{item.description}</p>
</div>
<Button text={item.primaryButton.text} href={item.primaryButton.href} variant="primary" className="w-full mt-2 md:mt-3" />
</div>
</div>
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesTaggedCards;

View File

@@ -0,0 +1,123 @@
import { useState, useEffect, useRef } from "react";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import Transition from "@/components/ui/Transition";
import Button from "@/components/ui/Button";
type FeatureItem = {
title: string;
description: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
interface FeaturesTimelineCardsProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: [FeatureItem, FeatureItem, FeatureItem];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const FeaturesTimelineCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: FeaturesTimelineCardsProps) => {
const [activeIndex, setActiveIndex] = useState(0);
const [progress, setProgress] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
setProgress(0);
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => {
setProgress((prev) => (prev >= 100 ? 0 : prev + 1));
}, 50);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [activeIndex]);
useEffect(() => {
if (progress === 100) {
setActiveIndex((i) => (i + 1) % items.length);
}
}, [progress, items.length]);
const handleCardClick = (index: number) => {
if (index !== activeIndex) setActiveIndex(index);
};
return (
<section aria-label="Features timeline section" className="py-20">
<div className="flex flex-col w-content-width mx-auto gap-8 md:gap-10">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
{(primaryButton || secondaryButton) && (
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
{primaryButton && <Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>}
{secondaryButton && <Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />}
</div>
)}
</div>
<Transition className="flex flex-col gap-5">
<div className="relative aspect-square md:aspect-10/4 overflow-hidden card rounded">
<Transition key={activeIndex} transitionType="fade" className="absolute inset-px overflow-hidden rounded">
<ImageOrVideo imageSrc={items[activeIndex].imageSrc} videoSrc={items[activeIndex].videoSrc} className="absolute inset-0" />
</Transition>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
{items.map((item, index) => (
<div
key={item.title}
data-active={index === activeIndex}
onClick={() => handleCardClick(index)}
className="flex flex-col justify-between gap-4 xl:gap-5 2xl:gap-6 p-6 xl:p-7 2xl:p-8 card rounded transition-opacity duration-300 opacity-50 data-[active=true]:opacity-100 cursor-pointer data-[active=true]:cursor-default hover:opacity-75 data-[active=true]:hover:opacity-100"
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-center size-8 primary-button rounded">
<span className="text-sm font-medium text-primary-cta-text">{index + 1}</span>
</div>
<h3 className="mt-1 text-3xl font-medium leading-tight text-balance">{item.title}</h3>
<p className="text-base leading-tight text-balance">{item.description}</p>
</div>
<div className="relative w-full h-px overflow-hidden">
<div className="absolute inset-0 bg-foreground/20" />
<div className="absolute inset-y-0 left-0 bg-foreground transition-[width] duration-100" style={{ width: index === activeIndex ? `${progress}%` : index < activeIndex ? "100%" : "0%" }} />
</div>
</div>
))}
</div>
</Transition>
</div>
</section>
);
};
export default FeaturesTimelineCards;

View File

@@ -0,0 +1,65 @@
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
title: string;
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBasic = ({
columns,
leftText,
rightText,
}: {
columns: FooterColumn[];
leftText: string;
rightText: string;
}) => {
return (
<footer
data-section="footer"
aria-label="Site footer"
className="w-full pt-20 pb-10"
>
<div className="w-content-width mx-auto pt-10 border-t border-foreground/15">
<div className="w-full flex flex-wrap justify-between gap-y-10 mb-10">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
<div className="w-full h-px bg-foreground/20" />
<div className="w-full flex items-center justify-between pt-5">
<span className="text-sm opacity-50">{leftText}</span>
<span className="text-sm opacity-50">{rightText}</span>
</div>
</div>
</footer>
);
};
export default FooterBasic;

View File

@@ -0,0 +1,67 @@
import { ChevronRight } from "lucide-react";
import { useButtonClick } from "@/hooks/useButtonClick";
import AutoFillText from "@/components/ui/AutoFillText";
import { cls } from "@/lib/utils";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<div className="flex items-center gap-2 text-base">
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
<button
onClick={handleClick}
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
</div>
);
};
const FooterBrand = ({
brand,
columns,
}: {
brand: string;
columns: FooterColumn[];
}) => {
return (
<footer
data-section="footer"
aria-label="Site footer"
className="w-full py-15 mt-20 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
>
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
<AutoFillText className="font-semibold">{brand}</AutoFillText>
<div
className={cls(
"flex flex-col gap-8 mb-10 md:flex-row",
columns.length === 1 ? "md:justify-center" : "md:justify-between"
)}
>
{columns.map((column, index) => (
<div key={index} className="flex flex-col items-start gap-3">
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
</footer>
);
};
export default FooterBrand;

View File

@@ -0,0 +1,102 @@
import { useRef, useEffect, useState } from "react";
import { ChevronRight } from "lucide-react";
import { useButtonClick } from "@/hooks/useButtonClick";
import AutoFillText from "@/components/ui/AutoFillText";
import { cls } from "@/lib/utils";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<div className="flex items-center gap-2 text-base">
<ChevronRight className="size-4" strokeWidth={3} aria-hidden="true" />
<button
onClick={handleClick}
className="text-base text-primary-cta-text font-semibold hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
</div>
);
};
const FooterBrandReveal = ({
brand,
columns,
}: {
brand: string;
columns: FooterColumn[];
}) => {
const footerRef = useRef<HTMLDivElement>(null);
const [footerHeight, setFooterHeight] = useState(0);
useEffect(() => {
const updateHeight = () => {
if (footerRef.current) {
setFooterHeight(footerRef.current.offsetHeight);
}
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
if (footerRef.current) {
resizeObserver.observe(footerRef.current);
}
return () => resizeObserver.disconnect();
}, []);
return (
<section
className="relative z-0 w-full mt-20"
style={{
height: footerHeight ? `${footerHeight}px` : "auto",
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
}}
>
<div
className="fixed bottom-0 w-full"
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
>
<footer
data-section="footer"
ref={footerRef}
aria-label="Site footer"
className="w-full py-15 rounded-t-lg overflow-hidden primary-button text-primary-cta-text"
>
<div className="w-content-width mx-auto flex flex-col gap-10 md:gap-20">
<AutoFillText className="font-semibold">{brand}</AutoFillText>
<div
className={cls(
"flex flex-col gap-8 mb-10 md:flex-row",
columns.length === 1 ? "md:justify-center" : "md:justify-between"
)}
>
{columns.map((column, index) => (
<div key={index} className="flex flex-col items-start gap-3">
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
</footer>
</div>
</section>
);
};
export default FooterBrandReveal;

View File

@@ -0,0 +1,57 @@
import type { LucideIcon } from "lucide-react";
import { useButtonClick } from "@/hooks/useButtonClick";
import AutoFillText from "@/components/ui/AutoFillText";
import { resolveIcon } from "@/utils/resolve-icon";
type SocialLink = {
icon: string | LucideIcon;
href?: string;
onClick?: () => void;
};
const SocialLinkItem = ({ icon, href, onClick }: SocialLink) => {
const Icon = resolveIcon(icon);
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="flex items-center justify-center size-10 rounded-full primary-button text-primary-cta-text cursor-pointer"
>
<Icon className="size-4" strokeWidth={1.5} />
</button>
);
};
const FooterMinimal = ({
brand,
copyright,
socialLinks,
}: {
brand: string;
copyright: string;
socialLinks?: SocialLink[];
}) => {
return (
<footer data-section="footer" aria-label="Site footer" className="relative w-full py-20">
<div className="flex flex-col w-content-width mx-auto px-10 pb-5 rounded-lg card">
<AutoFillText className="font-semibold" paddingY="py-5">{brand}</AutoFillText>
<div className="h-px w-full mb-5 bg-foreground/50" />
<div className="flex flex-col gap-3 items-center justify-between md:flex-row">
<span className="text-base opacity-75">{copyright}</span>
{socialLinks && socialLinks.length > 0 && (
<div className="flex items-center gap-3">
{socialLinks.map((link, index) => (
<SocialLinkItem key={index} icon={link.icon} href={link.href} onClick={link.onClick} />
))}
</div>
)}
</div>
</div>
</footer>
);
};
export default FooterMinimal;

View File

@@ -0,0 +1,82 @@
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterColumn = {
title: string;
items: { label: string; href?: string; onClick?: () => void }[];
};
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterSimple = ({
brand,
columns,
copyright,
links,
}: {
brand: string;
columns: FooterColumn[];
copyright: string;
links: FooterLink[];
}) => {
return (
<footer data-section="footer" aria-label="Site footer" className="w-full py-15 mt-20 primary-button text-primary-cta-text">
<div className="w-content-width mx-auto">
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
<h2 className="text-4xl font-semibold">{brand}</h2>
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
<span className="text-sm opacity-50">{copyright}</span>
<div className="flex items-center gap-3">
{links.map((link) => (
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
))}
</div>
</div>
</div>
</footer>
);
};
export default FooterSimple;

View File

@@ -0,0 +1,82 @@
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
title: string;
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterSimpleCard = ({
brand,
columns,
copyright,
links,
}: {
brand: string;
columns: FooterColumn[];
copyright: string;
links: FooterLink[];
}) => {
return (
<footer data-section="footer" aria-label="Site footer" className="w-full py-20">
<div className="w-content-width mx-auto p-10 rounded-lg card">
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
<h2 className="text-4xl font-semibold">{brand}</h2>
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-foreground/20">
<span className="text-sm opacity-50">{copyright}</span>
<div className="flex items-center gap-3">
{links.map((link) => (
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
))}
</div>
</div>
</div>
</footer>
);
};
export default FooterSimpleCard;

View File

@@ -0,0 +1,95 @@
import { useButtonClick } from "@/hooks/useButtonClick";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
type FooterColumn = {
title: string;
items: FooterLink[];
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterSimpleMedia = ({
imageSrc,
videoSrc,
brand,
columns,
copyright,
links,
}: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }) & {
brand: string;
columns: FooterColumn[];
copyright: string;
links: FooterLink[];
}) => {
return (
<footer data-section="footer" aria-label="Site footer" className="relative w-full mt-20 overflow-hidden">
<div className="w-full aspect-square md:aspect-16/6 mask-fade-top-long">
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="w-full h-full object-cover rounded-none!"
/>
</div>
<div className="w-full py-15 primary-button text-primary-cta-text">
<div className="w-content-width mx-auto">
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
<h2 className="text-4xl font-semibold">{brand}</h2>
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
<span className="text-sm opacity-50">{copyright}</span>
<div className="flex items-center gap-3">
{links.map((link) => (
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
))}
</div>
</div>
</div>
</div>
</footer>
);
};
export default FooterSimpleMedia;

View File

@@ -0,0 +1,121 @@
import { useRef, useEffect, useState } from "react";
import { useButtonClick } from "@/hooks/useButtonClick";
type FooterColumn = {
title: string;
items: { label: string; href?: string; onClick?: () => void }[];
};
type FooterLink = {
label: string;
href?: string;
onClick?: () => void;
};
const FooterLinkItem = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-base text-primary-cta-text hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterBottomLink = ({ label, href, onClick }: FooterLink) => {
const handleClick = useButtonClick(href, onClick);
return (
<button
onClick={handleClick}
className="text-sm opacity-50 hover:opacity-75 transition-opacity cursor-pointer"
>
{label}
</button>
);
};
const FooterSimpleReveal = ({
brand,
columns,
copyright,
links,
}: {
brand: string;
columns: FooterColumn[];
copyright: string;
links: FooterLink[];
}) => {
const footerRef = useRef<HTMLDivElement>(null);
const [footerHeight, setFooterHeight] = useState(0);
useEffect(() => {
const updateHeight = () => {
if (footerRef.current) {
setFooterHeight(footerRef.current.offsetHeight);
}
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
if (footerRef.current) {
resizeObserver.observe(footerRef.current);
}
return () => resizeObserver.disconnect();
}, []);
return (
<section
className="relative z-0 w-full mt-20"
style={{
height: footerHeight ? `${footerHeight}px` : "auto",
clipPath: "polygon(0% 0, 100% 0%, 100% 100%, 0 100%)",
}}
>
<div
className="fixed bottom-0 w-full"
style={{ height: footerHeight ? `${footerHeight}px` : "auto" }}
>
<footer
data-section="footer"
ref={footerRef}
aria-label="Site footer"
className="w-full py-15 primary-button text-primary-cta-text"
>
<div className="w-content-width mx-auto">
<div className="flex flex-col md:flex-row gap-10 md:gap-0 justify-between items-start mb-10">
<h2 className="text-4xl font-semibold">{brand}</h2>
<div className="w-full md:w-fit flex flex-wrap gap-y-10 md:gap-12">
{columns.map((column) => (
<div key={column.title} className="w-1/2 md:w-auto flex flex-col items-start gap-3">
<h3 className="text-sm opacity-50 truncate">{column.title}</h3>
{column.items.map((item) => (
<FooterLinkItem key={item.label} label={item.label} href={item.href} onClick={item.onClick} />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between pt-8 border-t border-primary-cta-text/20">
<span className="text-sm opacity-50">{copyright}</span>
<div className="flex items-center gap-3">
{links.map((link) => (
<FooterBottomLink key={link.label} label={link.label} href={link.href} onClick={link.onClick} />
))}
</div>
</div>
</div>
</footer>
</div>
</section>
);
};
export default FooterSimpleReveal;

View File

@@ -0,0 +1,74 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import AvatarGroup from "@/components/ui/AvatarGroup";
type HeroBillboardProps = {
tag?: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
avatarsSrc?: string[];
avatarsLabel?: string;
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboard = ({
tag,
title,
description,
primaryButton,
secondaryButton,
avatarsSrc,
avatarsLabel,
imageSrc,
videoSrc,
textAnimation,
}: HeroBillboardProps) => {
return (
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
<HeroBackgroundSlot />
<div className="flex flex-col gap-12 md:gap-15 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
{avatarsSrc && avatarsSrc.length > 0 ? (
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} className="mb-1" />
) : tag ? (
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
) : null}
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
</div>
<ScrollReveal variant="fade" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
</ScrollReveal>
</div>
</section>
);
};
export default HeroBillboard;

View File

@@ -0,0 +1,54 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
import ScrollReveal from "@/components/ui/ScrollReveal";
type HeroBillboardBrandProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboardBrand = ({
brand,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
textAnimation,
}: HeroBillboardBrandProps) => {
return (
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
<HeroBackgroundSlot />
<div className="flex flex-col gap-10 md:gap-12 w-content-width mx-auto">
<div className="flex flex-col items-end gap-5">
<AutoFillText className="w-full font-semibold" paddingY="">{brand}</AutoFillText>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl leading-snug text-balance text-right"
/>
<div className="flex flex-wrap justify-end gap-3 mt-1 md:mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
</ScrollReveal>
</div>
</section>
);
};
export default HeroBillboardBrand;

View File

@@ -0,0 +1,102 @@
import { useRef } from "react";
import { motion, useScroll, useTransform } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FloatingCard = {
name: string;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const CARD_CONFIG = [
{ position: "left", bottom: "30%", aspect: "aspect-3/4", movement: "-100%" },
{ position: "right", bottom: "14%", aspect: "aspect-square", movement: "-75%" },
] as const;
type HeroBillboardBrandFloatingCardsProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
floatingCards: [FloatingCard, FloatingCard];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboardBrandFloatingCards = ({
brand,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
floatingCards,
textAnimation,
}: HeroBillboardBrandFloatingCardsProps) => {
const heroRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: heroRef,
offset: ["start start", "end start"],
});
const leftCardY = useTransform(scrollYProgress, [0, 1], ["0%", CARD_CONFIG[0].movement]);
const rightCardY = useTransform(scrollYProgress, [0, 1], ["0%", CARD_CONFIG[1].movement]);
const cardYTransforms = [leftCardY, rightCardY];
return (
<section ref={heroRef} aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
<HeroBackgroundSlot />
{floatingCards.map((card, i) => (
<motion.div
key={i}
style={{ y: cardYTransforms[i], bottom: CARD_CONFIG[i].bottom }}
className={cls(
"absolute z-10 w-28 xl:w-48 2xl:w-52 p-1 xl:p-2 2xl:p-3 card rounded overflow-hidden shadow-xl",
CARD_CONFIG[i].position === "left"
? "left-[calc((100vw-var(--width-content-width))/2)] translate-x-1/4 lg:-translate-x-1/2"
: "right-[calc((100vw-var(--width-content-width))/2)] -translate-x-1/4 lg:translate-x-1/2"
)}
>
<div className="relative">
<ImageOrVideo
imageSrc={card.imageSrc}
videoSrc={card.videoSrc}
className={cls("w-full object-cover rounded", CARD_CONFIG[i].aspect)}
/>
<div className="absolute bottom-1 xl:bottom-2 2xl:bottom-3 inset-x-1 xl:inset-x-2 2xl:inset-x-3 card rounded p-1 xl:p-2 2xl:p-3">
<h3 className="text-2xl md:text-3xl text-center truncate">{card.name}</h3>
</div>
</div>
</motion.div>
))}
<div className="flex flex-col gap-10 md:gap-12 w-content-width mx-auto">
<div className="flex flex-col items-end gap-5">
<AutoFillText className="w-full font-semibold" paddingY="">{brand}</AutoFillText>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl leading-snug text-balance text-right"
/>
<div className="flex flex-wrap justify-end gap-3 mt-1 md:mt-2">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
</div>
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5 md:aspect-video" />
</ScrollReveal>
</div>
</section>
);
};
export default HeroBillboardBrandFloatingCards;

View File

@@ -0,0 +1,77 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroBillboardCarouselProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const HeroBillboardCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: HeroBillboardCarouselProps) => {
const duplicated = [...items, ...items, ...items, ...items];
return (
<section
aria-label="Hero section"
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
>
<HeroBackgroundSlot />
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
{duplicated.map((item, i) => (
<div key={i} className="shrink-0 w-60 md:w-75 2xl:w-80 aspect-4/5 mr-3 md:mr-5 p-2 xl:p-3 2xl:p-4 card rounded-lg overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="w-full h-full rounded-lg object-cover"
/>
</div>
))}
</div>
</div>
</section>
);
};
export default HeroBillboardCarousel;

View File

@@ -0,0 +1,141 @@
import { motion } from "motion/react";
import { Check } from "lucide-react";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type CreatorVideo = {
videoSrc: string;
name: string;
followers: string;
imageSrc: string;
};
type HeroBillboardCreatorProps = {
tag: string;
title: string;
titleHighlight?: string;
description: string;
primaryButton: { text: string; href: string };
note: string;
videos: CreatorVideo[];
badgeText: string;
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const HeroBillboardCreator = ({
tag,
title,
titleHighlight,
description,
primaryButton,
note,
videos,
badgeText,
textAnimation,
}: HeroBillboardCreatorProps) => {
const duplicated = [...videos, ...videos, ...videos, ...videos];
return (
<section
aria-label="Hero section"
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
>
<HeroBackgroundSlot />
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<div className="mb-1 px-3 py-1 w-fit text-sm card rounded">
<p>{tag}</p>
</div>
<motion.h1
className="md:max-w-8/10 text-7xl 2xl:text-8xl font-semibold leading-[1.15] text-balance"
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-20%" }}
transition={{ staggerChildren: 0.04 }}
>
<motion.span
className="inline pb-[0.1em] -mb-[0.1em] bg-linear-to-r from-foreground to-primary-cta bg-clip-text text-transparent"
variants={{ hidden: { opacity: 0, y: "50%" }, visible: { opacity: 1, y: 0 } }}
transition={{ duration: 0.6, ease: [0.25, 0.46, 0.45, 0.94] }}
>
{title}{" "}
{titleHighlight && (
<span className="italic" style={{ fontFamily: "'Playfair Display', serif" }}>
{titleHighlight}
</span>
)}
</motion.span>
</motion.h1>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex justify-center mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} />
</div>
<motion.div
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<span className="flex items-center gap-2">
<div className="flex items-center justify-center size-4 primary-button rounded-full">
<Check className="size-1/2 text-primary-cta-text" />
</div>
{note}
</span>
</motion.div>
</div>
<div className="w-content-width mx-auto overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "60s" }}>
{duplicated.map((video, i) => (
<div
key={i}
className="relative shrink-0 mr-3 xl:mr-4 2xl:mr-5 w-60 md:w-75 2xl:w-80 aspect-4/5 overflow-hidden rounded-lg"
>
<div className="absolute z-10 top-3 left-3 xl:top-4 xl:left-4 2xl:top-5 2xl:left-5 px-2 py-1 xl:px-2.5 xl:py-1.5 2xl:px-3 2xl:py-2 text-xs font-medium rounded-sm border border-background/30 bg-background/50 backdrop-blur-md">
{badgeText}
</div>
<ImageOrVideo
videoSrc={video.videoSrc}
className="w-full h-full object-cover"
/>
<div
className="absolute -inset-x-px -bottom-px h-1/3 bg-background-accent/50 backdrop-blur-xl"
style={{ maskImage: "linear-gradient(to bottom, transparent, black 60%)" }}
aria-hidden="true"
/>
<div className="absolute flex items-center inset-x-3 bottom-3 xl:inset-x-4 xl:bottom-4 2xl:inset-x-5 2xl:bottom-5 gap-2 xl:gap-2.5 2xl:gap-3">
<ImageOrVideo
imageSrc={video.imageSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col min-w-0">
<span className="flex items-center gap-1 text-base text-background font-semibold leading-snug truncate">
{video.name}
<img src="https://storage.googleapis.com/webild/default/templates/ai-ugc/verified-badge.webp" alt="Verified" className="shrink-0 h-[calc(var(--text-base)*1.25)] w-auto" />
</span>
<span className="text-base text-background/75 leading-snug truncate">{video.followers}</span>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default HeroBillboardCreator;

View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import ActiveBadge from "@/components/ui/ActiveBadge";
import { resolveIcon } from "@/utils/resolve-icon";
type FeatureItem = {
icon: string | LucideIcon;
title: string;
description: string;
};
type HeroBillboardFeaturesProps = {
badge: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
features: FeatureItem[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroBillboardFeatures = ({
badge,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
features,
textAnimation,
}: HeroBillboardFeaturesProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (features.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % features.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [features.length]);
const feature = features[currentIndex];
const FeatureIcon = resolveIcon(feature.icon);
return (
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
<HeroBackgroundSlot />
<div className="flex flex-col gap-12 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<ActiveBadge text={badge} className="mb-1" />
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
</div>
<ScrollReveal variant="fade" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute top-4 right-4 xl:top-6 xl:right-6 2xl:top-8 2xl:right-8 max-w-xs p-2 xl:p-3 2xl:p-4 card rounded flex flex-col gap-2"
>
<FeatureIcon className="size-5 text-accent mb-0.5" strokeWidth={1.5} />
<p className="text-base font-medium leading-snug">{feature.title}</p>
<p className="text-sm text-foreground/75 leading-snug">{feature.description}</p>
</motion.div>
</AnimatePresence>
</ScrollReveal>
</div>
</section>
);
};
export default HeroBillboardFeatures;

View File

@@ -0,0 +1,182 @@
import { useRef } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import { Check } from "lucide-react";
import { cls } from "@/lib/utils";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import AvatarGroup from "@/components/ui/AvatarGroup";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type FloatingCardPosition = "top-left" | "top-right" | "middle-left" | "middle-right";
type HeroBillboardFloatingCardsProps = {
avatarsSrc: string[];
avatarsLabel: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
note?: string;
floatingCardsSrc: [string, string, string, string];
logosSrc?: string[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const POSITIONS: FloatingCardPosition[] = ["top-left", "top-right", "middle-left", "middle-right"];
const FLOATING_CARD_CONFIG: Record<FloatingCardPosition, {
position: string;
rotation: string;
size: string;
animation: { duration: number; delay: number; yOffset: number; entryDelay: number };
}> = {
"top-left": {
position: "top-8 left-0",
rotation: "-rotate-8",
size: "size-20 xl:size-22 2xl:size-24",
animation: { duration: 4, delay: 0, yOffset: -8, entryDelay: 0.3 },
},
"top-right": {
position: "top-4 right-4",
rotation: "rotate-10",
size: "size-18 xl:size-20 2xl:size-22",
animation: { duration: 5, delay: 1, yOffset: -10, entryDelay: 0.5 },
},
"middle-left": {
position: "top-1/2 left-2",
rotation: "rotate-6",
size: "size-18 xl:size-20 2xl:size-22",
animation: { duration: 4.5, delay: 0.5, yOffset: -9, entryDelay: 0.7 },
},
"middle-right": {
position: "top-1/2 right-0",
rotation: "-rotate-6",
size: "size-20 xl:size-22 2xl:size-24",
animation: { duration: 3.8, delay: 1.5, yOffset: -8, entryDelay: 0.9 },
},
};
const HeroBillboardFloatingCards = ({
avatarsSrc,
avatarsLabel,
title,
description,
primaryButton,
secondaryButton,
note,
floatingCardsSrc,
logosSrc,
imageSrc,
videoSrc,
textAnimation,
}: HeroBillboardFloatingCardsProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: containerRef });
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
return (
<section aria-label="Hero section" className="relative">
<HeroBackgroundSlot />
<div ref={containerRef} className="pt-25 pb-20 md:pt-30 perspective-distant">
<div className="relative w-content-width mx-auto">
{POSITIONS.map((position, index) => {
const config = FLOATING_CARD_CONFIG[position];
const src = floatingCardsSrc[index];
if (!src) return null;
return (
<motion.div
key={index}
className={cls("absolute z-10 hidden md:block", config.position)}
animate={{ y: [0, config.animation.yOffset, 0] }}
transition={{ duration: config.animation.duration, repeat: Infinity, ease: "easeInOut", delay: config.animation.delay }}
>
<motion.div
className={cls("p-2 card rounded-2xl overflow-hidden", config.size, config.rotation)}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: config.animation.entryDelay }}
>
<img src={src} alt="" className="w-full h-full object-contain rounded-xl" />
</motion.div>
</motion.div>
);
})}
<div className="flex flex-col items-center gap-3 md:max-w-8/10 mx-auto text-center">
<div className="p-0.5 pr-3 mb-1 card rounded-full">
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarsLabel} />
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
{note && (
<motion.div
className="flex justify-center mt-2 md:mt-3 text-sm text-foreground/70"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<span className="flex items-center gap-2">
<div className="flex items-center justify-center size-4 primary-button rounded-full">
<Check className="size-1/2 text-primary-cta-text" />
</div>
{note}
</span>
</motion.div>
)}
</div>
</div>
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
</div>
<motion.div
style={{ rotateX: rotate, scale }}
className="w-content-width mx-auto mt-5 2xl:mt-2 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
</motion.div>
{logosSrc && logosSrc.length > 0 && (
<ScrollReveal variant="fade-blur" className="w-content-width mx-auto mt-2 xl:mt-4 2xl:mt-6 overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "45s" }}>
{[...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc, ...logosSrc].map((logo, index) => (
<div key={index} className="shrink-0 mx-1 xl:mx-2 2xl:mx-3 p-3 rounded card">
<img src={logo} alt="" className="h-8 w-auto object-contain rounded" />
</div>
))}
</div>
</ScrollReveal>
)}
</div>
</section>
);
};
export default HeroBillboardFloatingCards;

View File

@@ -0,0 +1,84 @@
import { useRef } from "react";
import { useScroll, useTransform, motion } from "motion/react";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroBillboardScrollProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBillboardScroll = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
textAnimation,
}: HeroBillboardScrollProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ target: containerRef });
const rotate = useTransform(scrollYProgress, [0, 1], [20, 0]);
const scale = useTransform(scrollYProgress, [0, 1], [1.05, 1]);
return (
<section aria-label="Hero section" className="relative">
<HeroBackgroundSlot />
<div
ref={containerRef}
className="pt-25 pb-20 md:pt-30 perspective-distant"
>
<div className="w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<div className="w-content-width mx-auto mt-8 p-2 card rounded overflow-hidden rotate-x-20 md:hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-4/5" />
</div>
<motion.div
style={{ rotateX: rotate, scale }}
className="w-content-width mx-auto mt-12 2xl:mt-8 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden hidden md:block"
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-video" />
</motion.div>
</div>
</section>
);
};
export default HeroBillboardScroll;

View File

@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroBillboardTestimonialProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
testimonials: Testimonial[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroBillboardTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
textAnimation,
}: HeroBillboardTestimonialProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (testimonials.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, testimonials.length]);
const testimonial = testimonials[currentIndex];
return (
<section aria-label="Hero section" className="relative pt-25 pb-20 md:pt-30">
<HeroBackgroundSlot />
<div className="flex flex-col gap-12 w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 text-center">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
<ScrollReveal variant="fade-blur" delay={0.2} className="relative w-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="aspect-3/4 md:aspect-video" />
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute bottom-4 left-4 right-4 xl:left-6 xl:bottom-6 xl:right-auto 2xl:left-8 2xl:bottom-8 max-w-sm p-4 xl:p-5 2xl:p-6 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
>
<div className="flex gap-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
strokeWidth={1.5}
/>
))}
</div>
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</ScrollReveal>
</div>
</section>
);
};
export default HeroBillboardTestimonial;

View File

@@ -0,0 +1,63 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import TiltedCarousel from "@/components/ui/TiltedCarousel";
type HeroBillboardTiltedCarouselProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const HeroBillboardTiltedCarousel = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: HeroBillboardTiltedCarouselProps) => {
return (
<section
aria-label="Hero section"
className="relative flex flex-col items-center justify-center gap-8 md:gap-10 w-full min-h-svh pt-25 pb-20 md:pt-30"
>
<HeroBackgroundSlot />
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
<TiltedCarousel items={items} />
</section>
);
};
export default HeroBillboardTiltedCarousel;

View File

@@ -0,0 +1,67 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
type HeroBrandProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroBrand = ({
brand,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
textAnimation,
}: HeroBrandProps) => {
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
>
<HeroBackgroundSlot />
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-5">
<div className="flex flex-col">
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-normal text-white leading-snug"
/>
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
<div className="flex flex-wrap gap-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<AutoFillText className="font-semibold text-white">{brand}</AutoFillText>
</div>
</div>
</section>
);
};
export default HeroBrand;

View File

@@ -0,0 +1,111 @@
import { useEffect, useState } from "react";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
import { cls } from "@/lib/utils";
type HeroBrandCarouselProps = {
brand: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const INTERVAL = 4000;
const HeroBrandCarousel = ({
brand,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: HeroBrandCarouselProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % items.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, items.length]);
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
>
<HeroBackgroundSlot />
{items.map((item, index) => (
<div
key={index}
className={cls(
"absolute inset-0 transition-opacity duration-500",
currentIndex === index ? "opacity-100 z-1" : "opacity-0 pointer-events-none"
)}
aria-hidden={currentIndex !== index}
>
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
</div>
))}
<div
className="absolute z-10 w-full h-[50svh] md:h-[75svh] left-0 bottom-0 backdrop-blur-xl mask-[linear-gradient(to_bottom,transparent,black_60%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-5">
<div className="flex flex-col">
<div className="w-full flex flex-col md:flex-row md:justify-between items-start md:items-end gap-3 md:gap-5">
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="w-full md:w-1/2 text-lg md:text-2xl text-balance font-normal text-white leading-snug"
/>
<div className="w-full md:w-1/2 flex justify-start md:justify-end">
<div className="flex flex-wrap gap-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<AutoFillText className="font-semibold text-white">{brand}</AutoFillText>
<div className="flex gap-3 pb-5">
{items.map((_, index) => (
<button
key={index}
className="relative h-1 w-full rounded overflow-hidden bg-primary-cta-text/20 cursor-pointer"
onClick={() => setCurrentIndex(index)}
aria-label="Slide"
aria-current={currentIndex === index}
>
<div
className={cls(
"absolute inset-0 bg-primary-cta-text rounded origin-left",
currentIndex === index ? "animate-progress" : (index < currentIndex ? "scale-x-100" : "scale-x-0")
)}
style={{ animationDuration: `${INTERVAL}ms` }}
/>
</button>
))}
</div>
</div>
</div>
</section>
);
};
export default HeroBrandCarousel;

View File

@@ -0,0 +1,82 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AvatarGroup from "@/components/ui/AvatarGroup";
type HeroCenteredLogosProps = {
avatarsSrc: string[];
avatarText: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
names: string[];
hideMedia?: boolean;
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroCenteredLogos = ({
avatarsSrc,
avatarText,
title,
description,
primaryButton,
secondaryButton,
names,
imageSrc,
videoSrc,
hideMedia = false,
textAnimation,
}: HeroCenteredLogosProps) => {
return (
<section aria-label="Hero section" className="relative h-svh flex flex-col mb-20">
<HeroBackgroundSlot />
{!hideMedia && (
<div className="absolute inset-0 z-0">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="size-full object-cover" />
<div className="absolute inset-0 bg-background/80" />
</div>
)}
<div className="relative z-10 flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3 pt-8 w-content-width mx-auto text-center">
<AvatarGroup avatarsSrc={avatarsSrc} label={avatarText} size="lg" />
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
</div>
</div>
<div className="relative z-10 w-content-width mx-auto pb-8 overflow-hidden mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
{[...names, ...names, ...names, ...names].map((name, index) => (
<div key={index} className="shrink-0 mx-3 px-4 py-2 card rounded">
<span className="text-xl font-semibold whitespace-nowrap text-foreground/75">{name}</span>
</div>
))}
</div>
</div>
</section>
);
};
export default HeroCenteredLogos;

View File

@@ -0,0 +1,146 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useScroll, useTransform } from "motion/react";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AutoFillText from "@/components/ui/AutoFillText";
import { useButtonClick } from "@/hooks/useButtonClick";
const StaggerText = ({ text }: { text: string }) => (
<span className="truncate overflow-hidden">
{[...text].map((char, index) => (
<span
key={index}
className="inline-block transition-transform duration-400 ease-out md:group-hover:-translate-y-[1.25em]"
style={{ textShadow: "0 1.25em currentColor", transitionDelay: `${index * 0.01}s`, whiteSpace: char === " " ? "pre" : undefined }}
>
{char}
</span>
))}
</span>
);
type HeroExpandProps = {
title: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
onComplete?: () => void;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroExpand = ({
title,
videoSrc,
imageSrc,
primaryButton,
secondaryButton,
onComplete,
}: HeroExpandProps) => {
const [showLoader, setShowLoader] = useState(true);
const [expanded, setExpanded] = useState(false);
const handlePrimaryClick = useButtonClick(primaryButton.href);
const handleSecondaryClick = useButtonClick(secondaryButton.href);
const sectionRef = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start start", "end start"],
});
const videoY = useTransform(scrollYProgress, [0, 1], ["0px", "150px"]);
const videoScale = useTransform(scrollYProgress, [0, 1], [1, 1.1]);
useEffect(() => {
const expandTimer = setTimeout(() => setExpanded(true), 600);
const hideTimer = setTimeout(() => {
setShowLoader(false);
onComplete?.();
}, 1500);
return () => {
clearTimeout(expandTimer);
clearTimeout(hideTimer);
};
}, []);
return (
<>
<AnimatePresence>
{showLoader && (
<motion.div
key="loader"
className="fixed inset-0 z-100 bg-background"
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<motion.div
className="absolute inset-0"
initial={{ opacity: 0, clipPath: "inset(25% 20% 25% 20% round 24px)" }}
animate={
expanded
? { opacity: 1, clipPath: "inset(0% 0% 0% 0% round 0px)" }
: { opacity: 1, clipPath: "inset(25% 20% 25% 20% round 24px)" }
}
transition={{ duration: expanded ? 1.4 : 1.2, ease: [0.76, 0, 0.24, 1] }}
>
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} className="rounded-none" />
</motion.div>
</motion.div>
)}
</AnimatePresence>
<section ref={sectionRef} aria-label="Hero section" className="relative w-full h-svh overflow-hidden mb-20">
<motion.div className="absolute inset-0" style={{ y: videoY, scale: videoScale }}>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
</motion.div>
<div className="absolute inset-0 bg-linear-to-t from-background/60 via-transparent to-background/0" />
<div className="absolute inset-0 z-10 flex flex-col justify-end pb-8 md:pb-12 xl:pb-16 2xl:pb-20">
<div className="w-content-width mx-auto flex flex-col md:flex-row md:items-end md:justify-between gap-8">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className="w-full"
>
<AutoFillText className="font-medium text-white" paddingY="0">{title}</AutoFillText>
</motion.div>
<div className="flex items-center gap-3">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, delay: 0.1, ease: "easeOut" }}
className="w-1/2 md:w-auto"
>
<a
href={primaryButton.href}
onClick={handlePrimaryClick}
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer primary-button text-primary-cta-text"
>
<StaggerText text={primaryButton.text} />
</a>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 1.2, delay: 0.2, ease: "easeOut" }}
className="w-1/2 md:w-auto"
>
<a
href={secondaryButton.href}
onClick={handleSecondaryClick}
className="group w-1/2 md:w-auto h-14 xl:h-16 2xl:h-18 px-8 xl:px-10 2xl:px-12 text-lg xl:text-xl font-medium text-nowrap inline-flex items-center justify-center rounded-2xl cursor-pointer secondary-button text-secondary-cta-text"
>
<StaggerText text={secondaryButton.text} />
</a>
</motion.div>
</div>
</div>
</div>
</section>
</>
);
};
export default HeroExpand;

View File

@@ -0,0 +1,85 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AvatarGroup from "@/components/ui/AvatarGroup";
type HeroOverlayProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
avatarsSrc?: string[];
avatarsLabel?: string;
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroOverlay = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
avatarsSrc,
avatarsLabel,
textAnimation,
}: HeroOverlayProps) => {
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-end mb-20"
>
<HeroBackgroundSlot />
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-[150vw] h-[150vw] left-0 bottom-0 -translate-x-1/2 translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pb-10 md:pb-25">
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-white text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl text-white leading-snug text-balance"
/>
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
{avatarsSrc && avatarsSrc.length > 0 && (
<div className="mt-3 md:mt-4">
<AvatarGroup avatarsSrc={avatarsSrc} size="lg" label={avatarsLabel} labelClassName="text-primary-cta-text" />
</div>
)}
</div>
</div>
</section>
);
};
export default HeroOverlay;

View File

@@ -0,0 +1,100 @@
import type { LucideIcon } from "lucide-react";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AvatarGroup from "@/components/ui/AvatarGroup";
type HeroOverlayMarqueeProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
avatarsSrc?: string[];
avatarsLabel?: string;
items: { text: string; icon: LucideIcon }[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroOverlayMarquee = ({
tag,
title,
description,
primaryButton,
secondaryButton,
avatarsSrc,
avatarsLabel,
items,
imageSrc,
videoSrc,
textAnimation,
}: HeroOverlayMarqueeProps) => {
return (
<section
aria-label="Hero section"
className="relative overflow-hidden flex flex-col justify-between mb-20 w-full h-svh"
>
<HeroBackgroundSlot />
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 object-cover w-full h-full rounded-none"
/>
<div
className="absolute z-10 left-0 top-0 w-[150vw] h-[150vw] -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<div className="relative z-10 mx-auto pt-35 w-content-width">
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
<div className="mb-1 px-3 py-1 w-fit text-sm card rounded">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-balance text-white"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl leading-snug text-balance text-white"
/>
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
</div>
{avatarsSrc && avatarsSrc.length > 0 && (
<div className="mt-3 md:mt-4">
<AvatarGroup avatarsSrc={avatarsSrc} size="lg" label={avatarsLabel} labelClassName="text-primary-cta-text" />
</div>
)}
</div>
</div>
<div className="relative z-10 overflow-hidden mx-auto pb-8 w-content-width mask-fade-x">
<div className="flex w-max animate-marquee-horizontal" style={{ animationDuration: "30s" }}>
{[...items, ...items, ...items, ...items].map((item, index) => (
<div key={index} className="flex items-center shrink-0 gap-1 mx-3 pl-2 pr-4 py-2 card rounded">
<item.icon className="h-(--text-base) text-foreground" />
<span className="whitespace-nowrap text-base font-medium text-foreground">{item.text}</span>
</div>
))}
</div>
</div>
</section>
);
};
export default HeroOverlayMarquee;

View File

@@ -0,0 +1,124 @@
import { useRef } from "react";
import { motion, useScroll, useTransform } from "motion/react";
import Button from "@/components/ui/Button";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AvatarGroup from "@/components/ui/AvatarGroup";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
type HeroOverlayParallaxProps = {
avatarsSrc: string[];
avatarsLabel: string;
title: string;
titleHighlight?: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroOverlayParallax = ({
imageSrc,
videoSrc,
avatarsSrc,
avatarsLabel,
title,
titleHighlight,
primaryButton,
secondaryButton,
}: HeroOverlayParallaxProps) => {
const heroRef = useRef<HTMLElement>(null);
const { scrollYProgress } = useScroll({
target: heroRef,
offset: ["start start", "end start"],
});
const imgY = useTransform(scrollYProgress, [0, 1], ["0px", "150px"]);
const imgScale = useTransform(scrollYProgress, [0, 1], [1, 1.1]);
return (
<section
ref={heroRef}
aria-label="Hero section"
className="relative h-screen w-full overflow-hidden flex flex-col"
>
<HeroBackgroundSlot />
<motion.div
className="absolute inset-0"
style={{ y: imgY, scale: imgScale }}
>
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 rounded-none"
/>
</motion.div>
<div className="absolute inset-0 bg-black/30" />
<div className="relative z-10 flex-1 flex items-center justify-center pb-8">
<div className="flex flex-col items-center gap-6 w-content-width mx-auto text-center text-white">
<motion.div
className="p-2 pr-4 rounded-full backdrop-blur-md bg-white/10 border border-white/20"
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
>
<AvatarGroup avatarsSrc={avatarsSrc} size="sm" label={avatarsLabel} labelClassName="text-white/80" />
</motion.div>
<motion.h1
className="text-9xl font-normal text-balance leading-none tracking-tight"
initial="hidden"
animate="visible"
transition={{ staggerChildren: 0.1, delayChildren: 0.25 }}
>
{(() => {
const titleWords = title.split(" ");
const highlightWords = titleHighlight ? titleHighlight.split(" ") : [];
const allWords = [...titleWords, ...highlightWords];
return allWords.map((word, i) => {
const isHighlight = i >= titleWords.length;
return (
<span key={i}>
{i > 0 && " "}
<motion.span
className={isHighlight ? "inline-block font-serif italic" : "inline-block"}
variants={{
hidden: { opacity: 0, y: "100%", filter: "blur(10px)" },
visible: { opacity: 1, y: 0, filter: "blur(0px)" },
}}
transition={{ duration: 1, ease: [0.25, 0.1, 0.25, 1] }}
>
{word}
</motion.span>
</span>
);
});
})()}
</motion.h1>
<div className="flex flex-wrap justify-center gap-3 mt-6">
<motion.div
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.9 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
transition={{ duration: 1, delay: 0.9, ease: [0.25, 0.1, 0.25, 1] }}
>
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" animate={false} />
</motion.div>
<motion.div
initial={{ opacity: 0, filter: "blur(10px)", scale: 0.9 }}
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
transition={{ duration: 1, delay: 1.05, ease: [0.25, 0.1, 0.25, 1] }}
>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animate={false} />
</motion.div>
</div>
</div>
</div>
<div className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2">
<div className="h-2 w-14 rounded-full bg-white/30" />
</div>
</section>
);
};
export default HeroOverlayParallax;

View File

@@ -0,0 +1,95 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import AvatarGroup from "@/components/ui/AvatarGroup";
type HeroOverlayStatisticsProps = {
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
avatarsSrc?: string[];
avatarsLabel?: string;
statistics?: { title: string; subtitle: string }[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroOverlayStatistics = ({
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
avatarsSrc,
avatarsLabel,
statistics,
textAnimation,
}: HeroOverlayStatisticsProps) => {
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-start mb-20"
>
<HeroBackgroundSlot />
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-[150vw] h-[150vw] left-0 top-0 -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pt-35">
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
{avatarsSrc && avatarsSrc.length > 0 && (
<div className="w-fit mb-1 p-2 pr-4 backdrop-blur-xl bg-background/8 border border-background/15 rounded-full">
<AvatarGroup avatarsSrc={avatarsSrc} size="sm" label={avatarsLabel} labelClassName="text-background" />
</div>
)}
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-white text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl text-white leading-snug text-balance"
/>
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
{statistics && statistics.length > 0 && (
<div className="absolute z-10 left-4 right-4 bottom-4 md:left-auto md:right-5 md:bottom-5 2xl:right-6 2xl:bottom-6 p-4 xl:p-5 2xl:p-6 backdrop-blur-xl bg-background/8 border border-background/15 rounded flex gap-3 xl:gap-4 2xl:gap-5">
{statistics.map((stat, index) => (
<div key={stat.title} className="flex items-center gap-5">
{index > 0 && <div className="w-px self-stretch bg-background/15" />}
<div className="flex flex-col">
<h3 className="text-lg font-medium leading-snug text-background">{stat.title}</h3>
<p className="text-base leading-snug text-background/60">{stat.subtitle}</p>
</div>
</div>
))}
</div>
)}
</section>
);
};
export default HeroOverlayStatistics;

View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroOverlayTestimonialProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
testimonials: Testimonial[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroOverlayTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
textAnimation,
}: HeroOverlayTestimonialProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (testimonials.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, testimonials.length]);
const testimonial = testimonials[currentIndex];
return (
<section
aria-label="Hero section"
className="relative w-full h-svh overflow-hidden flex flex-col justify-start mb-20"
>
<HeroBackgroundSlot />
<ImageOrVideo
imageSrc={imageSrc}
videoSrc={videoSrc}
className="absolute inset-0 w-full h-full object-cover rounded-none"
/>
<div
className="absolute z-10 w-[150vw] h-[150vw] left-0 top-0 -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<div className="relative z-10 w-content-width mx-auto pt-35">
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-white text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl text-white leading-snug text-balance"
/>
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute z-10 left-4 right-4 bottom-4 md:left-auto md:right-5 md:bottom-5 2xl:right-6 2xl:bottom-6 md:max-w-25/100 2xl:max-w-2/10 p-4 xl:p-5 2xl:p-6 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
>
<div className="flex gap-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
strokeWidth={1.5}
/>
))}
</div>
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</section>
);
};
export default HeroOverlayTestimonial;

View File

@@ -0,0 +1,136 @@
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import { useButtonClick } from "@/hooks/useButtonClick";
import type { LucideIcon } from "lucide-react";
type TitleSegment =
| { type: "text"; content: string }
| ({ type: "media" } & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }));
type SocialLink = {
icon: LucideIcon;
label: string;
href: string;
};
type LinkCard = {
icon: LucideIcon;
title: string;
description: string;
button: { text: string; href: string };
};
type HeroPersonalLinksProps = {
titleSegments: TitleSegment[];
socialLinks?: SocialLink[];
linkCards: LinkCard[];
};
const SocialLinkButton = ({ social }: { social: SocialLink }) => {
const handleClick = useButtonClick(social.href);
const Icon = social.icon;
return (
<button
type="button"
onClick={handleClick}
className="flex items-center gap-2 px-3 py-1.5 rounded card text-sm hover:opacity-80 transition-opacity duration-300 cursor-pointer"
>
<Icon className="h-[1em] w-auto aspect-square" />
<span>{social.label}</span>
</button>
);
};
const HeroPersonalLinks = ({
titleSegments,
socialLinks,
linkCards,
}: HeroPersonalLinksProps) => {
return (
<section
aria-label="Hero section"
className="relative w-full min-h-svh flex items-center justify-center py-20"
>
<HeroBackgroundSlot />
<div className="w-content-width mx-auto flex flex-col items-center">
<div className="w-full md:w-55/100 flex flex-col items-center gap-8">
<h1 className="text-5xl md:text-6xl font-semibold text-center leading-tight text-foreground">
<span className="inline">
{titleSegments.map((segment, index) =>
segment.type === "text" ? (
<motion.span
key={index}
initial={{ opacity: 0, y: 15, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.7, delay: 0.2 + index * 0.15, ease: [0.25, 0.1, 0.25, 1] }}
>
{segment.content}
</motion.span>
) : (
<motion.span
key={index}
initial={{ opacity: 0, filter: "blur(4px)", rotate: Math.floor(index / 2) % 2 === 0 ? -6 : 6 }}
animate={{ opacity: 1, filter: "blur(0px)", rotate: Math.floor(index / 2) % 2 === 0 ? -3 : 3 }}
transition={{ duration: 0.7, delay: 0.2 + index * 0.15, ease: [0.25, 0.1, 0.25, 1] }}
className="inline-block align-middle h-[1em] aspect-square rounded overflow-hidden mx-[0.25em] primary-button p-0.5"
>
<ImageOrVideo imageSrc={segment.imageSrc} videoSrc={segment.videoSrc} className="rounded-sm" />
</motion.span>
)
)}
</span>
</h1>
{socialLinks && socialLinks.length > 0 && (
<div className="flex flex-wrap justify-center gap-3">
{socialLinks.map((social, index) => (
<motion.div
key={social.label}
initial={{ opacity: 0, y: 15, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.7, delay: 0.5 + index * 0.1, ease: [0.25, 0.1, 0.25, 1] }}
>
<SocialLinkButton social={social} />
</motion.div>
))}
</div>
)}
<div className="w-full flex flex-col gap-3">
{linkCards.map((card, index) => (
<motion.div
key={card.title}
initial={{ opacity: 0, y: 15, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{ duration: 0.7, delay: 0.8 + index * 0.1, ease: [0.25, 0.1, 0.25, 1] }}
className="w-full card rounded p-2 xl:p-3 2xl:p-4 flex items-center gap-3"
>
<div className="relative h-10 w-10 secondary-button rounded flex items-center justify-center shrink-0">
<card.icon className="h-4 w-4 text-secondary-cta-text" strokeWidth={1.5} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground">{card.title}</h3>
<p className="text-sm truncate text-foreground/60">{card.description}</p>
</div>
<Button
text={card.button.text}
href={card.button.href}
variant="primary"
className="shrink-0"
animate={false}
/>
</motion.div>
))}
</div>
</div>
</div>
</section>
);
};
export default HeroPersonalLinks;

View File

@@ -0,0 +1,67 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type HeroSplitProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const HeroSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
textAnimation,
}: HeroSplitProps) => {
return (
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<HeroBackgroundSlot />
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<ScrollReveal variant="slide-up" delay={0.2} className="w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</ScrollReveal>
</div>
</section>
);
};
export default HeroSplit;

View File

@@ -0,0 +1,133 @@
import { useEffect, useRef } from "react";
import { motion } from "motion/react";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type KpiItem = {
value: string;
label: string;
};
type HeroSplitKpiProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
kpis: [KpiItem, KpiItem, KpiItem];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const KPI_POSITIONS = ["top-[5%] left-0", "top-[40%] right-0", "bottom-[5%] left-[5%]"];
const HeroSplitKpi = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
kpis,
textAnimation,
}: HeroSplitKpiProps) => {
const kpiRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]);
useEffect(() => {
if (window.innerWidth <= 768) return;
let mouseX = 0;
let mouseY = 0;
const offsets = [{ x: 0, y: 0 }, { x: 0, y: 0 }, { x: 0, y: 0 }];
const multipliers = [-0.25, -0.5, 0.25];
let animationId: number;
const onMouseMove = (e: MouseEvent) => {
mouseX = (e.clientX / window.innerWidth) * 100 - 50;
mouseY = (e.clientY / window.innerHeight) * 100 - 50;
};
const animate = () => {
offsets.forEach((offset, i) => {
offset.x += ((mouseX * multipliers[i]) - offset.x) * 0.025;
offset.y += ((mouseY * multipliers[i]) - offset.y) * 0.025;
const el = kpiRefs.current[i];
if (el) el.style.transform = `translate(${offset.x}px, ${offset.y}px)`;
});
animationId = requestAnimationFrame(animate);
};
animate();
window.addEventListener("mousemove", onMouseMove);
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(animationId);
};
}, []);
return (
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<HeroBackgroundSlot />
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<div className="relative w-full md:w-1/2 h-100 md:h-[65vh] md:max-h-[75svh]">
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full h-full p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden scale-85">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
</ScrollReveal>
{kpis.map((kpi, index) => (
<motion.div
key={index}
ref={(el) => { kpiRefs.current[index] = el; }}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, ease: "easeOut", delay: 0.4 + index * 0.1 }}
className={cls(
"absolute flex flex-col items-center p-3 xl:p-4 2xl:p-5 card backdrop-blur-sm rounded",
KPI_POSITIONS[index]
)}
>
<p className="text-2xl md:text-4xl text-foreground font-medium">{kpi.value}</p>
<p className="text-sm md:text-base text-foreground/75">{kpi.label}</p>
</motion.div>
))}
</div>
</div>
</section>
);
};
export default HeroSplitKpi;

View File

@@ -0,0 +1,74 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type HeroSplitMediaGridProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: [
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never },
{ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never }
];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const HeroSplitMediaGrid = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: HeroSplitMediaGridProps) => {
return (
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<HeroBackgroundSlot />
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<ScrollReveal variant="fade-blur" delay={0.2} className="w-full md:w-1/2 grid grid-cols-2 gap-2 xl:gap-3 2xl:gap-4">
{items.map((item, index) => (
<div key={index} className="h-80 md:h-[55vh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</ScrollReveal>
</div>
</section>
);
};
export default HeroSplitMediaGrid;

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from "react";
import { Star } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { cls } from "@/lib/utils";
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
type Testimonial = {
name: string;
handle: string;
text: string;
rating: number;
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
type HeroSplitTestimonialProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
testimonials: Testimonial[];
textAnimation: "slide-up" | "fade-blur" | "fade";
} & ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never });
const INTERVAL = 5000;
const HeroSplitTestimonial = ({
tag,
title,
description,
primaryButton,
secondaryButton,
imageSrc,
videoSrc,
testimonials,
textAnimation,
}: HeroSplitTestimonialProps) => {
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (testimonials.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % testimonials.length);
}, INTERVAL);
return () => clearInterval(interval);
}, [currentIndex, testimonials.length]);
const testimonial = testimonials[currentIndex];
return (
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<HeroBackgroundSlot />
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<ScrollReveal variant="slide-up" delay={0.2} className="relative w-full md:w-1/2 aspect-3/4 md:aspect-auto md:h-[65vh] md:max-h-[75svh] p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={imageSrc} videoSrc={videoSrc} />
<AnimatePresence mode="wait">
<motion.div
key={currentIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="absolute bottom-4 left-4 right-4 xl:bottom-6 xl:right-6 2xl:bottom-8 2xl:right-8 md:left-auto md:max-w-5/10 p-4 xl:p-5 2xl:p-6 card rounded flex flex-col gap-3 xl:gap-4 2xl:gap-5"
>
<div className="flex gap-1.5">
{Array.from({ length: 5 }).map((_, index) => (
<Star
key={index}
className={cls("size-5 text-accent", index < testimonial.rating ? "fill-accent" : "fill-transparent")}
strokeWidth={1.5}
/>
))}
</div>
<p className="text-lg leading-snug text-balance">{testimonial.text}</p>
<div className="flex items-center gap-3">
<ImageOrVideo
imageSrc={testimonial.imageSrc}
videoSrc={testimonial.videoSrc}
className="size-10 md:size-11 2xl:size-12 rounded-full object-cover"
/>
<div className="flex flex-col">
<span className="text-base text-foreground leading-snug font-medium">{testimonial.name}</span>
<span className="text-base text-foreground/75 leading-snug">{testimonial.handle}</span>
</div>
</div>
</motion.div>
</AnimatePresence>
</ScrollReveal>
</div>
</section>
);
};
export default HeroSplitTestimonial;

View File

@@ -0,0 +1,89 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroSplitVerticalMarqueeProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
leftItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
rightItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const HeroSplitVerticalMarquee = ({
tag,
title,
description,
primaryButton,
secondaryButton,
leftItems,
rightItems,
textAnimation,
}: HeroSplitVerticalMarqueeProps) => {
const duplicatedLeft = [...leftItems, ...leftItems, ...leftItems, ...leftItems];
const duplicatedRight = [...rightItems, ...rightItems, ...rightItems, ...rightItems];
return (
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<HeroBackgroundSlot />
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<div className="w-full md:w-1/2 h-100 md:h-[75vh] flex gap-2 xl:gap-3 2xl:gap-4 overflow-hidden">
<div className="flex-1 overflow-hidden mask-fade-y-medium">
<div className="flex flex-col gap-2 xl:gap-3 2xl:gap-4 animate-marquee-vertical">
{duplicatedLeft.map((item, index) => (
<div key={index} className="shrink-0 aspect-square p-1 xl:p-2 2xl:p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</div>
<div className="flex-1 overflow-hidden mask-fade-y-medium">
<div className="flex flex-col gap-2 xl:gap-3 2xl:gap-4 animate-marquee-vertical-reverse">
{duplicatedRight.map((item, index) => (
<div key={index} className="shrink-0 aspect-square p-1 xl:p-2 2xl:p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
};
export default HeroSplitVerticalMarquee;

View File

@@ -0,0 +1,89 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
type HeroSplitVerticalMarqueeTallProps = {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
leftItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
rightItems: ({ imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never })[];
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const HeroSplitVerticalMarqueeTall = ({
tag,
title,
description,
primaryButton,
secondaryButton,
leftItems,
rightItems,
textAnimation,
}: HeroSplitVerticalMarqueeTallProps) => {
const duplicatedLeft = [...leftItems, ...leftItems, ...leftItems, ...leftItems];
const duplicatedRight = [...rightItems, ...rightItems, ...rightItems, ...rightItems];
return (
<section aria-label="Hero section" className="relative flex items-center h-fit md:h-svh pt-25 pb-20 md:py-0">
<HeroBackgroundSlot />
<div className="flex flex-col md:flex-row items-center gap-12 md:gap-20 w-content-width mx-auto">
<div className="w-full md:w-1/2">
<div className="flex flex-col items-center md:items-start gap-3">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center md:text-left text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-8/10 text-lg md:text-xl leading-snug text-center md:text-left text-balance"
/>
<div className="flex flex-wrap max-md:justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
</div>
<div className="w-full md:w-1/2 h-100 md:h-[75vh] flex gap-2 xl:gap-3 2xl:gap-4 overflow-hidden">
<div className="flex-1 overflow-hidden mask-fade-y-medium">
<div className="flex flex-col gap-2 xl:gap-3 2xl:gap-4 animate-marquee-vertical">
{duplicatedLeft.map((item, index) => (
<div key={index} className="shrink-0 aspect-4/5 p-1 xl:p-2 2xl:p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</div>
<div className="flex-1 overflow-hidden mask-fade-y-medium">
<div className="flex flex-col gap-2 xl:gap-3 2xl:gap-4 animate-marquee-vertical-reverse">
{duplicatedRight.map((item, index) => (
<div key={index} className="shrink-0 aspect-4/5 p-1 xl:p-2 2xl:p-3 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</div>
</div>
</div>
</section>
);
};
export default HeroSplitVerticalMarqueeTall;

View File

@@ -0,0 +1,263 @@
import { useRef, useEffect, useState } from "react";
import { motion } from "motion/react";
import gsap from "gsap";
import { Draggable } from "gsap/Draggable";
import { cls } from "@/lib/utils";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import { useButtonClick } from "@/hooks/useButtonClick";
gsap.registerPlugin(Draggable);
type SocialLink = {
iconSrc: string;
href: string;
label: string;
};
type BackTextSegment =
| { type: "text"; content: string }
| { type: "highlight"; content: string }
| { type: "photo"; src: string };
type HeroStickerFlipProps = {
title: string;
description: string;
imageSrc: string;
backTextSegments: BackTextSegment[];
stickerImageSrcs: [string, string, string, string, string, string, string, string];
socialLinks: SocialLink[];
clickBadgeText: string;
};
const STICKER_POSITIONS = [
{ top: "6%", left: "14%", rotate: "-12deg", size: "w-20 md:w-28 2xl:w-32" },
{ top: "10%", right: "12%", rotate: "10deg", size: "w-24 md:w-30 2xl:w-36" },
{ top: "40%", left: "10%", rotate: "8deg", size: "w-20 md:w-28 2xl:w-32" },
{ top: "42%", right: "9%", rotate: "-6deg", size: "w-20 md:w-28 2xl:w-32" },
{ bottom: "20%", left: "12%", rotate: "-5deg", size: "w-24 md:w-30 2xl:w-36" },
{ bottom: "22%", right: "13%", rotate: "14deg", size: "w-20 md:w-28 2xl:w-32" },
{ bottom: "7%", left: "22%", rotate: "4deg", size: "w-20 md:w-28 2xl:w-32" },
{ bottom: "8%", right: "20%", rotate: "-10deg", size: "w-20 md:w-28 2xl:w-32" },
];
const SocialLinkItem = ({ link }: { link: SocialLink }) => {
const handleClick = useButtonClick(link.href);
return (
<a
href={link.href}
onClick={handleClick}
aria-label={link.label}
className="size-10 md:size-11 2xl:size-12 flex items-center justify-center rounded-xl border border-foreground/15 text-foreground/60 hover:text-foreground hover:border-foreground/40 transition-all"
>
<ImageOrVideo imageSrc={link.iconSrc} className="size-4/10 rounded-none invert-on-dark" />
</a>
);
};
const HeroStickerFlip = ({
title,
description,
imageSrc,
backTextSegments,
stickerImageSrcs,
socialLinks,
clickBadgeText,
}: HeroStickerFlipProps) => {
const stickersRef = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false);
useEffect(() => {
if (!stickersRef.current) return;
const ctx = gsap.context(() => {
const stickerElements = Array.from(
stickersRef.current!.querySelectorAll<HTMLElement>("[data-sticker]")
);
stickerElements.forEach((el, i) => {
gsap.set(el, { willChange: "transform", force3D: true });
gsap.fromTo(
el,
{ autoAlpha: 0, scale: 0.5, y: 30 },
{
autoAlpha: 1,
scale: 1,
y: 0,
duration: 0.6,
delay: 0.8 + i * 0.08,
ease: "back.out(2)",
}
);
Draggable.create(el, {
type: "x,y",
bounds: stickersRef.current,
dragResistance: 0.1,
onPress() {
gsap.to(el, {
scale: 1.15,
rotation: (Math.random() - 0.5) * 20,
duration: 0.3,
ease: "power2.out",
overwrite: true,
});
},
onRelease() {
gsap.to(el, {
scale: 1,
rotation: 0,
duration: 0.5,
ease: "back.out(3)",
overwrite: true,
});
},
});
});
}, stickersRef);
return () => ctx.revert();
}, []);
return (
<section aria-label="Hero section" className="relative h-svh flex flex-col items-center justify-center overflow-hidden">
<HeroBackgroundSlot />
<div
ref={stickersRef}
className="absolute inset-0 pointer-events-auto z-0"
>
{stickerImageSrcs.map((src, i) => {
const pos = STICKER_POSITIONS[i];
const style: React.CSSProperties = {
opacity: 0,
visibility: "hidden",
top: pos.top,
bottom: pos.bottom,
left: pos.left,
right: pos.right,
rotate: pos.rotate,
};
return (
<div
key={i}
data-sticker
className={cls("absolute cursor-grab active:cursor-grabbing", pos.size)}
style={style}
>
<ImageOrVideo
imageSrc={src}
className="h-auto drop-shadow-lg rounded-none"
/>
</div>
);
})}
</div>
<div className="relative z-10 flex flex-col items-center gap-5 w-content-width mx-auto pointer-events-none">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
className="pointer-events-auto cursor-pointer select-none"
style={{ perspective: 800 }}
onClick={() => setFlipped((f) => !f)}
onMouseEnter={() => {
if (window.innerWidth >= 768) setFlipped(true);
}}
onMouseLeave={() => {
if (window.innerWidth >= 768) setFlipped(false);
}}
>
<div
className="relative size-60 md:size-75 2xl:size-85 transition-transform duration-1200 ease-[cubic-bezier(0.22,0.68,0,1.2)]"
style={{
transformStyle: "preserve-3d",
transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)",
}}
>
<div
className="absolute inset-0 rounded-4xl bg-card p-2 xl:p-3 2xl:p-4 shadow-2x"
style={{ backfaceVisibility: "hidden" }}
>
<div className="w-full h-full rounded-3xl overflow-hidden relative">
<ImageOrVideo
imageSrc={imageSrc}
className="rounded-none"
/>
<div className="absolute top-3 left-3 px-3 py-1.5 md:px-3.5 md:py-2 2xl:px-4 2xl:py-2.5 card rounded-full flex items-center justify-center leading-none">
<span className="text-xs md:text-sm font-medium text-foreground">{clickBadgeText}</span>
</div>
</div>
</div>
<div
className="absolute inset-0 rounded-4xl bg-card p-2 xl:p-3 2xl:p-4 shadow-2xl"
style={{
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
}}
>
<div className="w-full h-full rounded-3xl overflow-hidden bg-foreground flex items-center justify-center p-7 md:p-9 2xl:p-11">
<p className="text-lg md:text-2xl 2xl:text-3xl leading-relaxed font-medium">
{backTextSegments.map((segment, i) => (
<span key={i}>
{segment.type === "text" && (
<span className="text-background/40">{segment.content}</span>
)}
{segment.type === "highlight" && (
<span className="text-background font-medium">{segment.content}</span>
)}
{segment.type === "photo" && (
<span className="inline-block align-middle size-9 md:size-10 2xl:size-11 rounded-lg overflow-hidden card p-0.5 mx-1 -mt-1">
<ImageOrVideo imageSrc={segment.src} className="rounded-md" />
</span>
)}
</span>
))}
</p>
</div>
</div>
</div>
</motion.div>
<div className="flex flex-col items-center gap-3 text-center">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3, ease: [0.22, 1, 0.36, 1] }}
className="text-5xl 2xl:text-6xl leading-[1.15] font-semibold text-foreground"
>
{title}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.45, ease: [0.22, 1, 0.36, 1] }}
className="text-lg md:text-xl text-foreground/50 leading-snug"
>
{description}
</motion.p>
{socialLinks.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.6, ease: [0.22, 1, 0.36, 1] }}
className="flex items-center gap-3 mt-0.75 pointer-events-auto"
>
{socialLinks.map((link, i) => (
<SocialLinkItem key={i} link={link} />
))}
</motion.div>
)}
</div>
</div>
</section>
);
};
export default HeroStickerFlip;

View File

@@ -0,0 +1,99 @@
import Button from "@/components/ui/Button";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import ScrollReveal from "@/components/ui/ScrollReveal";
import { cls } from "@/lib/utils";
type MediaItem = { imageSrc: string; videoSrc?: never } | { videoSrc: string; imageSrc?: never };
interface HeroTiltedCardsProps {
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton: { text: string; href: string };
items: [MediaItem, MediaItem, MediaItem, MediaItem, MediaItem];
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const HeroTiltedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
textAnimation,
}: HeroTiltedCardsProps) => {
const marqueeItems = [...items, ...items];
const galleryStyles = [
"-rotate-6 z-10 -translate-y-5",
"rotate-6 z-20 translate-y-5 -ml-15",
"-rotate-6 z-30 -translate-y-5 -ml-15",
"rotate-6 z-40 translate-y-5 -ml-15",
"-rotate-6 z-50 -translate-y-5 -ml-15",
];
return (
<section aria-label="Hero section" className="relative h-svh md:h-auto pt-25 pb-20 md:pt-30">
<HeroBackgroundSlot />
<div className="flex flex-col items-center gap-12 md:gap-15 w-full md:w-content-width mx-auto">
<div className="flex flex-col items-center gap-3 w-content-width mx-auto text-center">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="md:max-w-8/10 text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-balance"
/>
<div className="flex flex-wrap justify-center gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary"/>
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary"animationDelay={0.1} />
</div>
</div>
<ScrollReveal variant="fade-blur" delay={0.2} className="block md:hidden w-full overflow-hidden mask-padding-x">
<div className="flex w-max animate-marquee-horizontal">
{marqueeItems.map((item, index) => (
<div key={index} className="shrink-0 w-[50vw] mr-5 aspect-4/5 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</ScrollReveal>
<ScrollReveal variant="fade-blur" delay={0.2} className="hidden md:flex justify-center items-center w-full">
<div className="flex items-center justify-center">
{items.map((item, index) => (
<div
key={index}
className={cls(
"relative w-[23%] aspect-4/5 p-2 xl:p-3 2xl:p-4 card rounded overflow-hidden shadow-lg transition-transform duration-500 ease-out hover:scale-110",
galleryStyles[index]
)}
>
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} />
</div>
))}
</div>
</ScrollReveal>
</div>
</section>
);
};
export default HeroTiltedCards;

View File

@@ -0,0 +1,230 @@
import { useRef, useState, useEffect } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { motion, AnimatePresence } from "motion/react";
import Button from "@/components/ui/Button";
import TextAnimation from "@/components/ui/TextAnimation";
import AvatarGroup from "@/components/ui/AvatarGroup";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
gsap.registerPlugin(ScrollTrigger);
type HeroVideoScrollProps = {
videoSrc: string;
tag: string;
title: string;
description: string;
primaryButton: { text: string; href: string };
secondaryButton?: { text: string; href: string };
bottomText: string;
avatarsSrc?: string[];
avatarsLabel?: string;
textAnimation: "slide-up" | "fade-blur" | "fade";
};
const HeroVideoScroll = ({
videoSrc,
tag,
title,
description,
primaryButton,
secondaryButton,
bottomText,
avatarsSrc,
avatarsLabel,
textAnimation,
}: HeroVideoScrollProps) => {
const sectionRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
const [showLoader, setShowLoader] = useState(true);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleCanPlayThrough = () => {
setIsVideoLoaded(true);
};
if (video.readyState >= 4) {
setIsVideoLoaded(true);
} else {
video.addEventListener("canplaythrough", handleCanPlayThrough, { once: true });
}
const timeout = setTimeout(() => {
setIsVideoLoaded(true);
}, 8000);
return () => {
video.removeEventListener("canplaythrough", handleCanPlayThrough);
clearTimeout(timeout);
};
}, []);
useGSAP(
() => {
if (!isVideoLoaded) return;
const video = videoRef.current;
if (!video) return;
const setupScrollTrigger = () => {
ScrollTrigger.create({
trigger: sectionRef.current,
start: "top top",
end: "bottom bottom",
scrub: 0.5,
onUpdate: (self) => {
if (video.duration) {
video.currentTime = video.duration * self.progress;
}
},
});
};
if (video.readyState >= 1) {
setupScrollTrigger();
} else {
video.addEventListener("loadedmetadata", setupScrollTrigger, {
once: true,
});
}
},
{ scope: sectionRef, dependencies: [isVideoLoaded] }
);
return (
<>
<AnimatePresence>
{showLoader && (
<motion.div
className="fixed inset-0 z-1001 flex flex-col items-center justify-center bg-foreground"
exit={{ opacity: 0 }}
transition={{ duration: 0.8, ease: [0.76, 0, 0.24, 1] }}
>
<motion.div
className="flex flex-col items-center gap-6"
animate={isVideoLoaded ? { opacity: 0, y: -20 } : { opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
onAnimationComplete={() => {
if (isVideoLoaded) setShowLoader(false);
}}
>
<div className="relative">
<span className="text-xl md:text-2xl font-medium tracking-tight text-background/20">
{tag}
</span>
<motion.span
className="absolute inset-0 text-xl md:text-2xl font-medium tracking-tight text-background"
initial={{ clipPath: "inset(0% 100% 0% 0%)" }}
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
transition={{ duration: 2, ease: [0.76, 0, 0.24, 1] }}
>
{tag}
</motion.span>
</div>
<div className="w-48 h-0.5 bg-background/20 rounded-full overflow-hidden">
<motion.div
className="h-full bg-background rounded-full"
initial={{ scaleX: 0 }}
animate={{ scaleX: isVideoLoaded ? 1 : 0.9 }}
style={{ originX: 0 }}
transition={{
duration: isVideoLoaded ? 0.3 : 3,
ease: [0.76, 0, 0.24, 1]
}}
/>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<div ref={sectionRef} className="relative h-[300vh] mb-20">
<section
aria-label="Hero section"
className="sticky top-0 overflow-hidden flex flex-col justify-between w-full h-svh"
>
<HeroBackgroundSlot />
<video
ref={videoRef}
src={videoSrc}
muted
playsInline
preload="auto"
className="absolute inset-0 w-full h-full object-cover"
/>
<div
className="absolute inset-0 bg-black/20"
aria-hidden="true"
/>
<div
className="absolute z-10 left-0 top-0 w-[150vw] h-[150vw] -translate-x-1/2 -translate-y-1/2 backdrop-blur mask-[radial-gradient(circle,black_20%,transparent_70%)]"
aria-hidden="true"
/>
<motion.div
className="relative z-10 w-content-width mx-auto pt-35"
initial={{ opacity: 0, y: 30 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
transition={{ duration: 0.8, delay: 0.2, ease: [0.22, 1, 0.36, 1] }}
>
<div className="flex flex-col gap-3 w-full md:w-6/10 lg:w-1/2 xl:w-45/100 2xl:w-4/10">
<div className="w-fit px-3 py-1 mb-1 text-sm card rounded">
<p>{tag}</p>
</div>
<TextAnimation
text={title}
variant={textAnimation}
gradientText={true}
tag="h1"
className="text-7xl 2xl:text-8xl leading-[1.15] font-semibold text-white text-balance"
/>
<TextAnimation
text={description}
variant={textAnimation}
gradientText={false}
tag="p"
className="text-lg md:text-xl text-white leading-snug text-balance"
/>
<div className="flex flex-wrap gap-3 mt-2 md:mt-3">
<Button text={primaryButton.text} href={primaryButton.href} variant="primary" />
{secondaryButton && (
<Button text={secondaryButton.text} href={secondaryButton.href} variant="secondary" animationDelay={0.1} />
)}
</div>
{avatarsSrc && avatarsSrc.length > 0 && (
<div className="mt-3 md:mt-4">
<AvatarGroup avatarsSrc={avatarsSrc} size="lg" label={avatarsLabel} labelClassName="text-primary-cta-text" />
</div>
)}
</div>
</motion.div>
<motion.div
className="relative z-10 flex justify-end mx-auto pb-8 w-content-width"
initial={{ opacity: 0, y: 30 }}
animate={!showLoader ? { opacity: 1, y: 0 } : { opacity: 0, y: 30 }}
transition={{ duration: 0.8, delay: 0.4, ease: [0.22, 1, 0.36, 1] }}
>
<p className="md:max-w-1/2 2xl:max-w-4/10 text-sm md:text-base uppercase tracking-wide leading-normal text-balance text-end text-white/75">
{bottomText}
</p>
</motion.div>
</section>
</div>
</>
);
};
export default HeroVideoScroll;

View File

@@ -0,0 +1,285 @@
import { useRef, useEffect } from "react";
import { motion } from "motion/react";
import { ArrowRight } from "lucide-react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import ImageOrVideo from "@/components/ui/ImageOrVideo";
import HeroBackgroundSlot from "@/components/ui/HeroBackgroundSlot";
import TextAnimation from "@/components/ui/TextAnimation";
import { useButtonClick } from "@/hooks/useButtonClick";
gsap.registerPlugin(ScrollTrigger);
interface HeroWorkScrollStackProps {
tag: string;
title: string;
titleHighlight: string;
description: string;
descriptionMuted: string;
primaryButton: { text: string; href: string; avatarSrc: string; avatarLabel: string };
sectionTag: string;
sectionTitle: string;
sectionDescription: string;
items: [
{ title: string; description: string; imageSrc: string; tag: string },
{ title: string; description: string; imageSrc: string; tag: string },
{ title: string; description: string; imageSrc: string; tag: string }
];
secondaryButton?: { text: string; href: string };
heroAnimationDelay?: number;
textAnimation: "slide-up" | "fade-blur" | "fade";
}
const HeroWorkScrollStack = ({
tag,
title,
titleHighlight,
description,
descriptionMuted,
primaryButton,
sectionTag,
sectionTitle,
sectionDescription,
items,
secondaryButton,
heroAnimationDelay,
textAnimation,
}: HeroWorkScrollStackProps) => {
const animationRef = useRef<HTMLDivElement>(null);
const placeholderRef = useRef<HTMLDivElement>(null);
const card1Ref = useRef<HTMLDivElement>(null);
const card2Ref = useRef<HTMLDivElement>(null);
const card3Ref = useRef<HTMLDivElement>(null);
const handlePrimaryClick = useButtonClick(primaryButton.href);
const handleSecondaryClick = useButtonClick(secondaryButton?.href || "#");
useEffect(() => {
const isDesktop = window.matchMedia("(min-width: 768px)").matches;
const ctx = gsap.context(() => {
const cardRefs = [card1Ref.current, card2Ref.current, card3Ref.current];
const placeholder = placeholderRef.current;
if (!placeholder) return;
const placeholderRect = placeholder.getBoundingClientRect();
const placeholderCenterY = placeholderRect.top + placeholderRect.height / 2;
if (isDesktop) {
// DESKTOP: Scrub animation tied to scroll position
const xOffsets = ["32rem", "14.5rem", "-1.8rem"];
const yAdjustments = [0, -48, 0];
const rotations = [-5, 0, 5];
const scales = [1.35, 1.3, 1.25];
const zIndexes = [30, 20, 10];
const tl = gsap.timeline({
scrollTrigger: {
trigger: animationRef.current,
start: "top top",
end: "bottom bottom",
scrub: 1,
},
});
cardRefs.forEach((card, i) => {
if (!card) return;
const cardRect = card.getBoundingClientRect();
const cardCenterY = cardRect.top + cardRect.height / 2;
const yOffset = placeholderCenterY - cardCenterY;
gsap.set(card, {
x: xOffsets[i],
y: yOffset + yAdjustments[i],
rotation: rotations[i],
scale: scales[i],
zIndex: zIndexes[i],
willChange: "transform",
force3D: true,
});
tl.to(card, { x: 0, y: 0, rotation: 0, scale: 1, duration: 0.4, ease: "none" }, 0);
tl.to(card, { zIndex: 1, duration: 0.1, ease: "none" }, 0.3);
});
} else {
// MOBILE: Toggle animation - play/reverse on scroll
const xOffsets = ["2.5rem", "0.5rem", "-1rem"];
const yAdjustments = [-10, -30, 10];
const rotations = [-5, 0, 5];
const scales = [0.65, 0.7, 0.75];
const zIndexes = [30, 20, 10];
cardRefs.forEach((card, i) => {
if (!card) return;
const cardRect = card.getBoundingClientRect();
const cardCenterY = cardRect.top + cardRect.height / 2;
const yOffset = placeholderCenterY - cardCenterY;
gsap.set(card, {
x: xOffsets[i],
y: yOffset + yAdjustments[i],
rotation: rotations[i],
scale: scales[i],
zIndex: zIndexes[i],
willChange: "transform",
force3D: true,
});
gsap.to(card, {
x: 0,
y: 0,
rotation: 0,
scale: 1,
duration: 1.2,
ease: "power2.inOut",
scrollTrigger: {
trigger: placeholder,
start: "top 35%",
toggleActions: "play none none reverse",
},
});
});
}
}, animationRef);
return () => ctx.revert();
}, []);
return (
<div ref={animationRef}>
<div id="hero" data-section="hero">
<section aria-label="Hero section" className="relative h-fit md:h-svh pt-30 pb-20 md:py-0 flex items-center overflow-hidden md:overflow-visible">
<HeroBackgroundSlot />
<div className="w-content-width mx-auto">
<div className="flex flex-col md:flex-row items-center gap-10 md:gap-20 w-full">
<motion.div
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 1.8, ease: [0.16, 1, 0.3, 1], delay: heroAnimationDelay ?? 0 }}
className="w-full md:w-[46%] flex flex-col items-center md:items-start gap-3"
>
<div className="card backdrop-blur flex items-center gap-2 px-3 py-1 rounded">
<span className="size-2 rounded-full bg-green-500 animate-pulsate [--accent:#22c55e]" />
<p className="text-sm leading-snug font-medium text-foreground">{tag}</p>
</div>
<h1 className="text-6xl md:text-7xl 2xl:text-8xl font-medium leading-[1.05] tracking-tight text-center md:text-left">
<span className="inline pb-[0.1em] -mb-[0.1em] bg-linear-to-r from-foreground to-primary-cta bg-clip-text text-transparent">
{title}{" "}
<span className="font-bold">{titleHighlight}</span>
</span>
</h1>
<p className="text-base md:text-lg font-medium leading-snug text-center text-balance md:text-left max-w-[95%]">
{description}{" "}
<span className="text-foreground/50">{descriptionMuted}</span>
</p>
<a
href={primaryButton.href}
onClick={handlePrimaryClick}
className="group flex items-center gap-3 mt-2 text-primary-cta-text rounded-full pl-3 pr-6 py-3 w-fit primary-button transition-all duration-300"
>
<div className="flex items-center">
<div className="card p-px rounded-full transition-transform duration-500 ease-out group-hover:-rotate-6">
<img
src={primaryButton.avatarSrc}
className="w-9 h-9 rounded-full object-cover"
alt=""
/>
</div>
<div className="grid grid-cols-[0fr] group-hover:grid-cols-[1fr] transition-all duration-500 ease-out">
<div className="overflow-hidden flex items-center">
<span className="text-primary-cta-text text-sm font-medium mx-2 transition-transform duration-500 ease-out -translate-x-3 group-hover:translate-x-0">
+
</span>
<div className="card p-px rounded-full shrink-0 transition-transform duration-500 ease-out -translate-x-5 group-hover:translate-x-0 group-hover:rotate-6">
<span className="w-9 h-9 rounded-full flex items-center justify-center">
<span className="text-foreground text-xs font-bold">{primaryButton.avatarLabel}</span>
</span>
</div>
</div>
</div>
</div>
<span className="text-base font-medium whitespace-nowrap">{primaryButton.text}</span>
</a>
</motion.div>
<div ref={placeholderRef} className="w-full md:w-[54%] relative h-80 md:h-96">
<div className="absolute inset-0 card rounded-2xl md:hidden" />
</div>
</div>
</div>
</section>
</div>
<div id="work" data-section="work">
<section aria-label="Work section" className="py-20 md:pt-0">
<div className="flex flex-col gap-8 w-content-width mx-auto">
<div className="flex flex-col items-center gap-2">
<div className="px-3 py-1 mb-1 text-sm card rounded w-fit">
<p>{sectionTag}</p>
</div>
<TextAnimation
text={sectionTitle}
variant={textAnimation}
gradientText={true}
tag="h2"
className="md:max-w-8/10 text-6xl 2xl:text-7xl leading-[1.15] font-semibold text-center text-balance"
/>
<TextAnimation
text={sectionDescription}
variant={textAnimation}
gradientText={false}
tag="p"
className="md:max-w-7/10 text-lg md:text-xl leading-snug text-center text-balance"
/>
</div>
<div className="grid md:grid-cols-3 gap-5">
{items.map((item, index) => {
const cardRef = index === 0 ? card1Ref : index === 1 ? card2Ref : card3Ref;
return (
<div key={item.title} className="flex flex-col gap-3 xl:gap-4 2xl:gap-5">
<div
ref={cardRef}
className="aspect-4/3 rounded-2xl shadow-2xl relative card p-2 xl:p-3 2xl:p-4"
>
<div className="w-full h-full aspect-4/3 rounded-xl overflow-hidden relative">
<ImageOrVideo imageSrc={item.imageSrc} className="w-full h-full object-cover" />
<span className="absolute bottom-2 left-2 xl:bottom-3 xl:left-3 2xl:bottom-4 2xl:left-4 px-3 py-1.5 text-xs font-medium text-primary-cta-text rounded-full backdrop-blur-xl bg-primary-cta-text/15 border border-primary-cta-text/20">
{item.tag}
</span>
</div>
</div>
<p className="text-lg md:text-xl lg:text-2xl leading-snug">
<span className="font-semibold text-foreground">{item.title}. </span>
<span className="text-foreground/50">{item.description}</span>
</p>
</div>
);
})}
</div>
{secondaryButton && (
<div className="flex justify-center">
<a
href={secondaryButton.href}
onClick={handleSecondaryClick}
className="group flex items-center gap-2 px-6 py-3 text-base font-medium rounded-full secondary-button text-secondary-cta-text transition-all duration-300"
>
<span>{secondaryButton.text}</span>
<ArrowRight className="size-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
</div>
)}
</div>
</section>
</div>
</div>
);
};
export default HeroWorkScrollStack;

View File

@@ -0,0 +1,71 @@
import { cls } from "@/lib/utils";
type ContentItem =
| { type: "paragraph"; text: string }
| { type: "list"; items: string[] }
| { type: "numbered-list"; items: string[] };
type ContentSection = {
heading: string;
content: ContentItem[];
};
const PolicyContent = ({
title,
subtitle,
sections,
}: {
title: string;
subtitle?: string;
sections: ContentSection[];
}) => {
return (
<article aria-label="Policy content" className="w-content-width mx-auto pt-40 pb-20">
<div className="md:max-w-1/2 mx-auto flex flex-col gap-5">
<div className="flex flex-col gap-3">
<h1 className="text-3xl md:text-4xl font-semibold leading-snug">{title}</h1>
{subtitle && (
<p className="text-sm opacity-50">{subtitle}</p>
)}
</div>
<div className="w-full h-px bg-foreground/20" />
<div className="flex flex-col gap-5">
{sections.map((section) => (
<section key={section.heading} className="flex flex-col gap-3">
<h2 className="text-xl md:text-2xl font-semibold leading-snug">{section.heading}</h2>
{section.content.map((item, i) => {
if (item.type === "paragraph") {
return (
<p key={i} className="text-sm md:text-base opacity-75 leading-relaxed">
{item.text}
</p>
);
}
const ListTag = item.type === "numbered-list" ? "ol" : "ul";
return (
<ListTag
key={i}
className={cls(
"flex flex-col gap-3 pl-5 text-sm md:text-base opacity-75 leading-relaxed",
item.type === "numbered-list" ? "list-decimal" : "list-disc"
)}
>
{item.items.map((li, j) => (
<li key={j}>{li}</li>
))}
</ListTag>
);
})}
</section>
))}
</div>
</div>
</article>
);
};
export default PolicyContent;

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