diff --git a/src/components/icons/Caret.module.scss b/src/components/icons/Caret.module.scss
new file mode 100644
index 000000000..8dcbbc5d2
--- /dev/null
+++ b/src/components/icons/Caret.module.scss
@@ -0,0 +1,14 @@
+.CaretIcon {
+ transition: transform 0.25s ease-in-out;
+
+ // Direction
+ &[data-direction="down"] {
+ transform: rotate(270deg);
+ }
+ &[data-direction="left"] {
+ transform: rotate(180deg);
+ }
+ &[data-direction="up"] {
+ transform: rotate(90deg);
+ }
+}
diff --git a/src/components/icons/Caret.tsx b/src/components/icons/Caret.tsx
new file mode 100644
index 000000000..5c1e1bb1a
--- /dev/null
+++ b/src/components/icons/Caret.tsx
@@ -0,0 +1,52 @@
+import { FC, SVGProps } from "react";
+import classNames from "classnames";
+
+import styles from "./Caret.module.scss";
+
+interface CaretIconProps extends SVGProps
{
+ color?: string;
+ direction?: "up" | "down" | "left" | "right";
+ className?: string;
+}
+
+const CaretIcon: FC = ({
+ color = "white",
+ direction = "right",
+ className,
+ ...props
+}) => {
+ return (
+
+ );
+};
+
+export default CaretIcon;
diff --git a/src/components/shared/CardsSlider.module.scss b/src/components/shared/CardsSlider.module.scss
new file mode 100644
index 000000000..8818515ab
--- /dev/null
+++ b/src/components/shared/CardsSlider.module.scss
@@ -0,0 +1,132 @@
+@import "~@/scss/solutions/_variables.scss";
+
+.Title {
+ font-size: 40px;
+ font-weight: 700;
+ line-height: 1.04;
+ letter-spacing: -0.01em;
+ text-align: center;
+ color: var(--white);
+ text-transform: capitalize;
+ margin: 0;
+ padding: 64px 24px 0;
+
+ strong {
+ background: var(--gradient-2);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ }
+
+ @include breakpoint(md) {
+ font-size: 56px;
+ padding-top: 80px;
+ }
+
+ @include breakpoint(lg) {
+ font-size: 64px;
+ padding-top: 128px;
+ }
+}
+
+.Carousel {
+ position: relative;
+ width: 100%;
+}
+
+.CarouselContainer {
+ display: flex;
+ width: 100%;
+ padding: {
+ top: 64px;
+ bottom: 64px;
+ }
+
+ overflow-x: scroll;
+ overscroll-behavior-x: auto;
+ scroll-behavior: smooth;
+ scrollbar-width: none; /*FireFox*/
+ -ms-overflow-style: -ms-autohiding-scrollbar; /*IE10+*/
+
+ /*Chrome, Safari, Edge*/
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ @include breakpoint(md) {
+ padding: {
+ top: 80px;
+ bottom: 40px;
+ }
+ }
+
+ @include breakpoint(lg) {
+ padding: {
+ top: 96px;
+ }
+ }
+}
+
+.Cards {
+ display: flex;
+ justify-content: start;
+ gap: 16px;
+ padding-left: 16px;
+ max-width: 80rem;
+ margin: 0 auto;
+}
+
+.CardWrapper {
+ border-radius: 1.5rem;
+ box-sizing: content-box;
+
+ &:last-child {
+ padding-right: 3%;
+
+ @include breakpoint(md) {
+ padding-right: 10%;
+ }
+
+ @include breakpoint(lg) {
+ padding-right: 33%;
+ }
+ }
+
+ @include breakpoint(md) {
+ > * {
+ width: 22rem;
+ height: 100%;
+ }
+ }
+}
+
+.Arrows {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ padding-bottom: 64px;
+
+ button {
+ position: relative;
+ z-index: 40;
+ height: 40px;
+ width: 40px;
+ border-radius: 50%;
+ background-color: var(--grey-300);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.1s ease;
+ transition:
+ background 0.3s ease-in-out,
+ transform 0.15s $easeInOutQuart;
+
+ &:disabled {
+ background-color: var(--grey-500);
+
+ svg path {
+ stroke: var(--grey-300);
+ }
+ }
+ }
+}
diff --git a/src/components/shared/CardsSlider.tsx b/src/components/shared/CardsSlider.tsx
new file mode 100644
index 000000000..f76474ab7
--- /dev/null
+++ b/src/components/shared/CardsSlider.tsx
@@ -0,0 +1,126 @@
+import { useEffect, useState, useRef, ReactNode } from "react";
+import { motion } from "framer-motion";
+import { Trans } from "next-i18next";
+import { useInView } from "react-intersection-observer";
+import classNames from "classnames";
+
+import CaretIcon from "@/components/icons/Caret";
+import { AnimatedText } from "@/components/shared/Text";
+
+import styles from "./CardsSlider.module.scss";
+
+interface CarouselProps {
+ items: Element[] | ReactNode[];
+ titleKey?: string;
+ initialScroll?: number;
+ id?: string;
+ className?: string;
+ carouselClassName?: string;
+ cardsClassName?: string;
+ cardWrapperClassName?: string;
+}
+
+const CardsSlider = ({
+ items,
+ titleKey,
+ initialScroll = 0,
+ id = "",
+ className = "",
+ carouselClassName = "",
+ cardsClassName = "",
+ cardWrapperClassName = "",
+}: CarouselProps) => {
+ const carouselRef = useRef(null);
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(true);
+
+ useEffect(() => {
+ if (carouselRef.current) {
+ carouselRef.current.scrollLeft = initialScroll;
+ checkScrollability();
+ }
+ }, [initialScroll]);
+
+ const checkScrollability = () => {
+ if (carouselRef.current) {
+ const { scrollLeft, scrollWidth, clientWidth } = carouselRef.current;
+ setCanScrollLeft(scrollLeft > 0);
+ setCanScrollRight(scrollLeft < scrollWidth - clientWidth);
+ }
+ };
+
+ const scrollLeft = () => {
+ if (carouselRef.current) {
+ carouselRef.current.scrollBy({ left: -304, behavior: "smooth" });
+ }
+ };
+
+ const scrollRight = () => {
+ if (carouselRef.current) {
+ carouselRef.current.scrollBy({ left: 304, behavior: "smooth" });
+ }
+ };
+
+ const { ref, inView } = useInView({
+ triggerOnce: true,
+ threshold: 0.5,
+ });
+
+ return (
+
+ {titleKey && (
+
+
+
+ )}
+
+
+
+
+ {items.map((item: any, index: number) => (
+
+ {item}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CardsSlider;
diff --git a/src/components/shared/CollapsibleContent.module.scss b/src/components/shared/CollapsibleContent.module.scss
new file mode 100644
index 000000000..81ccf47d2
--- /dev/null
+++ b/src/components/shared/CollapsibleContent.module.scss
@@ -0,0 +1,30 @@
+@import "../../scss/solutions/_variables.scss";
+
+.CollapsibleTrigger {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ gap: 16px;
+ font-size: 18px;
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ line-height: 1.16;
+ color: var(--grey-200);
+ text-align: left;
+ margin-bottom: 0;
+
+ p {
+ margin: 0;
+ }
+}
+
+.IconArrowDown {
+ transform: rotate(180deg);
+}
+
+@include breakpoint(md) {
+ .CollapsibleTrigger {
+ justify-content: flex-start;
+ }
+}
diff --git a/src/components/shared/CollapsibleContent.tsx b/src/components/shared/CollapsibleContent.tsx
new file mode 100644
index 000000000..d1070f0bc
--- /dev/null
+++ b/src/components/shared/CollapsibleContent.tsx
@@ -0,0 +1,84 @@
+import { useState, ReactNode } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import * as Collapsible from "@radix-ui/react-collapsible";
+import styles from "./CollapsibleContent.module.scss";
+import CaretIcon from "@/components/icons/Caret";
+
+interface CollapsibleContentProps {
+ label: string;
+ defaultOpen?: boolean;
+ className?: string;
+ triggerClassName?: string;
+ onOpenChange?: () => void;
+ previewContent?: ReactNode;
+ children: ReactNode;
+}
+
+const CollapsibleContent: React.FC = ({
+ label,
+ defaultOpen,
+ className,
+ triggerClassName,
+ onOpenChange,
+ previewContent,
+ children,
+}) => {
+ const [open, setOpen] = useState(defaultOpen || false);
+
+ const handleOpenChange = () => {
+ setOpen(!open);
+ onOpenChange && onOpenChange();
+ };
+
+ return (
+
+
+
+
+
+ {previewContent ? previewContent : null}
+
+
+ {open && (
+
+ {children}
+
+ )}
+
+
+ );
+};
+
+export default CollapsibleContent;
diff --git a/src/components/shared/LottieCarousel.module.scss b/src/components/shared/LottieCarousel.module.scss
new file mode 100644
index 000000000..b021b279d
--- /dev/null
+++ b/src/components/shared/LottieCarousel.module.scss
@@ -0,0 +1,95 @@
+@import "~@/scss/solutions/_variables.scss";
+
+.LottieCarouselSection {
+ padding: 64px 0 0;
+
+ @include breakpoint(lg) {
+ padding-top: 7rem;
+ }
+}
+
+.LottieCarouselItem {
+ transition: opacity 0.3s ease;
+
+ @include breakpoint(lg) {
+ opacity: 0.5;
+ }
+
+ &.ActiveSlide {
+ opacity: 1;
+ transition: opacity 0.3s ease;
+ }
+
+ > * {
+ padding-left: 24px;
+ padding-right: 24px;
+ }
+
+ p {
+ font-weight: 700;
+ font-size: 16px;
+ line-height: 1.16;
+ text-align: center;
+ letter-spacing: -0.01em;
+ color: var(--grey-250);
+ margin: 40px auto 0;
+
+ @include breakpoint(md) {
+ font-size: 18px;
+ }
+ @include breakpoint(lg) {
+ max-width: 100%;
+ }
+
+ strong {
+ color: var(--white);
+ }
+ }
+
+ .LottieWrapper {
+ margin: 0 auto;
+ padding: 0 3rem;
+ max-width: 70vw;
+
+ @include breakpoint(md) {
+ max-width: 400px;
+ }
+ }
+}
+
+.Arrows {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ padding-bottom: 40px;
+ padding-top: 32px;
+
+ @include breakpoint(lg) {
+ padding-bottom: 64px;
+ padding-top: 40px;
+ }
+
+ button {
+ position: relative;
+ z-index: 40;
+ height: 40px;
+ width: 40px;
+ border-radius: 50%;
+ background-color: var(--grey-300);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background-color 0.1s ease;
+ transition:
+ background 0.3s ease-in-out,
+ transform 0.15s $easeInOutQuart;
+
+ &:disabled {
+ background-color: var(--grey-500);
+
+ svg path {
+ stroke: var(--grey-300);
+ }
+ }
+ }
+}
diff --git a/src/components/shared/LottieCarousel.tsx b/src/components/shared/LottieCarousel.tsx
new file mode 100644
index 000000000..aa6ce83db
--- /dev/null
+++ b/src/components/shared/LottieCarousel.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import { useRef, ReactNode, useEffect, useState } from "react";
+import Lottie from "react-lottie";
+import Slider from "react-slick";
+import "slick-carousel/slick/slick.css";
+import "slick-carousel/slick/slick-theme.css";
+
+import Text from "@/components/shared/Text";
+import CaretIcon from "@/components/icons/Caret";
+
+import styles from "./LottieCarousel.module.scss";
+
+interface LottieCarouselProps {
+ itemsMobile: ReactNode[];
+ itemsDesktop?: ReactNode[];
+ itemsStateMobile?: any[];
+ itemsStateDesktop?: any[];
+ setItemsStateMobile?: (_state: any) => void;
+ setItemsStateDesktop?: (_state: any) => void;
+}
+
+const LottieCarousel = ({
+ itemsMobile,
+ itemsDesktop,
+ itemsStateMobile,
+ itemsStateDesktop,
+ setItemsStateMobile,
+ setItemsStateDesktop,
+}: LottieCarouselProps) => {
+ const sliderMobileRef = useRef(null);
+ const sliderDesktopRef = useRef(null);
+
+ const [isMobile, setIsMobile] = useState(null);
+
+ useEffect(() => {
+ if (window.innerWidth < 992) {
+ setIsMobile(true);
+ } else {
+ setIsMobile(false);
+ }
+ }, []);
+
+ const onBeforeChange = (current: number, next: number) => {
+ if (isMobile) {
+ let stateMobileCopy = new Array(itemsStateMobile.length).fill(true);
+ stateMobileCopy[next] = false;
+ setItemsStateMobile(stateMobileCopy);
+ }
+ };
+
+ const onAfterChange = () => {
+ if (!isMobile) {
+ const slick = document.querySelector(
+ `.${styles.LottieCarouselSection} .d-none.d-md-block .slick-list`,
+ );
+ const currentSlide =
+ sliderDesktopRef.current.innerSlider.state.currentSlide;
+ const stateDesktopCopy = new Array(itemsStateDesktop.length).fill(true);
+ stateDesktopCopy[currentSlide === 2 ? 0 : currentSlide + 1] = false;
+ setItemsStateDesktop(stateDesktopCopy);
+
+ const slides = slick.querySelectorAll(".slick-slide");
+ const slidesActive = slick.querySelectorAll(".slick-slide.slick-active");
+
+ for (let i = 0; i < slides.length; i++) {
+ const carouselItem = slides[i].querySelector(
+ `.${styles.LottieCarouselItem}`,
+ );
+ carouselItem.classList.remove(`${styles.ActiveSlide}`);
+ }
+
+ for (let i = 0; i < slidesActive.length; i++) {
+ const carouselItem = slidesActive[i].querySelector(
+ `.${styles.LottieCarouselItem}`,
+ );
+ if (i === slidesActive.length - 1) {
+ carouselItem.classList.add(`${styles.ActiveSlide}`);
+ } else {
+ carouselItem.classList.remove(`${styles.ActiveSlide}`);
+ }
+ }
+ }
+ };
+
+ const handlePrev = () => {
+ sliderMobileRef.current.slickPrev();
+ sliderDesktopRef.current.slickPrev();
+ };
+
+ const handleNext = () => {
+ sliderMobileRef.current.slickNext();
+ sliderDesktopRef.current.slickNext();
+ };
+
+ const settings = {
+ // Show 3 slides at a time
+ dots: false,
+ autoplay: false,
+ arrows: false,
+ infinite: true,
+ beforeChange: onBeforeChange,
+ afterChange: onAfterChange,
+ slidesToShow: 3,
+ slidesToScroll: 1,
+ centerMode: true,
+ centerPadding: "0",
+ responsive: [
+ {
+ // On mobile, show 1 slide at a time
+ breakpoint: 992,
+ settings: {
+ slidesToShow: 1,
+ centerMode: false,
+ },
+ },
+ ],
+ };
+
+ useEffect(() => {
+ if (!isMobile) {
+ const slick = document.querySelector(
+ `.${styles.LottieCarouselSection} .d-none.d-md-block .slick-list`,
+ );
+
+ const slidesActive = slick.querySelectorAll(".slick-slide.slick-active");
+
+ for (let i = 0; i < slidesActive.length; i++) {
+ const carouselItem = slidesActive[i].querySelector(
+ `.${styles.LottieCarouselItem}`,
+ );
+ if (i === slidesActive.length - 1) {
+ carouselItem.classList.add(`${styles.ActiveSlide}`);
+ } else {
+ carouselItem.classList.remove(`${styles.ActiveSlide}`);
+ }
+ }
+ }
+ }, [isMobile]);
+
+ return (
+
+
+
+ {itemsMobile.map((item, index) => (
+ {item}
+ ))}
+
+
+
+
+
+ {itemsDesktop.map((item, index) => (
+ {item}
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
+
+interface LottieCarouselItemProps {
+ lottie: any;
+ text: string | ReactNode;
+ isLottiePaused: boolean;
+}
+
+export const LottieCarouselItem = ({
+ lottie,
+ text,
+ isLottiePaused,
+}: LottieCarouselItemProps) => {
+ return (
+
+ );
+};
+
+export default LottieCarousel;
diff --git a/src/components/shared/Motions.jsx b/src/components/shared/Motions.jsx
new file mode 100644
index 000000000..0d68d61ef
--- /dev/null
+++ b/src/components/shared/Motions.jsx
@@ -0,0 +1,102 @@
+import { motion } from "framer-motion";
+import { useInView } from "react-intersection-observer";
+
+import { animations, easeFunctions, durations } from "@/constants/animations";
+
+//
+// Base motion wrapper component
+//
+export const MotionComponent = ({
+ animateTrigger = "whenInView", // "variable"
+ startAnimation = true,
+ inViewProps = {},
+ initial = {},
+ animate = {},
+ transition = {},
+ delayIndex = 0, // automated way to delay by an X amount
+ delayBase = 0.4,
+ delayIncrease = 0.175,
+ element = "div",
+ children,
+ ...props
+}) => {
+ //
+ // Enables triggering animation when component is in view
+ //
+ const { ref, inView } = useInView({
+ triggerOnce: true,
+ threshold: 0.5,
+ ...inViewProps,
+ });
+
+ if (animateTrigger === "whenInView") {
+ startAnimation = inView;
+ }
+
+ if (delayIndex) {
+ transition = {
+ ...transition,
+ delay: delayBase + delayIncrease * delayIndex,
+ };
+ }
+
+ const MotionComponentElement = motion[element];
+ return (
+
+ {children}
+
+ );
+};
+
+//
+// Animations
+//
+
+//
+// SlideIn
+//
+const slideInTransition = {
+ duration: durations.slower,
+ ease: easeFunctions.easeInQuart,
+};
+
+const slideInAnimations = {
+ bottom: {
+ initial: animations.fadeScaleSlideOut,
+ animate: animations.fadeScaleSlideIn,
+ },
+ right: {
+ initial: animations.fadeScaleSlideOutFromRight,
+ animate: animations.fadeScaleSlideInFromRight,
+ },
+ left: {
+ initial: animations.fadeScaleSlideOutFromLeft,
+ animate: animations.fadeScaleSlideInFromLeft,
+ },
+};
+
+export const MotionSlideIn = ({
+ transition = {},
+ from = "bottom",
+ children,
+ ...props
+}) => {
+ const animations = slideInAnimations[from];
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/shared/Text.module.scss b/src/components/shared/Text.module.scss
new file mode 100644
index 000000000..bca25cc0e
--- /dev/null
+++ b/src/components/shared/Text.module.scss
@@ -0,0 +1,42 @@
+@import "~@/scss/solutions/_variables.scss";
+
+.heading {
+ text-wrap: balance;
+}
+
+.paragraph {
+ text-wrap: pretty;
+
+ @include breakpoint(sm) {
+ max-width: 65%;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+.GradientText {
+ background: var(--gradient);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+
+ &::selection {
+ -webkit-text-fill-color: var(--black);
+ }
+}
+
+.OpacityInText {
+ --delay: 0s;
+
+ opacity: 0;
+ transition:
+ opacity $duration-standard $easeInQuart,
+ transform $duration-shorter $easeInQuart;
+ transform: translateY($anim-translate-y);
+ transition-delay: var(--delay);
+
+ &.Active {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
diff --git a/src/components/shared/Text.tsx b/src/components/shared/Text.tsx
new file mode 100644
index 000000000..c522cb404
--- /dev/null
+++ b/src/components/shared/Text.tsx
@@ -0,0 +1,156 @@
+import {
+ FC,
+ CSSProperties,
+ ReactNode,
+ useEffect,
+ useState,
+ useRef,
+} from "react";
+import classNames from "classnames";
+import { MotionSlideIn } from "@/components/shared/Motions";
+
+import styles from "./Text.module.scss";
+
+interface TextProps {
+ element: keyof JSX.IntrinsicElements;
+ as?: "heading" | "paragraph";
+ className?: string;
+ style?: CSSProperties;
+ children?: ReactNode;
+
+ /**
+ * The `as` prop determines the styling of the text:
+ * - "heading" applies text-wrap balanced
+ * - "paragraph" applies text-wrap pretty
+ */
+}
+
+interface AnimatedTextProps extends TextProps {
+ delayIndex?: number;
+}
+
+/**
+ * A simple text component that can be styled as a heading or paragraph.
+ */
+const Text: FC = ({
+ element: Element,
+ as,
+ className,
+ style = {},
+ children,
+}) => (
+
+ {children}
+
+);
+
+/**
+ * A text component that animates when it comes into the viewport.
+ *
+ * @param element - The HTML element to render.
+ * @param as - Determines the styling of the text ("heading" or "paragraph").
+ * @param className - Optional additional class names to apply.
+ * @param delayIndex - Optional delay index for the animation.
+ * @param children - The content to be animated.
+ */
+export const AnimatedText: FC = ({
+ element,
+ as,
+ className,
+ delayIndex = 0,
+ children,
+}) => (
+
+ {children}
+
+);
+
+/**
+ * Wrapper for gradient text.
+ * The children component should contain a element,
+ * which is what will be styled with the gradient.
+ *
+ * The `gradient` prop should be a linear gradient string.
+ *
+ * Example usage:
+ *
+ *
+ * Your Text Here
+ *
+ *
+ * Note: If it doesn't work with the gradient prop
+ * (maybe b/c of dynamically importing the component),
+ * set the --gradient CSS variable for the element in the
+ * component CSS file.
+ */
+export const GradientText: FC<{
+ gradient?: string;
+ fallbackColor?: string;
+ children?: ReactNode;
+}> = ({ gradient, fallbackColor = "white", children }) => {
+ const container = useRef(null);
+
+ useEffect(() => {
+ if (gradient && container.current) {
+ container.current.style.setProperty("--gradient", gradient);
+ }
+ }, [container, gradient]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+/**
+ * A vanilla CSS text component that fades in when it comes into the viewport.
+ * Better performance than using a motion component. Use for hero text.
+ */
+export const OpacityInText: FC = ({
+ element,
+ as,
+ className,
+ children,
+ delayIndex = 0,
+}) => {
+ const [active, setActive] = useState(false);
+
+ useEffect(() => {
+ setTimeout(() => setActive(true), 100);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default Text;