shadimage
bjork-ui
componentanimated-card-status-listsource: ok · hit

Animated card status list

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

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

Where it goes

stats

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationframer-motion
iconslucide-react

Files3 · source cached 10/9/2026, 1:26:28 PM

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

import { useState, useEffect } from "react";
import { motion, AnimatePresence, useReducedMotion } from "framer-motion";
import { ChevronLeft, Plus } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { BjorkCard } from "@/components/bjork-ui/primitives/card";

export interface Card {
  id: string;
  title: string;
  status: "completed" | "updates-found" | "syncing";
}

interface AnimatedCardStatusListProps {
  title?: string;
  cards?: Card[];
  onSynchronize?: (cardId: string) => void;
  onAddCard?: () => void;
  onBack?: () => void;
  className?: string;
}

const defaultCards: Card[] = [
  { id: "1", title: "Import products from your store", status: "completed" },
  { id: "2", title: "Unique selling points", status: "completed" },
  { id: "3", title: "Primary customers", status: "completed" },
  { id: "4", title: "Common words & phrases", status: "updates-found" },
  { id: "5", title: "Company overview and offer details", status: "syncing" },
];

const statusListPalettes = {
  dark: {
    card: "border-[#161616] bg-[#121212] text-[#ededed]",
    title: "text-[#ededed]",
    iconButton:
      "border-[#232323] bg-[#090909] text-[#ededed]/70 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:bg-[#141414] hover:text-[#ededed]",
    row: "border-[#161616] bg-[#121212] text-[#ededed] 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_20px_-6px_rgba(0,0,0,0.45)] hover:border-[#232323] hover:bg-[#161616]",
    rowTitle: "text-[#ededed]",
    statusText: "text-[#ededed]/42",
    syncButton:
      "border-[#2a2a2a] bg-[#090909] text-[#ededed] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] hover:bg-[#151515]",
    updatesGradient: "from-[#ec5c13]/14 to-transparent",
    syncingGradient: "from-[#22c55e]/12 to-transparent",
    spinnerActive: "#ffffff",
    spinnerInactive: "#6b7280",
  },
  light: {
    card:
      "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_-10px_rgba(55,47,36,0.2)]",
    title: "text-[#171717]",
    iconButton:
      "border-[#d8d3c7] bg-[#eee9df] text-[#171717]/68 shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] hover:bg-[#e7e1d5] hover:text-[#171717]",
    row: "border-[#d8d3c7] bg-[#f4f1e9] text-[#171717] shadow-[inset_0_7px_14px_rgba(255,255,255,0.58),inset_0_0.5px_0.5px_rgba(255,255,255,0.82),0_12px_20px_-14px_rgba(55,47,36,0.34)] hover:border-[#cbc3b5] hover:bg-[#eee9df]",
    rowTitle: "text-[#171717]",
    statusText: "text-[#171717]/60",
    syncButton:
      "border-[#d2c8b9] bg-[#eee9df] text-[#171717] shadow-[inset_0_1px_0_rgba(255,255,255,0.75)] hover:bg-[#e7e1d5]",
    updatesGradient: "from-[#ec5c13]/12 to-transparent",
    syncingGradient: "from-[#22c55e]/10 to-transparent",
    spinnerActive: "#171717",
    spinnerInactive: "#9a9286",
  },
};

type StatusListPalette = (typeof statusListPalettes)["dark"];

export function AnimatedCardStatusList({
  title = "Fundamentals",
  cards: initialCards = defaultCards,
  onSynchronize,
  onAddCard,
  onBack,
  className = ""
}: AnimatedCardStatusListProps = {}) {
  const { resolvedTheme } = useTheme();
  const [mounted, setMounted] = useState(false);
  const [cards, setCards] = useState<Card[]>(initialCards);
  const [hoveredCard, setHoveredCard] = useState<string | null>(null);
  const [activeDashIndex, setActiveDashIndex] = useState(0);
  const shouldReduceMotion = useReducedMotion();
  const palette: StatusListPalette =
    mounted && resolvedTheme === "light"
      ? statusListPalettes.light
      : statusListPalettes.dark;

  useEffect(() => {
    setMounted(true);
  }, []);

  // Cycle through dash indices every 100ms
  useEffect(() => {
    if (shouldReduceMotion) return;
    
    const interval = setInterval(() => {
      setActiveDashIndex(prev => (prev + 1) % 8);
    }, 100);
    
    return () => clearInterval(interval);
  }, [shouldReduceMotion]);

  const handleSynchronize = (cardId: string) => {
    // Call external handler if provided
    if (onSynchronize) {
      onSynchronize(cardId);
    }

    // Update internal state
    setCards(prev => prev.map(card => 
      card.id === cardId ? { ...card, status: "syncing" as const } : card
    ));

    // Simulate sync completion after 2.5 seconds
    setTimeout(() => {
      setCards(prev => prev.map(card => 
        card.id === cardId ? { ...card, status: "completed" as const } : card
      ));
    }, 2500);
  };

  const handleAddCard = () => {
    if (onAddCard) {
      onAddCard();
    }
  };

  const handleBack = () => {
    if (onBack) {
      onBack();
    }
  };

  const getStatusIcon = (status: Card["status"]) => {
    switch (status) {
      case "completed":
        return (
          <svg width="16" height="16" viewBox="0 0 16 16" className="drop-shadow-sm">
            <circle cx="8" cy="8" r="8" fill="#22c55e" />
            <path 
              d="M5 8l2.5 2.5 3.5-4" 
              stroke="white" 
              strokeWidth="1.5" 
              fill="none" 
              strokeLinecap="round" 
              strokeLinejoin="round"
            />
          </svg>
        );
      case "updates-found":
        return (
          <svg width="16" height="16" viewBox="0 0 16 16">
            <path 
              d="M8 1.5L14.5 13H1.5L8 1.5Z" 
              fill="#eab308" 
              stroke="#eab308" 
              strokeWidth="1"
              strokeLinejoin="round"
            />
            <path 
              d="M8 6v3M8 11h0" 
              stroke="white" 
              strokeWidth="1.5" 
              strokeLinecap="round"
            />
          </svg>
        );
      case "syncing":
        return (
          <svg 
            width="16" 
            height="16" 
            viewBox="0 0 16 16"
          >
            {/* Create 8 dashes around the circle */}
            {Array.from({ length: 8 }).map((_, index) => {
              const angle = (index * 45) - 90; // Start from top, -90 to offset
              const radian = (angle * Math.PI) / 180;
              const radius = 6;
              const dashLength = 1.8;
              
              // Calculate start and end points for each dash
              const startX = 8 + (radius - dashLength/2) * Math.cos(radian);
              const startY = 8 + (radius - dashLength/2) * Math.sin(radian);
              const endX = 8 + (radius + dashLength/2) * Math.cos(radian);
              const endY = 8 + (radius + dashLength/2) * Math.sin(radian);
              
              // Use the activeDashIndex to determine which dash is white
              const isActive = index === activeDashIndex;
              
              return (
                <line
                  key={index}
                  x1={startX}
                  y1={startY}
                  x2={endX}
                  y2={endY}
                  stroke={isActive ? palette.spinnerActive : palette.spinnerInactive}
                  strokeWidth="2"
                  strokeLinecap="round"
                />
              );
            })}
          </svg>
        );
    }
  };

  const getStatusText = (status: Card["status"]) => {
    switch (status) {
      case "updates-found":
        return "UPDATES FOUND";
      case "syncing":
        return "SYNCING";
      default:
        return null;
    }
  };

  const getGradientClass = (status: Card["status"]) => {
    switch (status) {
      case "updates-found":
        return palette.updatesGradient;
      case "syncing":
        return palette.syncingGradient;
      default:
        return "";
    }
  };

  // Sort cards: completed first, then others
  const sortedCards = [...cards].sort((a, b) => {
    if (a.status === "completed" && b.status !== "completed") return -1;
    if (a.status !== "completed" && b.status === "completed") return 1;
    return 0;
  });

  return (
    <div className={cn("mx-auto w-full max-w-2xl p-6", className)}>
      <BjorkCard variant="elevated" padding="lg" className={cn("rounded-2xl", palette.card)}>
        {/* Header */}
        <div className="flex items-center justify-between mb-6">
          <motion.button
            onClick={handleBack}
            className={cn(
              "cursor-pointer rounded-lg border p-2 transition-colors",
              palette.iconButton
            )}
            whileHover={shouldReduceMotion ? undefined : { scale: 1.05 }}
            whileTap={shouldReduceMotion ? undefined : { scale: 0.98 }}
            aria-label="Go back"
          >
            <ChevronLeft className="w-4 h-4" />
          </motion.button>
          
          <h1 className={cn("text-xl font-medium tracking-[-0.02em]", palette.title)}>{title}</h1>
          
          <motion.button
            onClick={handleAddCard}
            className={cn(
              "cursor-pointer rounded-lg border p-2 transition-colors",
              palette.iconButton
            )}
            whileHover={shouldReduceMotion ? undefined : { scale: 1.05 }}
            whileTap={shouldReduceMotion ? undefined : { scale: 0.98 }}
            aria-label="Add card"
          >
            <Plus className="w-4 h-4" />
          </motion.button>
        </div>

        {/* Cards */}
        <motion.div 
          className="space-y-3"
          variants={{
            visible: {
              transition: {
                staggerChildren: 0.08,
                delayChildren: 0.1,
              }
            }
          }}
          initial="hidden"
          animate="visible"
        >
          <AnimatePresence>
            {sortedCards.map((card) => (
              <motion.div
                key={card.id}
                layout
                layoutId={card.id}
                variants={{
                  hidden: { opacity: 0, y: 20, scale: 0.98 },
                  visible: { 
                    opacity: 1, 
                    y: 0, 
                    scale: 1,
                    transition: { 
                      type: "spring", 
                      stiffness: 300, 
                      damping: 30,
                      duration: shouldReduceMotion ? 0.2 : undefined
                    }
                  }
                }}
                exit={{ 
                  opacity: 0, 
                  y: -20, 
                  scale: 0.98,
                  transition: { 
                    duration: shouldReduceMotion ? 0.15 : 0.2,
                    ease: "easeInOut"
                  }
                }}
                transition={{
                  layout: { 
                    type: "spring", 
                    stiffness: 400, 
                    damping: 30,
                    duration: shouldReduceMotion ? 0.2 : 0.5
                  }
                }}
                className="relative cursor-pointer"
                onMouseEnter={() => setHoveredCard(card.id)}
                onMouseLeave={() => setHoveredCard(null)}
              >
                <BjorkCard
                  variant="interactive"
                  padding="none"
                  className={cn("relative rounded-xl p-4", palette.row)}
                  whileHover={{
                    y: -1,
                    transition: { type: "spring", stiffness: 400, damping: 25 }
                  }}
                  animate={
                    card.status === "completed" ? {
                      scale: [1, 1.02, 1],
                      transition: {
                        duration: shouldReduceMotion ? 0 : 0.6,
                        ease: [0.04, 0.62, 0.23, 0.98],
                        times: [0, 0.3, 1],
                      }
                    } : {}
                  }
                >
                  {/* Gradient overlay for status */}
                  {(card.status === "updates-found" || card.status === "syncing") && (
                    <div className={`absolute inset-0 bg-gradient-to-l ${getGradientClass(card.status)} pointer-events-none`} 
                         style={{ 
                           backgroundSize: "40% 100%", 
                           backgroundPosition: "right",
                           backgroundRepeat: "no-repeat"
                         }} 
                    />
                  )}
                  
                  <div className="relative flex items-center justify-between">
                    <div className="flex items-center gap-3">
                      {/* Status Icon */}
                      <div className="w-5 h-5 flex items-center justify-center overflow-hidden">
                        <AnimatePresence mode="wait">
                          <motion.div
                            key={card.status}
                            initial={{ 
                              x: card.status === "completed" ? 24 : 0,
                              scale: 0.8, 
                              opacity: 0 
                            }}
                            animate={{ 
                              x: 0,
                              scale: 1, 
                              opacity: 1 
                            }}
                            exit={{ 
                              x: card.status === "syncing" ? -24 : 0,
                              scale: 0.8, 
                              opacity: 0 
                            }}
                            transition={{ 
                              type: "spring", 
                              stiffness: 400, 
                              damping: 25,
                              duration: shouldReduceMotion ? 0.15 : undefined
                            }}
                          >
                            {getStatusIcon(card.status)}
                          </motion.div>
                        </AnimatePresence>
                      </div>
                      
                      {/* Title */}
                      <span className={cn("truncate", palette.rowTitle)}>{card.title}</span>
                    </div>

                    {/* Status Text or Synchronize Button */}
                    <div className="flex items-center min-w-0 h-8">
                      <AnimatePresence mode="wait">
                        {card.status === "updates-found" && hoveredCard === card.id ? (
                          <motion.button
                            key="sync-button"
                            initial={{ scale: 0, opacity: 0 }}
                            animate={{ scale: 1, opacity: 1 }}
                            exit={{ scale: 0, opacity: 0 }}
                            whileHover={{ 
                              scale: 1.02,
                              transition: { type: "spring", stiffness: 400, damping: 25 }
                            }}
                            whileTap={{ scale: 0.98 }}
                            transition={{ 
                              type: "spring", 
                              stiffness: 400, 
                              damping: 25,
                              duration: shouldReduceMotion ? 0.15 : undefined
                            }}
                            onClick={() => handleSynchronize(card.id)}
                            className={cn(
                              "cursor-pointer whitespace-nowrap rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
                              palette.syncButton
                            )}
                          >
                            Synchronize
                          </motion.button>
                        ) : getStatusText(card.status) ? (
                          <motion.span
                            key="status-text"
                            initial={{ opacity: 0 }}
                            animate={{ opacity: 1 }}
                            exit={{ opacity: 0 }}
                            className={cn(
                              "whitespace-nowrap font-mono text-xs font-medium tracking-wider",
                              palette.statusText
                            )}
                          >
                            {getStatusText(card.status)}
                          </motion.span>
                        ) : null}
                      </AnimatePresence>
                    </div>
                  </div>
                </BjorkCard>
              </motion.div>
            ))}
          </AnimatePresence>
        </motion.div>
      </BjorkCard>
    </div>
  );
}
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
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 };

More from bjork-ui

Browse all components in this library →