Retour au catalogue
Blog Carousel Cards
Carousel horizontal de cartes blog avec snap scrolling et navigation par fleches.
blogmedium Both Responsive a11y
minimalplayfuluniversalsaasecommercecarousel
Theme
"use client";
import { useRef, useState } from "react";
import { motion } from "framer-motion";
import { ChevronLeft, ChevronRight, Calendar } from "lucide-react";
interface Article {
title: string;
excerpt: string;
date: string;
category: string;
image: string;
}
interface BlogCarouselCardsProps {
sectionTitle?: string;
sectionSubtitle?: string;
articles?: Article[];
}
const EASE = [0.16, 1, 0.3, 1] as const;
export default function BlogCarouselCards({
sectionTitle = "Articles recents",
sectionSubtitle = "Nos dernieres publications",
articles = [],
}: BlogCarouselCardsProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const checkScroll = () => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 10);
setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 10);
};
const scroll = (dir: "left" | "right") => {
const el = scrollRef.current;
if (!el) return;
const amount = 360;
el.scrollBy({ left: dir === "left" ? -amount : amount, behavior: "smooth" });
setTimeout(checkScroll, 350);
};
return (
<section style={{ paddingTop: "var(--section-padding-y)", paddingBottom: "var(--section-padding-y)", background: "var(--color-background)" }}>
<div style={{ maxWidth: "var(--container-max-width)", margin: "0 auto", padding: "0 var(--container-padding-x)" }}>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, ease: EASE }}
style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", marginBottom: "2rem", flexWrap: "wrap", gap: "1rem" }}
>
<div>
<h2 style={{ fontFamily: "var(--font-sans)", fontSize: "clamp(1.5rem, 3vw, 2.25rem)", fontWeight: 700, color: "var(--color-foreground)", marginBottom: "0.4rem" }}>{sectionTitle}</h2>
<p style={{ fontSize: "1rem", color: "var(--color-foreground-muted)" }}>{sectionSubtitle}</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button onClick={() => scroll("left")} disabled={!canScrollLeft} aria-label="Precedent" style={{ width: 40, height: 40, borderRadius: "var(--radius-full)", border: "1px solid var(--color-border)", background: "var(--color-background-card)", cursor: canScrollLeft ? "pointer" : "default", display: "flex", alignItems: "center", justifyContent: "center", opacity: canScrollLeft ? 1 : 0.4, transition: "opacity 0.2s" }}>
<ChevronLeft style={{ width: 18, height: 18, color: "var(--color-foreground)" }} />
</button>
<button onClick={() => scroll("right")} disabled={!canScrollRight} aria-label="Suivant" style={{ width: 40, height: 40, borderRadius: "var(--radius-full)", border: "1px solid var(--color-border)", background: "var(--color-background-card)", cursor: canScrollRight ? "pointer" : "default", display: "flex", alignItems: "center", justifyContent: "center", opacity: canScrollRight ? 1 : 0.4, transition: "opacity 0.2s" }}>
<ChevronRight style={{ width: 18, height: 18, color: "var(--color-foreground)" }} />
</button>
</div>
</motion.div>
<div ref={scrollRef} onScroll={checkScroll} style={{ display: "flex", gap: "1.25rem", overflowX: "auto", scrollSnapType: "x mandatory", scrollbarWidth: "none", paddingBottom: "0.5rem" }}>
{articles.map((article, i) => (
<motion.a
key={i}
href="#"
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: i * 0.05, ease: EASE }}
style={{ minWidth: 300, maxWidth: 340, flexShrink: 0, scrollSnapAlign: "start", textDecoration: "none", borderRadius: "var(--radius-lg)", overflow: "hidden", border: "1px solid var(--color-border)", background: "var(--color-background-card)" }}
>
<div style={{ aspectRatio: "16/10", background: "var(--color-background-alt)", overflow: "hidden" }}>
<img src={article.image} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</div>
<div style={{ padding: "1.25rem" }}>
<span style={{ fontSize: "0.6875rem", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", color: "var(--color-accent)" }}>{article.category}</span>
<h3 style={{ fontFamily: "var(--font-sans)", fontSize: "1rem", fontWeight: 600, color: "var(--color-foreground)", lineHeight: 1.35, margin: "0.4rem 0 0.5rem" }}>{article.title}</h3>
<p style={{ fontSize: "0.8125rem", color: "var(--color-foreground-muted)", lineHeight: 1.6, marginBottom: "0.75rem" }}>{article.excerpt}</p>
<div style={{ display: "flex", alignItems: "center", gap: "0.35rem", fontSize: "0.75rem", color: "var(--color-foreground-muted)" }}>
<Calendar style={{ width: 12, height: 12 }} /> {article.date}
</div>
</div>
</motion.a>
))}
</div>
</div>
</section>
);
}