Animated Card
NEWA content card with a pointer-following spotlight, a spring lift on hover and a nudging arrow.
Live preview
Loading preview…
Installation
Install the dependencies, then copy the source below into your project.
npm install clsx framer-motion lucide-react tailwind-mergeimport AnimatedCard from "@/components/ui/animated-card"; Source
One file. Paths are relative to your source root (the @/ alias).
import type { PointerEvent, ReactNode } from "react";
import { motion, useMotionTemplate, useMotionValue, useReducedMotion } from "framer-motion";
import { ArrowUpRight, Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
export interface AnimatedCardProps {
title?: string;
description?: string;
/** Small label shown in the top-right corner. */
eyebrow?: string;
/** When set, the action is rendered as a link. */
href?: string;
actionLabel?: string;
className?: string;
children?: ReactNode;
}
const actionClassName =
"relative mt-6 inline-flex items-center gap-1.5 rounded-md text-sm font-medium text-zinc-100 outline-none focus-visible:ring-2 focus-visible:ring-white/60";
/**
* A content card with a pointer-following spotlight, a spring lift on hover
* and a nudging arrow. Honors `prefers-reduced-motion`.
*/
export function AnimatedCard({
title = "Animated card",
description = "A card with a spotlight that follows the pointer.",
eyebrow,
href,
actionLabel = "Learn more",
className,
children,
}: AnimatedCardProps) {
const reduceMotion = useReducedMotion();
const pointerX = useMotionValue(0);
const pointerY = useMotionValue(0);
const spotlight = useMotionTemplate`radial-gradient(260px circle at ${pointerX}px ${pointerY}px, rgba(255, 255, 255, 0.1), transparent 70%)`;
function handlePointerMove(event: PointerEvent<HTMLElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
pointerX.set(event.clientX - bounds.left);
pointerY.set(event.clientY - bounds.top);
}
const action = (
<>
{actionLabel}
<ArrowUpRight
className="size-4 transition-transform duration-200 group-hover:-translate-y-0.5 group-hover:translate-x-0.5"
aria-hidden="true"
/>
</>
);
return (
<motion.article
onPointerMove={handlePointerMove}
initial={reduceMotion ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
whileHover={reduceMotion ? undefined : { y: -4 }}
transition={{ type: "spring", stiffness: 300, damping: 24 }}
className={cn(
"group relative w-full max-w-sm overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 p-5 text-left shadow-lg shadow-black/30 sm:p-6",
className,
)}
>
<motion.div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
style={{ background: spotlight }}
/>
<div className="relative flex items-start justify-between gap-4">
<span className="flex size-10 items-center justify-center rounded-xl border border-white/10 bg-white/5 text-zinc-100 transition-colors duration-300 group-hover:border-white/20">
<Sparkles className="size-5" aria-hidden="true" />
</span>
{eyebrow ? (
<span className="rounded-full border border-white/10 px-2.5 py-1 text-[11px] font-medium uppercase tracking-wider text-zinc-400">
{eyebrow}
</span>
) : null}
</div>
<h3 className="relative mt-5 text-lg font-semibold tracking-tight text-zinc-50 sm:text-xl">{title}</h3>
<p className="relative mt-2 text-sm leading-6 text-zinc-400">{description}</p>
{children ? <div className="relative mt-4">{children}</div> : null}
{href ? (
<a href={href} className={actionClassName}>
{action}
</a>
) : (
<span className={actionClassName}>{action}</span>
)}
</motion.article>
);
}
export default AnimatedCard;