Initial commit

This commit is contained in:
dk
2026-06-13 10:55:23 +00:00
commit d7087fbfb8
315 changed files with 37710 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
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 InfoCardMarquee from "@/components/ui/InfoCardMarquee";
import AnimatedBarChart from "@/components/ui/AnimatedBarChart";
import ChecklistTimeline from "@/components/ui/ChecklistTimeline";
import MediaStack from "@/components/ui/MediaStack";
import { cls } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
gsap.registerPlugin(ScrollTrigger);
type IconInput = string | LucideIcon;
type FeatureItem = { title: string; description: string; primaryButton?: { text: string; href: string }; secondaryButton?: { text: string; href: string } } & (
| { bentoComponent: "info-card-marquee"; infoCards: { icon: IconInput; label: string; value: string }[] }
| { bentoComponent: "animated-bar-chart" }
| { 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 = (item: FeatureItem) => {
switch (item.bentoComponent) {
case "info-card-marquee": return <InfoCardMarquee items={item.infoCards} />;
case "animated-bar-chart": return <AnimatedBarChart />;
case "checklist-timeline": return <ChecklistTimeline heading={item.heading} subheading={item.subheading} items={item.checklistItems} completedLabel={item.completedLabel} />;
case "media-stack": return <MediaStack items={item.mediaItems} />;
}
};
interface FeaturesAlternatingBentoProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: FeatureItem[];
}
const FeaturesAlternatingBento = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: FeaturesAlternatingBentoProps) => {
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="slide-up"
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="slide-up"
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 h-full rounded overflow-hidden bg-foreground/5 p-3 xl:p-4 2xl:p-5">
{getBentoComponent(item)}
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default FeaturesAlternatingBento;

View File

@@ -0,0 +1,128 @@
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[];
}
const FeaturesAlternatingSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: 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="fade"
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="fade"
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,107 @@
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[];
}
const FeaturesArrowCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: 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="fade"
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="fade"
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,99 @@
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[];
}
const FeaturesAttributeCards = ({ tag, title, description, primaryButton, secondaryButton, items }: 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="slide-up"
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="slide-up"
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-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-none">
<div className="relative aspect-4/3 overflow-hidden rounded-none">
<ImageOrVideo imageSrc={item.imageSrc} videoSrc={item.videoSrc} className="rounded-none 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-none">
{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,103 @@
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,
}: {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
features: FeatureCard[];
}) => (
<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="fade-blur"
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="fade-blur"
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>
{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,83 @@
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 };
}
const FeaturesBentoGrid = ({
tag,
title,
description,
features,
primaryButton,
secondaryButton,
}: 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="slide-up"
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="slide-up"
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,112 @@
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;
};
}
const FeaturesBentoGridCta = ({
tag,
title,
description,
features,
ctaButton,
}: 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="slide-up"
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="slide-up"
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,86 @@
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[];
}
const FeaturesBorderGlow = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
}: 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="slide-up"
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="slide-up"
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>
{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,85 @@
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[];
}
const FeaturesComparison = ({
tag,
title,
description,
primaryButton,
secondaryButton,
negativeItems,
positiveItems,
}: 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="fade"
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="fade"
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="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,94 @@
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[];
}
const FeaturesDetailedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: 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="fade"
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="fade"
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="slide-up"
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,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 { 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[];
}
const FeaturesDetailedSteps = ({
tag,
title,
description,
primaryButton,
secondaryButton,
steps,
}: 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="fade"
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="fade"
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,117 @@
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[];
}
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,
}: 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="fade-blur"
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="fade-blur"
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>
{items.map((item) => (
<FeatureFlipCard key={item.title} item={item} />
))}
</GridOrCarousel>
</ScrollReveal>
</div>
</section>
);
};
export default FeaturesFlipCards;

View File

@@ -0,0 +1,105 @@
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;
}
const FeaturesGridSplit = ({
tag,
title,
description,
primaryButton,
secondaryButton,
topItems,
bottomItem,
}: 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="slide-up"
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="slide-up"
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-blur" 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-blur">
<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,87 @@
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[];
}
const FeaturesIconCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
features,
}: 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="slide-up"
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="slide-up"
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,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 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];
}
const FeaturesImageBento = ({ tag, title, description, primaryButton, secondaryButton, items }: 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="fade-blur"
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="fade-blur"
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,81 @@
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[];
}
const FeaturesMediaCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: 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="fade"
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="fade"
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-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,101 @@
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[];
}
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,
}: 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="fade-blur"
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="fade-blur"
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,82 @@
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[];
}
const FeaturesMediaGrid = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: 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="fade-blur"
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="fade-blur"
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-none" />
</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,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 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[];
}
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,
}: 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="slide-up"
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="slide-up"
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">
<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,103 @@
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[];
}
const FeaturesRevealCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: 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="fade-blur"
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="fade-blur"
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>
{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,109 @@
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];
}
const FeaturesRevealCardsBento = ({ tag, title, description, primaryButton, secondaryButton, items }: 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="fade-blur"
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="fade-blur"
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,109 @@
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 FeaturesRevealCardsBentoSharpProps {
tag: string;
title: string;
description: string;
primaryButton?: { text: string; href: string };
secondaryButton?: { text: string; href: string };
items: [FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem, FeatureItem];
}
const FeaturesRevealCardsBentoSharp = ({ tag, title, description, primaryButton, secondaryButton, items }: FeaturesRevealCardsBentoSharpProps) => {
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="slide-up"
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="slide-up"
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="slide-up" delay={staggerDelays[index]} className={cls("col-span-1 group", gridClasses[index])}>
<a href={item.href} className="block relative overflow-hidden rounded-none">
<div className="h-80 xl:h-100 2xl:h-120 overflow-hidden">
<ImageOrVideo
imageSrc={item.imageSrc}
videoSrc={item.videoSrc}
className="rounded-none 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-none 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 FeaturesRevealCardsBentoSharp;

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,89 @@
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[];
}
const FeaturesTaggedCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: 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="fade"
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="fade"
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,121 @@
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];
}
const FeaturesTimelineCards = ({
tag,
title,
description,
primaryButton,
secondaryButton,
items,
}: 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="fade"
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="fade"
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;