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