Initial commit

This commit is contained in:
dk
2025-12-21 20:24:39 +02:00
commit 6767c847c8
308 changed files with 64977 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,195 @@
"use client";
import { memo, Children } from "react";
import { CardStackProps } from "./types";
import GridLayout from "./layouts/grid/GridLayout";
import AutoCarousel from "./layouts/carousels/AutoCarousel";
import ButtonCarousel from "./layouts/carousels/ButtonCarousel";
import TimelineBase from "./layouts/timelines/TimelineBase";
const CardStack = ({
children,
mode = "buttons",
gridVariant = "uniform-all-items-equal",
uniformGridCustomHeightClasses,
gridRowsClassName,
itemHeightClassesOverride,
animationType,
containerStyle = "default",
title,
titleSegments,
description,
tag,
tagIcon,
buttons,
textboxLayout = "default",
useInvertedBackground,
carouselThreshold = 5,
className = "",
containerClassName = "",
gridClassName = "",
carouselClassName = "",
carouselItemClassName = "",
controlsClassName = "",
textBoxClassName = "",
titleClassName = "",
titleImageWrapperClassName = "",
titleImageClassName = "",
descriptionClassName = "",
tagClassName = "",
buttonContainerClassName = "",
buttonClassName = "",
buttonTextClassName = "",
ariaLabel = "Card stack",
}: CardStackProps) => {
const childrenArray = Children.toArray(children);
const itemCount = childrenArray.length;
// Timeline layout for zigzag pattern (works best with 3-6 items)
if ((gridVariant === "timeline" || gridVariant === "timeline-three-columns") && itemCount >= 3 && itemCount <= 6) {
return (
<TimelineBase
variant={gridVariant}
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
containerStyle={containerStyle}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
className={className}
containerClassName={containerClassName}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
ariaLabel={ariaLabel}
>
{childrenArray}
</TimelineBase>
);
}
// Use grid for items below threshold, carousel for items at or above threshold
// Timeline with 7+ items will also use carousel
const useCarousel = itemCount >= carouselThreshold || ((gridVariant === "timeline" || gridVariant === "timeline-three-columns") && itemCount > 6);
// Grid layout for 1-4 items
if (!useCarousel) {
return (
<GridLayout
itemCount={itemCount}
gridVariant={gridVariant}
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
gridRowsClassName={gridRowsClassName}
itemHeightClassesOverride={itemHeightClassesOverride}
animationType={animationType}
containerStyle={containerStyle}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
className={className}
containerClassName={containerClassName}
gridClassName={gridClassName}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
ariaLabel={ariaLabel}
>
{childrenArray}
</GridLayout>
);
}
// Auto-scroll carousel for 5+ items
if (mode === "auto") {
return (
<AutoCarousel
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
containerStyle={containerStyle}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
className={className}
containerClassName={containerClassName}
carouselClassName={carouselClassName}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
ariaLabel={ariaLabel}
>
{childrenArray}
</AutoCarousel>
);
}
// Button-controlled carousel for 5+ items
return (
<ButtonCarousel
uniformGridCustomHeightClasses={uniformGridCustomHeightClasses}
animationType={animationType}
containerStyle={containerStyle}
title={title}
titleSegments={titleSegments}
description={description}
tag={tag}
tagIcon={tagIcon}
buttons={buttons}
textboxLayout={textboxLayout}
useInvertedBackground={useInvertedBackground}
className={className}
containerClassName={containerClassName}
carouselClassName={carouselClassName}
carouselItemClassName={carouselItemClassName}
controlsClassName={controlsClassName}
textBoxClassName={textBoxClassName}
titleClassName={titleClassName}
titleImageWrapperClassName={titleImageWrapperClassName}
titleImageClassName={titleImageClassName}
descriptionClassName={descriptionClassName}
tagClassName={tagClassName}
buttonContainerClassName={buttonContainerClassName}
buttonClassName={buttonClassName}
buttonTextClassName={buttonTextClassName}
ariaLabel={ariaLabel}
>
{childrenArray}
</ButtonCarousel>
);
};
CardStack.displayName = "CardStack";
export default memo(CardStack);

View File

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

View File

@@ -0,0 +1,95 @@
import { useRef } from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { CardAnimationType } from "../types";
gsap.registerPlugin(ScrollTrigger);
interface UseCardAnimationProps {
animationType: CardAnimationType;
itemCount: number;
}
export const useCardAnimation = ({ animationType, itemCount }: UseCardAnimationProps) => {
const itemRefs = useRef<(HTMLElement | null)[]>([]);
useGSAP(() => {
if (animationType === "none" || itemRefs.current.length === 0) return;
const items = itemRefs.current.filter((el) => el !== null);
if (animationType === "opacity") {
gsap.fromTo(
items,
{ opacity: 0 },
{
opacity: 1,
duration: 1.25,
stagger: 0.15,
ease: "sine",
scrollTrigger: {
trigger: items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
} else if (animationType === "slide-up") {
items.forEach((item, index) => {
gsap.fromTo(
item,
{ opacity: 0, yPercent: 15 },
{
opacity: 1,
yPercent: 0,
duration: 1,
delay: index * 0.15,
ease: "sine",
scrollTrigger: {
trigger: items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
});
} else if (animationType === "scale-rotate") {
gsap.fromTo(
items,
{ scaleX: 0, rotate: 10 },
{
scaleX: 1,
rotate: 0,
duration: 1,
stagger: 0.15,
ease: "power3",
scrollTrigger: {
trigger: items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
} else if (animationType === "blur-reveal") {
gsap.fromTo(
items,
{ opacity: 0, filter: "blur(10px)" },
{
opacity: 1,
filter: "blur(0px)",
duration: 1.2,
stagger: 0.15,
ease: "power2.out",
scrollTrigger: {
trigger: items[0],
start: "top 80%",
toggleActions: "play none none none",
},
}
);
}
}, [animationType, itemCount]);
return { itemRefs };
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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