All components

Expandable Cards

Expandable Cards is a React and Tailwind CSS card component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT cards

What it is

Expandable Cards is a React and Tailwind CSS card component from SmoothUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i motion lucide-react

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Expandable Cards

index.tsx
"use client";

import { Play } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";

const _AVATAR_SIZE = 96;
// ease-out-quint for entering/exiting elements
const EASE_OUT_QUINT = [0.23, 1, 0.32, 1] as const;

export interface Card {
  author?: {
    name: string;
    role: string;
    image: string;
  };
  content: string;
  id: number;
  image: string;
  title: string;
}

export interface ExpandableCardsProps {
  cardClassName?: string;
  cards: Card[];
  className?: string;
  onSelect?: (id: number | null) => void;
  selectedCard?: number | null;
}

export default function ExpandableCards({
  cards,
  selectedCard: controlledSelected,
  onSelect,
  className = "",
  cardClassName = "",
}: ExpandableCardsProps) {
  const [internalSelected, setInternalSelected] = useState<number | null>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const shouldReduceMotion = useReducedMotion();

  const selectedCard =
    controlledSelected === undefined ? internalSelected : controlledSelected;

  useEffect(() => {
    if (scrollRef.current) {
      const { scrollWidth } = scrollRef.current;
      const { clientWidth } = scrollRef.current;
      scrollRef.current.scrollLeft = (scrollWidth - clientWidth) / 2;
    }
  }, []);

  const handleCardClick = (id: number) => {
    if (selectedCard === id) {
      if (onSelect) {
        onSelect(null);
      } else {
        setInternalSelected(null);
      }
    } else {
      if (onSelect) {
        onSelect(id);
      } else {
        setInternalSelected(id);
      }
      // Center the clicked card in view
      const cardElement = document.querySelector(`[data-card-id="${id}"]`);
      if (cardElement) {
        cardElement.scrollIntoView({
          behavior: "smooth",
          block: "nearest",
          inline: "center",
        });
      }
    }
  };

  return (
    <div
      className={`flex w-full flex-col gap-4 overflow-scroll p-4 ${className}`}
    >
      <div
        className="scrollbar-hide mx-auto flex overflow-x-auto pt-4 pb-8"
        ref={scrollRef}
        style={{
          scrollPaddingLeft: "20%",
          scrollSnapType: "x mandatory",
        }}
      >
        {cards.map((card) => (
          <motion.div
            animate={{
              width: selectedCard === card.id ? "500px" : "200px",
            }}
            aria-label={`${card.title} card${selectedCard === card.id ? ", expanded" : ""}`}
            aria-selected={selectedCard === card.id}
            className={`relative mr-4 h-[300px] shrink-0 cursor-pointer overflow-hidden rounded-2xl border bg-background shadow-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${cardClassName}`}
            data-card-id={card.id}
            key={card.id}
            layout
            onClick={() => handleCardClick(card.id)}
            onKeyDown={(e) => {
              if (e.key === "Enter" || e.key === " ") {
                e.preventDefault();
                handleCardClick(card.id);
              }
            }}
            role="button"
            style={{
              scrollSnapAlign: "start",
            }}
            tabIndex={0}
            transition={
              shouldReduceMotion
                ? { duration: 0 }
                : {
                    duration: 0.25,
                    ease: EASE_OUT_QUINT,
                  }
            }
          >
            <div className="relative h-full w-[200px]">
              <img
                alt={card.title}
                className="h-full w-full object-cover"
                draggable={false}
                height={300}
                src={card.image || "/placeholder.svg"}
                width={200}
              />
              <div className="absolute inset-0 bg-black/20" />
              <div className="absolute inset-0 flex flex-col justify-between p-6 text-white">
                <h2 className="font-bold text-2xl">{card.title}</h2>
                <div className="flex items-center gap-2">
                  <button
                    aria-label={`Play video: ${card.title}`}
                    className="ease flex h-12 min-h-[44px] w-12 min-w-[44px] items-center justify-center rounded-full bg-background/30 backdrop-blur-sm transition-transform duration-200 hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
                    onClick={(e) => {
                      e.stopPropagation();
                      // Handle play action
                    }}
                    type="button"
                  >
                    <Play className="h-6 w-6 text-white" />
                  </button>
                  <span className="font-medium text-sm">Play video</span>
                </div>
              </div>
            </div>
            <AnimatePresence mode="popLayout">
              {selectedCard === card.id && (
                <motion.div
                  animate={
                    shouldReduceMotion
                      ? { opacity: 1, width: "300px" }
                      : { filter: "blur(0px)", opacity: 1, width: "300px" }
                  }
                  className="absolute top-0 right-0 h-full bg-background"
                  exit={
                    shouldReduceMotion
                      ? { opacity: 0, width: 0 }
                      : { filter: "blur(5px)", opacity: 0, width: 0 }
                  }
                  initial={
                    shouldReduceMotion
                      ? { opacity: 0, width: 0 }
                      : { filter: "blur(5px)", opacity: 0, width: 0 }
                  }
                  transition={
                    shouldReduceMotion
                      ? { duration: 0 }
                      : {
                          duration: 0.25,
                          ease: EASE_OUT_QUINT,
                          opacity: { delay: 0.1, duration: 0.2 },
                        }
                  }
                >
                  <motion.div
                    animate={
                      shouldReduceMotion
                        ? { opacity: 1, x: 0 }
                        : { filter: "blur(0px)", opacity: 1, x: 0 }
                    }
                    className="flex h-full flex-col justify-between p-8"
                    exit={
                      shouldReduceMotion
                        ? { opacity: 0, x: 20 }
                        : { filter: "blur(5px)", opacity: 0, x: 20 }
                    }
                    initial={
                      shouldReduceMotion
                        ? { opacity: 0, x: 20 }
                        : { filter: "blur(5px)", opacity: 0, x: 20 }
                    }
                    transition={
                      shouldReduceMotion
                        ? { duration: 0 }
                        : { delay: 0.2, duration: 0.2, ease: EASE_OUT_QUINT }
                    }
                  >
                    <p className="text-primary-foreground text-sm">
                      {card.content}
                    </p>
                    {card.author ? (
                      <div className="mt-4 flex items-center gap-3">
                        <div className="h-12 w-12 overflow-hidden rounded-full border bg-primary">
                          <img
                            alt={card.author.name}
                            className="h-full w-full object-cover"
                            draggable={false}
                            height={48}
                            src={card.author.image}
                            width={48}
                          />
                        </div>
                        <div>
                          <p className="font-semibold text-foreground">
                            {card.author.name}
                          </p>
                          <p className="text-primary-foreground text-xs">
                            {card.author.role}
                          </p>
                        </div>
                      </div>
                    ) : null}
                  </motion.div>
                </motion.div>
              )}
            </AnimatePresence>
          </motion.div>
        ))}
      </div>
    </div>
  );
}

Similar components