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>
  );
}

Avis

Blog Carousel Cards — React Blog Section — Incubator