diff --git a/components/Blocks/CarouselCards/carouselCards.module.css b/components/Blocks/CarouselCards/carouselCards.module.css
index 4ea1a4c2a..0e4d728c6 100644
--- a/components/Blocks/CarouselCards/carouselCards.module.css
+++ b/components/Blocks/CarouselCards/carouselCards.module.css
@@ -29,3 +29,7 @@
grid-auto-columns: calc((100% - var(--Spacing-x3) * 2) / 3);
}
}
+
+.navigationButton {
+ top: 30%;
+}
diff --git a/components/Blocks/CarouselCards/index.tsx b/components/Blocks/CarouselCards/index.tsx
index 459169958..f19a615c1 100644
--- a/components/Blocks/CarouselCards/index.tsx
+++ b/components/Blocks/CarouselCards/index.tsx
@@ -2,6 +2,7 @@
import { useState } from "react"
+import { Carousel } from "@/components/Carousel"
import ContentCard from "@/components/ContentCard"
import SectionContainer from "@/components/Section/Container"
import SectionHeader from "@/components/Section/Header"
@@ -43,19 +44,24 @@ export default function CarouselCards({ carousel_cards }: CarouselCardsProps) {
onFilterSelect={setActiveFilter}
/>
)}
- {/* Carousel functionality will go here */}
-
- {filteredCards.map((card) => (
-
- ))}
-
+
+
+ {filteredCards.map((card, index) => (
+
+
+
+ ))}
+
+
+
+
+
)
diff --git a/components/Carousel/CarouselContent.tsx b/components/Carousel/CarouselContent.tsx
new file mode 100644
index 000000000..2fff8c089
--- /dev/null
+++ b/components/Carousel/CarouselContent.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import { cx } from "class-variance-authority"
+
+import { useCarousel } from "./CarouselContext"
+
+import styles from "./carousel.module.css"
+
+export function CarouselContent({
+ className,
+ children,
+ ...props
+}: React.HTMLAttributes) {
+ const { carouselRef } = useCarousel()
+
+ return (
+
+ )
+}
diff --git a/components/Carousel/CarouselContext.tsx b/components/Carousel/CarouselContext.tsx
new file mode 100644
index 000000000..a37827d96
--- /dev/null
+++ b/components/Carousel/CarouselContext.tsx
@@ -0,0 +1,17 @@
+"use client"
+
+import { createContext, useContext } from "react"
+
+import type { CarouselContextProps } from "./types"
+
+const CarouselContext = createContext(null)
+
+export function useCarousel() {
+ const context = useContext(CarouselContext)
+ if (!context) {
+ throw new Error("useCarousel must be used within a ")
+ }
+ return context
+}
+
+export { CarouselContext }
diff --git a/components/Carousel/CarouselDots.tsx b/components/Carousel/CarouselDots.tsx
new file mode 100644
index 000000000..2db1b35c0
--- /dev/null
+++ b/components/Carousel/CarouselDots.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import { cx } from "class-variance-authority"
+import { useEffect, useState } from "react"
+
+import { useCarousel } from "./CarouselContext"
+
+import styles from "./carousel.module.css"
+
+export function CarouselDots({ className }: { className?: string }) {
+ const { selectedIndex, api } = useCarousel()
+ const [scrollSnaps, setScrollSnaps] = useState([])
+
+ // Update scroll snaps when the carousel is initialized or viewport changes
+ useEffect(() => {
+ if (!api) return
+
+ const onInit = () => {
+ setScrollSnaps(api.scrollSnapList())
+ }
+
+ onInit()
+ api.on("reInit", onInit)
+
+ return () => {
+ api.off("reInit", onInit)
+ }
+ }, [api])
+
+ // Don't render dots if we have 1 or fewer scroll positions
+ if (scrollSnaps.length <= 1) return null
+
+ return (
+
+ {scrollSnaps.map((_, index) => (
+
+ ))}
+
+ )
+}
diff --git a/components/Carousel/CarouselItem.tsx b/components/Carousel/CarouselItem.tsx
new file mode 100644
index 000000000..f15711833
--- /dev/null
+++ b/components/Carousel/CarouselItem.tsx
@@ -0,0 +1,22 @@
+"use client"
+
+import { cx } from "class-variance-authority"
+
+import styles from "./carousel.module.css"
+
+export function CarouselItem({
+ className,
+ children,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/components/Carousel/CarouselNavigation.tsx b/components/Carousel/CarouselNavigation.tsx
new file mode 100644
index 000000000..3f8566723
--- /dev/null
+++ b/components/Carousel/CarouselNavigation.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import { cx } from "class-variance-authority"
+
+import { ArrowRightIcon } from "@/components/Icons"
+
+import { useCarousel } from "./CarouselContext"
+
+import styles from "./carousel.module.css"
+
+import type { CarouselButtonProps } from "./types"
+
+export function CarouselPrevious({ className, ...props }: CarouselButtonProps) {
+ const { scrollPrev, canScrollPrev } = useCarousel()
+
+ if (!canScrollPrev()) return null
+
+ return (
+
+ )
+}
+
+export function CarouselNext({ className, ...props }: CarouselButtonProps) {
+ const { scrollNext, canScrollNext } = useCarousel()
+
+ if (!canScrollNext()) return null
+
+ return (
+
+ )
+}
diff --git a/components/Carousel/carousel.module.css b/components/Carousel/carousel.module.css
new file mode 100644
index 000000000..5aeabf62c
--- /dev/null
+++ b/components/Carousel/carousel.module.css
@@ -0,0 +1,82 @@
+.root {
+ position: relative;
+}
+
+.viewport {
+ overflow: hidden;
+}
+
+.container {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 100%;
+ gap: var(--Spacing-x3);
+}
+
+.item {
+ min-width: 0;
+}
+
+.button {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ background: var(--Base-Surface-Primary-light-Normal);
+ box-shadow: 0px 0px 8px 1px rgba(0, 0, 0, 0.1);
+ border: none;
+ border-radius: 50%;
+ padding: var(--Spacing-x1);
+ cursor: pointer;
+ z-index: 1;
+}
+
+.buttonPrev {
+ left: calc(-1 * var(--Spacing-x3));
+}
+
+.buttonNext {
+ right: calc(-1 * var(--Spacing-x3));
+}
+
+.prevIcon {
+ transform: rotate(180deg);
+}
+
+.dots {
+ display: flex;
+ justify-content: center;
+ gap: var(--Spacing-x1);
+ margin-top: var(--Spacing-x3);
+}
+
+.dot {
+ height: 8px;
+ width: 8px;
+ border-radius: var(--Corner-radius-Large);
+ background: var(--UI-Grey-40);
+ transition: width 0.3s ease;
+}
+
+.dot[data-active="true"] {
+ width: 22px;
+ background: var(--UI-Text-Medium-contrast);
+}
+
+@media (min-width: 768px) {
+ .container {
+ grid-auto-columns: calc((100% - var(--Spacing-x3)) / 2);
+ }
+
+ .button {
+ display: flex;
+ }
+}
+
+@media (min-width: 1024px) {
+ .container {
+ grid-auto-columns: calc((100% - var(--Spacing-x3) * 2) / 3);
+ }
+}
diff --git a/components/Carousel/index.tsx b/components/Carousel/index.tsx
new file mode 100644
index 000000000..5c9b38c61
--- /dev/null
+++ b/components/Carousel/index.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import { cx } from "class-variance-authority"
+import useEmblaCarousel from "embla-carousel-react"
+import { useCallback, useEffect, useState } from "react"
+
+import { CarouselContent } from "./CarouselContent"
+import { CarouselContext } from "./CarouselContext"
+import { CarouselDots } from "./CarouselDots"
+import { CarouselItem } from "./CarouselItem"
+import { CarouselNext, CarouselPrevious } from "./CarouselNavigation"
+
+import styles from "./carousel.module.css"
+
+import type { CarouselApi, CarouselProps } from "./types"
+
+function Carousel({
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+}: CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: "x",
+ },
+ plugins
+ )
+ const [selectedIndex, setSelectedIndex] = useState(0)
+
+ const onSelect = useCallback((api: CarouselApi) => {
+ if (!api) return
+ setSelectedIndex(api.selectedScrollSnap())
+ }, [])
+
+ function scrollPrev() {
+ api?.scrollPrev()
+ }
+
+ function scrollNext() {
+ api?.scrollNext()
+ }
+
+ function handleKeyDown(event: React.KeyboardEvent) {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ scrollPrev()
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault()
+ scrollNext()
+ }
+ }
+
+ useEffect(() => {
+ if (!api || !setApi) return
+ setApi(api)
+ }, [api, setApi])
+
+ useEffect(() => {
+ if (!api) return
+
+ onSelect(api)
+ api.on("reInit", onSelect)
+ api.on("select", onSelect)
+
+ return () => {
+ api.off("select", onSelect)
+ }
+ }, [api, onSelect])
+
+ return (
+ false),
+ canScrollNext: api?.canScrollNext.bind(api) ?? (() => false),
+ selectedIndex,
+ }}
+ >
+
+ {children}
+
+
+ )
+}
+
+Carousel.Content = CarouselContent
+Carousel.Item = CarouselItem
+Carousel.Next = CarouselNext
+Carousel.Previous = CarouselPrevious
+Carousel.Dots = CarouselDots
+
+export { Carousel }
+export type { CarouselApi }
diff --git a/components/Carousel/types.ts b/components/Carousel/types.ts
new file mode 100644
index 000000000..5323a9b3e
--- /dev/null
+++ b/components/Carousel/types.ts
@@ -0,0 +1,34 @@
+/**
+ * Must be imported from the core package.
+ * @see https://www.embla-carousel.com/api/methods/#typescript
+ */
+import type { EmblaCarouselType } from "embla-carousel"
+import type useEmblaCarousel from "embla-carousel-react"
+import type { PropsWithChildren } from "react"
+
+export type CarouselApi = EmblaCarouselType
+type UseCarouselParameters = Parameters
+type CarouselOptions = UseCarouselParameters[0]
+type CarouselPlugin = UseCarouselParameters[1]
+
+export interface CarouselProps extends PropsWithChildren {
+ opts?: CarouselOptions
+ plugins?: CarouselPlugin
+ setApi?: (api: CarouselApi) => void
+ className?: string
+}
+
+export interface CarouselContextProps extends Omit {
+ carouselRef: ReturnType[0]
+ api: ReturnType[1]
+ scrollPrev: () => void
+ scrollNext: () => void
+ canScrollPrev: () => boolean
+ canScrollNext: () => boolean
+ selectedIndex: number
+}
+
+export interface CarouselButtonProps
+ extends React.ButtonHTMLAttributes {
+ className?: string
+}
diff --git a/components/ContentCard/contentCard.module.css b/components/ContentCard/contentCard.module.css
index 0a82585af..db568787c 100644
--- a/components/ContentCard/contentCard.module.css
+++ b/components/ContentCard/contentCard.module.css
@@ -44,10 +44,6 @@
}
@media (min-width: 768px) {
- .card {
- max-width: 413px;
- }
-
.content {
padding: var(--Spacing-x2) var(--Spacing-x2) var(--Spacing-x2) 0;
}
diff --git a/components/ContentType/StartPage/startPage.module.css b/components/ContentType/StartPage/startPage.module.css
index d73244aa3..d42cd4fe0 100644
--- a/components/ContentType/StartPage/startPage.module.css
+++ b/components/ContentType/StartPage/startPage.module.css
@@ -49,12 +49,6 @@
background-color: var(--Base-Surface-Primary-light-Normal);
}
-@media screen and (min-width: 768px) {
- .main {
- gap: calc(var(--Spacing-x5) * 3);
- }
-}
-
.section:empty {
display: none;
}
@@ -65,3 +59,13 @@
max-width: var(--max-width-content);
width: 100%;
}
+
+@media screen and (min-width: 768px) {
+ .main {
+ gap: calc(var(--Spacing-x5) * 3);
+ }
+ .section {
+ padding-left: var(--Spacing-x5);
+ padding-right: var(--Spacing-x5);
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index b49db9d7b..2c32c45d2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -39,6 +39,8 @@
"dayjs": "^1.11.10",
"deepmerge": "^4.3.1",
"downshift": "^9.0.8",
+ "embla-carousel": "^8.5.2",
+ "embla-carousel-react": "^8.5.2",
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^6.0.0",
"framer-motion": "^11.3.28",
@@ -11849,6 +11851,34 @@
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.52.tgz",
"integrity": "sha512-xtoijJTZ+qeucLBDNztDOuQBE1ksqjvNjvqFoST3nGC7fSpqJ+X6BdTBaY5BHG+IhWWmpc6b/KfpeuEDupEPOQ=="
},
+ "node_modules/embla-carousel": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.2.tgz",
+ "integrity": "sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==",
+ "license": "MIT"
+ },
+ "node_modules/embla-carousel-react": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.5.2.tgz",
+ "integrity": "sha512-Tmx+uY3MqseIGdwp0ScyUuxpBgx5jX1f7od4Cm5mDwg/dptEiTKf9xp6tw0lZN2VA9JbnVMl/aikmbc53c6QFA==",
+ "license": "MIT",
+ "dependencies": {
+ "embla-carousel": "8.5.2",
+ "embla-carousel-reactive-utils": "8.5.2"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/embla-carousel-reactive-utils": {
+ "version": "8.5.2",
+ "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.5.2.tgz",
+ "integrity": "sha512-QC8/hYSK/pEmqEdU1IO5O+XNc/Ptmmq7uCB44vKplgLKhB/l0+yvYx0+Cv0sF6Ena8Srld5vUErZkT+yTahtDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "embla-carousel": "8.5.2"
+ }
+ },
"node_modules/emittery": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
diff --git a/package.json b/package.json
index d80694fad..d93018dc5 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,8 @@
"dayjs": "^1.11.10",
"deepmerge": "^4.3.1",
"downshift": "^9.0.8",
+ "embla-carousel": "^8.5.2",
+ "embla-carousel-react": "^8.5.2",
"fast-deep-equal": "^3.1.3",
"fetch-retry": "^6.0.0",
"framer-motion": "^11.3.28",