shadimage
bjork-ui
componentanimated-card-optionssource: ok · hit

Animated card options

Animated card options is a production-ready cards component from BJORK UI, packaged for shadcn registries.

npx shadcn add "https://ui.isaiahbjork.com/animated-card-options.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationframer-motion

Files3 · source cached 10/9/2026, 4:38:28 PM

components/bjork-ui/cards/animated-card-options.tsx
"use client";

import { motion, AnimatePresence } from "framer-motion";
import { ReactNode, useState } from "react";
import { BjorkCard } from "@/components/bjork-ui/primitives/card";
import { cn } from "@/lib/utils";

export interface CardOption {
  id: string;
  icon: ReactNode;
  name: string;
}

interface AnimatedCardOptionsProps {
  options: CardOption[];
  columns?: number;
  onSelect?: (option: CardOption) => void;
}

export function AnimatedCardOptions({ 
  options, 
  columns = 4, 
  onSelect 
}: AnimatedCardOptionsProps) {
  const [selectedCard, setSelectedCard] = useState<string | null>(null);
  const [fadingCards, setFadingCards] = useState<Set<string>>(new Set());

  const cardVariants = {
    initial: {
      opacity: 0,
      scale: 0.8,
      y: 20,
    },
    animate: (index: number) => ({
      opacity: 1,
      scale: [0.8, 1.01, 1], // Overshoot then settle
      y: 0,
      transition: {
        duration: 0.5,
        delay: index * 0.05, // Staggered delay of 50ms per card
        type: "spring",
        stiffness: 500,
        damping: 25,
        scale: {
          type: "tween", // Use tween for keyframes support
          duration: 0.5,
          ease: [0.175, 0.885, 0.32, 1.275], // Custom easing for overshoot
        },
      },
    }),
    exit: {
      opacity: 0,
      scale: 0.8,
      transition: {
        duration: 0.2,
      },
    },
  };

  const hoverVariants = {
    hover: {
      scale: 1.05,
      transition: {
        type: "spring",
        stiffness: 400,
        damping: 10,
      },
    },
  };

  const handleCardClick = (option: CardOption) => {
    if (selectedCard) return;
    
    setSelectedCard(option.id);
    
    // Create random delays for other cards to fade out
    const otherCards = options.filter(opt => opt.id !== option.id);
    
    otherCards.forEach((card) => {
      setTimeout(() => {
        setFadingCards(prev => new Set([...prev, card.id]));
      }, Math.random() * 300); // Random delay up to 300ms
    });
    
    onSelect?.(option);
  };

  const shouldShowCard = (cardId: string) => {
    if (!selectedCard) return true;
    if (selectedCard === cardId) return true;
    return !fadingCards.has(cardId);
  };

  return (
    <div
      className="relative mx-auto grid max-w-4xl gap-3"
      style={{
        gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
      }}
    >
      {options.map((option, index) => (
        <div key={option.id} className="relative">
          {/* Always maintain grid cell structure */}
          <AnimatePresence mode="wait">
            {shouldShowCard(option.id) ? (
              <motion.div
                key={`card-${option.id}`}
                className={`relative group cursor-pointer ${selectedCard === option.id ? 'z-10' : ''}`}
                variants={cardVariants}
                initial={false}
                animate="animate"
                exit="exit"
                custom={index}
                whileHover={selectedCard ? {} : "hover"}
                onClick={() => handleCardClick(option)}
              >
                <motion.div variants={hoverVariants}>
                  <BjorkCard
                    variant="interactive"
                    padding="none"
                    className={cn(
                      "h-24 w-full rounded-[18px]",
                      "border-[#d8d3c7] bg-[#f4f1e9] text-[#171717] shadow-[inset_0_7px_14px_rgba(255,255,255,0.62),inset_0_0.5px_0.5px_rgba(255,255,255,0.9),0_14px_24px_-18px_rgba(55,47,36,0.28)] hover:border-[#cfc6b8] hover:bg-[#eee9df]",
                      "dark:border-[#161616] dark:bg-[#121212] dark:text-[#ededed] dark:shadow-[inset_0_7px_14px_rgba(255,255,255,0.03),inset_0_0.5px_0.5px_rgba(255,255,255,0.06),0_14px_24px_-18px_rgba(0,0,0,0.85)] dark:hover:border-[#232323] dark:hover:bg-[#161616]",
                      selectedCard === option.id && "border-[#ec5c13]/40 bg-[#ec5c13]/10 dark:border-[#ec5c13]/34 dark:bg-[#ec5c13]/10"
                    )}
                  >
                    <div className="flex h-full items-center space-x-3 px-4">
                      <div className="flex size-10 shrink-0 items-center justify-center rounded-[13px] border border-[#d8d3c7] bg-[#eee9df] text-[#bd4514] shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] transition-opacity duration-200 group-hover:text-[#ec5c13] dark:border-[#232323] dark:bg-[#090909] dark:text-[#d86a2c] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] [&_svg]:size-5">
                        {option.icon}
                      </div>
                      <div className="min-w-0 flex-1">
                        <h3 className="text-pretty text-xs font-medium leading-snug text-[#171717]/78 transition-colors duration-200 group-hover:text-[#171717] dark:text-[#ededed]/72 dark:group-hover:text-[#ededed]">
                          {option.name}
                        </h3>
                      </div>
                    </div>
                  </BjorkCard>
                </motion.div>
              </motion.div>
            ) : (
              <div key={`placeholder-${option.id}`} className="h-24 w-full" />
            )}
          </AnimatePresence>
        </div>
      ))}
    </div>
  );
}
components/bjork-ui/primitives/card.tsx
"use client";

import * as React from "react";
import { motion, useReducedMotion, type HTMLMotionProps } from "framer-motion";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const bjorkCardVariants = cva(
  "relative flex flex-col overflow-hidden rounded-[20px] border text-[color:var(--bjork-text)] outline-none transition-[background-color,border-color,box-shadow,opacity,transform] duration-300 focus-visible:ring-2 focus-visible:ring-[#ec5c13]/45 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bjork-ring-offset)]",
  {
    variants: {
      variant: {
        surface:
          "border-[color:var(--bjork-border-muted)] bg-[var(--bjork-surface)] shadow-[var(--bjork-shadow-surface)]",
        elevated:
          "border-[color:var(--bjork-border-muted)] bg-[var(--bjork-surface)] shadow-[var(--bjork-shadow-surface)]",
        quiet:
          "border-[color:var(--bjork-border-muted)] bg-[var(--bjork-panel)] shadow-[var(--bjork-shadow-soft)]",
        interactive:
          "cursor-pointer border-[color:var(--bjork-border-muted)] bg-[var(--bjork-surface)] shadow-[var(--bjork-shadow-surface)] hover:border-[color:var(--bjork-border)] hover:bg-[var(--bjork-surface-hover)]",
      },
      padding: {
        none: "p-0",
        sm: "p-3",
        md: "p-4",
        lg: "p-5",
      },
    },
    defaultVariants: {
      variant: "surface",
      padding: "md",
    },
  },
);

export interface BjorkCardProps
  extends Omit<HTMLMotionProps<"div">, "ref">,
    VariantProps<typeof bjorkCardVariants> {}

export function BjorkCard({
  className,
  variant,
  padding,
  ...props
}: BjorkCardProps) {
  const shouldReduceMotion = useReducedMotion();
  const isInteractive = variant === "interactive" || props.role === "button";

  return (
    <motion.div
      data-slot="bjork-card"
      className={cn(bjorkCardVariants({ variant, padding, className }))}
      whileHover={isInteractive && !shouldReduceMotion ? { y: -2, scale: 1.005 } : undefined}
      whileTap={isInteractive && !shouldReduceMotion ? { scale: 0.995 } : undefined}
      transition={{ type: "spring", stiffness: 260, damping: 28, mass: 0.8 }}
      {...props}
    />
  );
}

export function BjorkCardHeader({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="bjork-card-header"
      className={cn("flex items-start justify-between gap-4", className)}
      {...props}
    />
  );
}

export function BjorkCardTitle({
  className,
  ...props
}: React.ComponentProps<"h3">) {
  return (
    <h3
      data-slot="bjork-card-title"
      className={cn("text-base font-semibold leading-none tracking-[-0.02em] text-[color:var(--bjork-text)]", className)}
      {...props}
    />
  );
}

export function BjorkCardDescription({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="bjork-card-description"
      className={cn("text-sm leading-6 text-[color:var(--bjork-text-soft)]", className)}
      {...props}
    />
  );
}

export function BjorkCardContent({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="bjork-card-content"
      className={cn("mt-5 text-[color:var(--bjork-text-medium)]", className)}
      {...props}
    />
  );
}

export function BjorkCardFooter({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="bjork-card-footer"
      className={cn("mt-5 flex items-center gap-3", className)}
      {...props}
    />
  );
}

export { bjorkCardVariants };
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

More from bjork-ui

Browse all components in this library →