Bloom Logo

Card

A content container component supporting headers, bodies, footers, interactive states (hoverable, pressable), custom radii, variants, and clean neutral theme backgrounds.

importimport { Card } from "@/components/ui/card/card";
$npx @bloomui-react/cli add card
card.tsx
"use client";

import { Icon } from "@iconify/react";
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { designRadius } from "@/lib/design-system";
import { useKeyboardClick } from "@/lib/hooks";
import { Ripple } from "@/lib/ripple/ripple";
import { useRipples } from "@/lib/ripple/useRipple";
import { cn } from "@/lib/utils";

type CardColor =
  | "default"
  | "primary"
  | "secondary"
  | "accent"
  | "success"
  | "warning"
  | "danger";

type CardVariant =
  | "default"
  | "bordered"
  | "flat"
  | "ghost"
  | "shadow"
  | "glassmorphism"
  | "gradient";

export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
  variant?: CardVariant;
  color?: CardColor;
  radius?: keyof typeof designRadius;
  isHoverable?: boolean;
  isPressable?: boolean;
  isDisabled?: boolean;
  isLoading?: boolean;
  disableRipple?: boolean;
  backgroundIcon?: string;
  children?: React.ReactNode;
  asChild?: boolean;
}

const cardColorMap: Record<CardColor, Record<CardVariant, string>> = {
  default: {
    default:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200/80 dark:border-zinc-800/80 shadow-xs",
    bordered:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800",
    flat: "bg-zinc-100 dark:bg-zinc-800/60 text-zinc-900 dark:text-zinc-100 border-transparent",
    ghost: "bg-transparent text-zinc-900 dark:text-zinc-100 border-transparent",
    shadow:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200/50 dark:border-zinc-800/50 shadow-md",
    glassmorphism:
      "backdrop-blur-md bg-white/70 dark:bg-zinc-900/70 text-zinc-900 dark:text-zinc-100 border border-zinc-200/50 dark:border-zinc-800/50 shadow-lg",
    gradient:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800",
  },
  primary: {
    default:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-sky-500 shadow-xs",
    bordered:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-sky-500/50 shadow-xs",
    flat: "bg-sky-500/5 dark:bg-sky-500/10 text-zinc-900 dark:text-zinc-100 border border-sky-500/20",
    ghost: "bg-transparent text-zinc-900 dark:text-zinc-100 border-transparent",
    shadow:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-sky-500 shadow-md",
    glassmorphism:
      "backdrop-blur-md bg-white/70 dark:bg-zinc-900/70 text-zinc-900 dark:text-zinc-100 border border-sky-500/30 shadow-lg",
    gradient:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-sky-500",
  },
  secondary: {
    default:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-purple-500 shadow-xs",
    bordered:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-purple-500/50 shadow-xs",
    flat: "bg-purple-500/5 dark:bg-purple-500/10 text-zinc-900 dark:text-zinc-100 border border-purple-500/20",
    ghost: "bg-transparent text-zinc-900 dark:text-zinc-100 border-transparent",
    shadow:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-purple-500 shadow-md",
    glassmorphism:
      "backdrop-blur-md bg-white/70 dark:bg-zinc-900/70 text-zinc-900 dark:text-zinc-100 border border-purple-500/30 shadow-lg",
    gradient:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-purple-500",
  },
  accent: {
    default:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-pink-500 shadow-xs",
    bordered:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-pink-500/50 shadow-xs",
    flat: "bg-pink-500/5 dark:bg-pink-500/10 text-zinc-900 dark:text-zinc-100 border border-pink-500/20",
    ghost: "bg-transparent text-zinc-900 dark:text-zinc-100 border-transparent",
    shadow:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-pink-500 shadow-md",
    glassmorphism:
      "backdrop-blur-md bg-white/70 dark:bg-zinc-900/70 text-zinc-900 dark:text-zinc-100 border border-pink-500/30 shadow-lg",
    gradient:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-pink-500",
  },
  success: {
    default:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-emerald-500 shadow-xs",
    bordered:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-emerald-500/50 shadow-xs",
    flat: "bg-emerald-500/5 dark:bg-emerald-500/10 text-zinc-900 dark:text-zinc-100 border border-emerald-500/20",
    ghost: "bg-transparent text-zinc-900 dark:text-zinc-100 border-transparent",
    shadow:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-emerald-500 shadow-md",
    glassmorphism:
      "backdrop-blur-md bg-white/70 dark:bg-zinc-900/70 text-zinc-900 dark:text-zinc-100 border border-emerald-500/30 shadow-lg",
    gradient:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-emerald-500",
  },
  warning: {
    default:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-amber-500 shadow-xs",
    bordered:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-amber-500/50 shadow-xs",
    flat: "bg-amber-500/5 dark:bg-amber-500/10 text-zinc-900 dark:text-zinc-100 border border-amber-500/20",
    ghost: "bg-transparent text-zinc-900 dark:text-zinc-100 border-transparent",
    shadow:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-amber-500 shadow-md",
    glassmorphism:
      "backdrop-blur-md bg-white/70 dark:bg-zinc-900/70 text-zinc-900 dark:text-zinc-100 border border-amber-500/30 shadow-lg",
    gradient:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-amber-500",
  },
  danger: {
    default:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-rose-500 shadow-xs",
    bordered:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-rose-500/50 shadow-xs",
    flat: "bg-rose-500/5 dark:bg-rose-500/10 text-zinc-900 dark:text-zinc-100 border border-rose-500/20",
    ghost: "bg-transparent text-zinc-900 dark:text-zinc-100 border-transparent",
    shadow:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-rose-500 shadow-md",
    glassmorphism:
      "backdrop-blur-md bg-white/70 dark:bg-zinc-900/70 text-zinc-900 dark:text-zinc-100 border border-rose-500/30 shadow-lg",
    gradient:
      "bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 border border-zinc-200 dark:border-zinc-800 border-l-4 border-l-rose-500",
  },
};

const Card = React.forwardRef<HTMLDivElement, CardProps>(
  (
    {
      className,
      variant = "default",
      color = "default",
      radius = "xl",
      isHoverable = false,
      isPressable = false,
      isDisabled = false,
      isLoading = false,
      disableRipple = false,
      backgroundIcon,
      onClick,
      children,
      asChild = false,
      ...props
    },
    ref,
  ) => {
    const { ripples, addRipple, removeRipple } = useRipples();
    const Comp = asChild ? Slot : "div";

    const handleClick = React.useCallback(
      (e: React.MouseEvent<HTMLDivElement>) => {
        if (isDisabled || isLoading) return;

        if (isPressable && !disableRipple) {
          const isKeyboardClick = e.clientX === 0 && e.clientY === 0;
          const rect = e.currentTarget.getBoundingClientRect();
          const size = Math.max(rect.width, rect.height);
          const x = isKeyboardClick ? rect.width / 2 : e.clientX - rect.left;
          const y = isKeyboardClick ? rect.height / 2 : e.clientY - rect.top;
          addRipple(x, y, size);
        }

        onClick?.(e);
      },
      [isPressable, isDisabled, isLoading, disableRipple, addRipple, onClick],
    );

    const keyboardProps = useKeyboardClick<HTMLDivElement>(
      isPressable && !isDisabled && !isLoading,
    );

    return (
      <Comp
        ref={ref}
        role={isPressable ? "button" : undefined}
        aria-disabled={isDisabled || isLoading ? true : undefined}
        aria-busy={isLoading || undefined}
        onClick={handleClick}
        className={cn(
          "group relative overflow-hidden flex flex-col justify-between transition-all duration-200 select-none",
          designRadius[radius],
          cardColorMap[color][variant],
          isHoverable &&
            !isDisabled &&
            !isLoading &&
            "hover:-translate-y-0.5 hover:shadow-lg hover:border-zinc-300 dark:hover:border-zinc-700",
          isPressable &&
            !isDisabled &&
            !isLoading &&
            "cursor-pointer hover:scale-[1.01] active:scale-[0.98] outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
          isDisabled && "opacity-50 pointer-events-none cursor-not-allowed",
          isLoading && "opacity-75 cursor-wait pointer-events-none",
          className,
        )}
        {...keyboardProps}
        {...props}
      >
        {backgroundIcon && (
          <Icon
            icon={backgroundIcon}
            className="absolute -right-3 -bottom-3 size-28 text-zinc-900/[0.04] dark:text-zinc-100/[0.03] group-hover:scale-110 group-hover:text-zinc-900/[0.07] dark:group-hover:text-zinc-100/[0.06] transition-all duration-300 pointer-events-none select-none"
          />
        )}
        {isLoading && (
          <div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 dark:bg-zinc-900/50 backdrop-blur-[1px]">
            <div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
          </div>
        )}
        <div className="relative z-10 w-full flex flex-col flex-1 justify-between">
          {children}
        </div>
        {isPressable &&
          !disableRipple &&
          ripples.map((r) => (
            <Ripple
              key={r.id}
              x={r.x}
              y={r.y}
              size={r.size}
              onComplete={() => removeRipple(r.id)}
            />
          ))}
      </Comp>
    );
  },
);
Card.displayName = "Card";

export interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}

const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn("flex flex-col space-y-1.5 p-6", className)}
      {...props}
    />
  ),
);
CardHeader.displayName = "CardHeader";

export interface CardTitleProps
  extends React.HTMLAttributes<HTMLHeadingElement> {}

const CardTitle = React.forwardRef<HTMLHeadingElement, CardTitleProps>(
  ({ className, children, ...props }, ref) => (
    <h3
      ref={ref}
      className={cn(
        "font-semibold leading-none tracking-tight text-lg text-zinc-900 dark:text-zinc-100",
        className,
      )}
      {...props}
    >
      {children}
    </h3>
  ),
);
CardTitle.displayName = "CardTitle";

export interface CardDescriptionProps
  extends React.HTMLAttributes<HTMLParagraphElement> {}

const CardDescription = React.forwardRef<
  HTMLParagraphElement,
  CardDescriptionProps
>(({ className, ...props }, ref) => (
  <p
    ref={ref}
    className={cn(
      "text-sm text-zinc-500 dark:text-zinc-400 leading-relaxed",
      className,
    )}
    {...props}
  />
));
CardDescription.displayName = "CardDescription";

export interface CardBodyProps extends React.HTMLAttributes<HTMLDivElement> {}

const CardBody = React.forwardRef<HTMLDivElement, CardBodyProps>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(
        "p-6 pt-0 text-sm text-zinc-700 dark:text-zinc-300",
        className,
      )}
      {...props}
    />
  ),
);
CardBody.displayName = "CardBody";

export interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
  isBlurred?: boolean;
}

const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>(
  ({ className, isBlurred = false, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(
        "flex items-center p-6 pt-0",
        isBlurred &&
          "backdrop-blur-md bg-white/60 dark:bg-zinc-900/60 absolute bottom-0 left-0 right-0 z-10 rounded-b-xl border-t border-zinc-200/50 dark:border-zinc-800/50",
        className,
      )}
      {...props}
    />
  ),
);
CardFooter.displayName = "CardFooter";

export { Card, CardBody, CardDescription, CardFooter, CardHeader, CardTitle };

Default

Standard card layout composed of CardHeader, CardTitle, CardDescription, CardBody, and CardFooter.

Notifications

You have 3 unread messages.

Your subscription is set to renew automatically on August 15, 2026.

Variants

Cards support default, bordered, flat, ghost, shadow, glassmorphism, and gradient visual variants.

variant: default | bordered | flat | ghost | shadow | glassmorphism | gradient

Default

Standard background with border and subtle shadow.

Bordered

Transparent background with standard border.

Flat

Muted background without border.

Ghost

Completely transparent content container.

Shadow

Elevated card with deep shadow projection.

Glassmorphism

Frosted glass backdrop-blur effect.

Color Themes

Apply subtle status accent indicators to cards while preserving a clean, neutral white/zinc dark theme card background.

color: default | primary | secondary | accent | success | warning | danger
color="primary"

System Information

Primary

Clean neutral card container with a subtle primary border indicator.

color="success"

Deployment Successful

Success

All 12 microservices deployed without errors.

color="warning"

Storage Warning

Warning

Your database storage is at 84% capacity.

color="danger"

Connection Error

Danger

Failed to reach primary API cluster endpoint.

Interactive Cards & Ripple Effect

Use isHoverable for subtle hover translations and isPressable to enable click press feedback with an animated water ripple effect. Ripple can be disabled using disableRipple.

isHoverable: booleanisPressable: booleandisableRipple: boolean

Hoverable Card

Hover Me

Translates slightly upward on hover with shadow depth.

Pressable with Ripple

Click Me

Click to trigger fluid water ripple feedback animation.

Loading State

Pass isLoading to render a clean backdrop overlay and animated spinner during data fetching.

isLoading: boolean

Fetching Analytics

Loading real-time user metrics...

Card content is temporarily blurred and non-interactive while loading.

Processing Payment

Communicating with payment gateway...

Please wait while we confirm your transaction.

Background Icon Watermark

Pass backgroundIcon to display a watermark icon aligned at the bottom-right corner of the card container.

backgroundIcon: string

Complete Ownership

Copy full source code directly into your repository.

Neutral Architecture

Standardized neutral palette with light and dark theme tokens.

Micro-Interactions

Subtle, fluid motion powered by Framer Motion.

Props — Card

Properties to configure the Card component.

PropTypeDefaultDescription
variant'default' | 'bordered' | 'flat' | 'ghost' | 'shadow' | 'glassmorphism' | 'gradient''default'Visual variant style of the card.
color'default' | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger''default'Semantic color theme of the card accent indicator.
backgroundIconstringundefinedIconify icon string to render as a background watermark overlay in the bottom-right corner.
radius'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full''xl'Border radius scale.
isHoverablebooleanfalseEnables subtle hover translation and shadow enhancement.
isPressablebooleanfalseEnables active press animations, ripple feedback, and interactive button role accessibility.
disableRipplebooleanfalseDisables click water ripple effect on pressable cards.
isLoadingbooleanfalseDisplays an overlay spinner state during data loading.
isDisabledbooleanfalseDisables interaction and applies muted opacity styling.