Retour au catalogue

Content Tabs Animated

Tabs avec transition directionnelle premium : le contenu slide selon la direction de navigation. Indicateur pill avec layoutId.

content-tabscomplex Both Responsive a11y
minimalcorporateuniversalsaasagencystacked
Theme
"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import * as LucideIcons from "lucide-react";
import React from "react";

interface TabItem {
  id: string;
  label: string;
  icon?: string;
  title: string;
  description: string;
  features?: string[];
}

interface ContentTabsAnimatedProps {
  badge?: string;
  title?: string;
  subtitle?: string;
  tabs: TabItem[];
}

const EASE = [0.16, 1, 0.3, 1] as const;

function getIcon(name?: string) {
  if (!name) return null;
  return (LucideIcons as unknown as Record<string, React.ElementType>)[name] || null;
}

export default function ContentTabsAnimated({
  badge = "Fonctionnalites",
  title = "Tout ce dont vous avez besoin",
  subtitle = "Naviguez entre nos modules.",
  tabs = [],
}: ContentTabsAnimatedProps) {
  const [activeIndex, setActiveIndex] = useState(0);
  const [direction, setDirection] = useState(1);

  const current = tabs[activeIndex];

  const handleTabChange = (newIndex: number) => {
    setDirection(newIndex > activeIndex ? 1 : -1);
    setActiveIndex(newIndex);
  };

  const slideVariants = {
    enter: (dir: number) => ({ x: dir * 120, opacity: 0 }),
    center: { x: 0, opacity: 1 },
    exit: (dir: number) => ({ x: dir * -120, opacity: 0 }),
  };

  return (
    <section
      style={{
        padding: "var(--section-padding-y, 6rem) 0",
        background: "var(--color-background)",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width, 1200px)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x, 1.5rem)",
        }}
      >
        {/* Header */}
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, margin: "-80px" }}
          transition={{ duration: 0.5, ease: EASE }}
          style={{ textAlign: "center", maxWidth: "600px", margin: "0 auto 3rem" }}
        >
          {badge && (
            <span
              style={{
                display: "inline-block",
                fontSize: "0.75rem",
                fontWeight: 600,
                textTransform: "uppercase",
                letterSpacing: "0.08em",
                color: "var(--color-accent)",
                marginBottom: "0.75rem",
              }}
            >
              {badge}
            </span>
          )}
          <h2
            style={{
              fontFamily: "var(--font-sans)",
              fontSize: "clamp(1.75rem, 3vw, 2.75rem)",
              fontWeight: 700,
              lineHeight: 1.15,
              letterSpacing: "-0.02em",
              color: "var(--color-foreground)",
              marginBottom: "1rem",
            }}
          >
            {title}
          </h2>
          <p style={{ fontSize: "1rem", lineHeight: 1.7, color: "var(--color-foreground-muted)" }}>
            {subtitle}
          </p>
        </motion.div>

        {/* Tab bar with sliding indicator */}
        <div
          style={{
            display: "flex",
            gap: "0.25rem",
            padding: "0.25rem",
            borderRadius: "var(--radius-lg)",
            background: "var(--color-background-alt)",
            marginBottom: "2.5rem",
            maxWidth: "fit-content",
            margin: "0 auto 2.5rem",
          }}
        >
          {tabs.map((tab, i) => {
            const Icon = getIcon(tab.icon);
            const isActive = i === activeIndex;
            return (
              <button
                key={tab.id}
                onClick={() => handleTabChange(i)}
                style={{
                  position: "relative",
                  display: "flex",
                  alignItems: "center",
                  gap: "6px",
                  padding: "0.625rem 1.25rem",
                  fontSize: "0.875rem",
                  fontWeight: isActive ? 600 : 400,
                  color: isActive ? "var(--color-foreground)" : "var(--color-foreground-muted)",
                  background: "none",
                  border: "none",
                  cursor: "pointer",
                  whiteSpace: "nowrap",
                  zIndex: 1,
                  borderRadius: "var(--radius-md)",
                }}
              >
                {isActive && (
                  <motion.div
                    layoutId="tab-pill-animated"
                    style={{
                      position: "absolute",
                      inset: 0,
                      borderRadius: "var(--radius-md)",
                      background: "var(--color-background-card)",
                      border: "1px solid var(--color-border)",
                      boxShadow: "0 2px 8px rgba(0,0,0,0.04)",
                    }}
                    transition={{ type: "spring", stiffness: 400, damping: 30 }}
                  />
                )}
                <span style={{ position: "relative", zIndex: 1, display: "flex", alignItems: "center", gap: "6px" }}>
                  {Icon && <Icon style={{ width: 15, height: 15 }} />}
                  {tab.label}
                </span>
              </button>
            );
          })}
        </div>

        {/* Content with directional slide */}
        <div style={{ overflow: "hidden", minHeight: "280px" }}>
          <AnimatePresence mode="wait" custom={direction}>
            {current && (
              <motion.div
                key={current.id}
                custom={direction}
                variants={slideVariants}
                initial="enter"
                animate="center"
                exit="exit"
                transition={{ duration: 0.35, ease: EASE }}
                style={{
                  display: "grid",
                  gap: "2.5rem",
                  alignItems: "center",
                }}
                className="md:grid-cols-2"
              >
                <div>
                  <h3
                    style={{
                      fontFamily: "var(--font-sans)",
                      fontSize: "1.5rem",
                      fontWeight: 700,
                      color: "var(--color-foreground)",
                      marginBottom: "1rem",
                    }}
                  >
                    {current.title}
                  </h3>
                  <p
                    style={{
                      fontSize: "1rem",
                      lineHeight: 1.7,
                      color: "var(--color-foreground-muted)",
                      marginBottom: current.features?.length ? "1.5rem" : "0",
                    }}
                  >
                    {current.description}
                  </p>
                  {current.features && current.features.length > 0 && (
                    <ul style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: "0.5rem" }}>
                      {current.features.map((f, i) => (
                        <li
                          key={i}
                          style={{
                            display: "flex",
                            alignItems: "center",
                            gap: "0.5rem",
                            fontSize: "0.9375rem",
                            color: "var(--color-foreground-muted)",
                          }}
                        >
                          <span
                            style={{
                              width: 6,
                              height: 6,
                              borderRadius: "var(--radius-full)",
                              background: "var(--color-accent)",
                              flexShrink: 0,
                            }}
                          />
                          {f}
                        </li>
                      ))}
                    </ul>
                  )}
                </div>

                <div
                  style={{
                    aspectRatio: "4/3",
                    borderRadius: "var(--radius-xl)",
                    background: "var(--color-background-alt)",
                    border: "1px solid var(--color-border)",
                  }}
                />
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
    </section>
  );
}

Avis

Content Tabs Animated — React Content-tabs Section — Incubator