Todos os componentes

Expandable Cards

Expandable Cards é um componente de cartão para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT cards

O que é

Expandable Cards é um componente de cartão para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i motion lucide-react

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

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

Componentes parecidos