Skip to content
ui/registry
Browse families

Animated Card

NEW

A 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-merge
import AnimatedCard from "@/components/ui/animated-card";

Source

One file. Paths are relative to your source root (the @/ alias).

components/ui/animated-card.tsx tsx
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;