team-2

PreviousNext

Carousel team section block with auto-play

Docs
smoothuiui

Preview

Loading preview…
index.tsx
"use client";

import { getAllPeople, getAvatarUrl, type Person } from "@smoothui/data";
import { motion } from "motion/react";
import { useEffect, useState } from "react";

const CARDS_PER_VIEW = 3; // Number of cards visible at once
const CARD_WIDTH = 288;
const CARD_GAP = 16;
const AUTOPLAY_INTERVAL = 5000;
const TRANSITION_TIMEOUT = 1500;
const AVATAR_SIZE = 160;
const STAGGER_DELAY = 0.1;

type TeamCarouselProps = {
  title?: string;
  subtitle?: string;
  description?: string;
  members?: Person[];
};

export function TeamCarousel({
  title = "Tech Pioneers",
  subtitle = "building the future",
  description = "We bring together brilliant developers, engineers, and tech innovators to create groundbreaking digital solutions.",
  members = getAllPeople(),
}: TeamCarouselProps) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [isAutoPlaying, setIsAutoPlaying] = useState(true);
  const [isTransitioning, setIsTransitioning] = useState(false);

  useEffect(() => {
    if (!isAutoPlaying || isTransitioning) {
      return;
    }

    const interval = setInterval(() => {
      setCurrentIndex(
        (prev) => (prev + 1) % (members.length - CARDS_PER_VIEW + 1)
      );
    }, AUTOPLAY_INTERVAL);

    return () => clearInterval(interval);
  }, [members.length, isAutoPlaying, isTransitioning]);

  const nextSlide = () => {
    if (isTransitioning) {
      return;
    }
    const maxIndex = members.length - CARDS_PER_VIEW;
    if (currentIndex >= maxIndex) {
      return;
    }

    setIsTransitioning(true);
    setCurrentIndex((prev) => Math.min(prev + 1, maxIndex));
    setIsAutoPlaying(false);

    setTimeout(() => {
      setIsTransitioning(false);
      setIsAutoPlaying(true);
    }, TRANSITION_TIMEOUT);
  };

  const prevSlide = () => {
    if (isTransitioning) {
      return;
    }
    if (currentIndex <= 0) {
      return;
    }

    setIsTransitioning(true);
    setCurrentIndex((prev) => Math.max(prev - 1, 0));
    setIsAutoPlaying(false);

    setTimeout(() => {
      setIsTransitioning(false);
      setIsAutoPlaying(true);
    }, TRANSITION_TIMEOUT);
  };

  return (
    <section className="overflow-hidden py-32">
      <div className="mx-auto max-w-5xl px-8 lg:px-0">
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          transition={{ duration: 0.6 }}
          viewport={{ once: true }}
          whileInView={{ opacity: 1, y: 0 }}
        >
          <h2 className="font-medium text-5xl md:text-6xl">
            {title} <br />
            <span className="text-foreground/50">{subtitle}</span>
          </h2>
          <p className="mt-6 max-w-md text-foreground/70">{description}</p>
        </motion.div>

        <div className="relative">
          {/* Navigation Buttons */}
          <div className="mt-4 hidden items-center justify-end gap-4 md:flex">
            <motion.button
              className="-left-12 static top-1/2 inline-flex size-11 shrink-0 translate-x-0 translate-y-0 items-center justify-center gap-2 whitespace-nowrap rounded-full border bg-background font-medium text-sm shadow-xs outline-none transition-all hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
              disabled={currentIndex === 0 || isTransitioning}
              onClick={prevSlide}
              type="button"
              whileHover={{ scale: 1.05 }}
              whileTap={{ scale: 0.95 }}
            >
              <svg
                aria-hidden="true"
                className="lucide lucide-arrow-left"
                fill="none"
                height="24"
                stroke="currentColor"
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth="2"
                viewBox="0 0 24 24"
                width="24"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path d="m12 19-7-7 7-7" />
                <path d="M19 12H5" />
              </svg>
              <span className="sr-only">Previous slide</span>
            </motion.button>
            <motion.button
              className="-right-12 static top-1/2 inline-flex size-11 shrink-0 translate-x-0 translate-y-0 items-center justify-center gap-2 whitespace-nowrap rounded-full border bg-background font-medium text-sm shadow-xs outline-none transition-all hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:border-input dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
              disabled={
                currentIndex >= members.length - CARDS_PER_VIEW ||
                isTransitioning
              }
              onClick={nextSlide}
              type="button"
              whileHover={{ scale: 1.05 }}
              whileTap={{ scale: 0.95 }}
            >
              <svg
                aria-hidden="true"
                className="lucide lucide-arrow-right"
                fill="none"
                height="24"
                stroke="currentColor"
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth="2"
                viewBox="0 0 24 24"
                width="24"
                xmlns="http://www.w3.org/2000/svg"
              >
                <path d="M5 12h14" />
                <path d="m12 5 7 7-7 7" />
              </svg>
              <span className="sr-only">Next slide</span>
            </motion.button>
          </div>

          {/* Carousel Content */}
          <div className="mt-16 [&>div[data-slot=carousel-content]]:overflow-visible">
            <div className="overflow-hidden" data-slot="carousel-content">
              <motion.div
                animate={{
                  x: `-${currentIndex * (CARD_WIDTH + CARD_GAP)}px`,
                }}
                className="-ml-4 flex max-w-[min(calc(100vw-4rem),24rem)] select-none"
                transition={{
                  type: "spring",
                  stiffness: 300,
                  damping: 30,
                }}
              >
                {members.map((member, index) => (
                  <div
                    className="min-w-0 max-w-72 shrink-0 grow-0 basis-full pl-4"
                    data-slot="carousel-item"
                    key={member.name}
                  >
                    <motion.div
                      className="rounded-2xl border border-border bg-background p-7 text-center"
                      initial={{ opacity: 0, y: 20 }}
                      transition={{ duration: 0.5, delay: index * STAGGER_DELAY }}
                      viewport={{ once: true }}
                      whileInView={{ opacity: 1, y: 0 }}
                    >
                      {/* biome-ignore lint/performance/noImgElement: Using img for team avatars without Next.js Image optimizations */}
                      <img
                        alt={member.name}
                        className="mx-auto size-20 rounded-full border border-border"
                        height={80}
                        src={getAvatarUrl(member.avatar, AVATAR_SIZE)}
                        width={80}
                      />
                      <div className="mt-6 flex flex-col justify-center">
                        <p className="font-medium text-foreground text-lg">
                          {member.name}
                        </p>
                        <p className="text-muted-foreground text-sm">
                          {member.role}
                        </p>
                      </div>
                      <div
                        className="my-6 shrink-0 bg-border bg-linear-to-r from-background via-border to-background data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px"
                        data-orientation="horizontal"
                        data-slot="separator-root"
                        role="none"
                      />
                      <p className="text-muted-foreground text-sm">
                        {member.experience}
                      </p>
                    </motion.div>
                  </div>
                ))}
              </motion.div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

export default TeamCarousel;

Installation

npx shadcn@latest add @smoothui/team-2

Usage

import { Team2 } from "@/components/ui/team-2"
<Team2 />