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