shadimage
bjork-ui
componentdashboard-card-modalsource: ok · hit

Dashboard card modal

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

npx shadcn add "https://ui.isaiahbjork.com/dashboard-card-modal.json"
free

Where it goes

kanbandashboard-shell

Interaction

modal

Use cases

dashboard

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

Dependencies

Bundle weight · medium
ui-primitives@radix-ui/react-avatar@radix-ui/react-separator
animationframer-motion
iconslucide-react

Files7 · source cached 10/8/2026, 3:38:50 PM

components/bjork-ui/cards/dashboard-card-modal.tsx
"use client";

import React, { useState } from 'react';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { Copy, Shield, Car, Calendar, MapPin, User, CreditCard, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { BjorkBadge } from '@/components/bjork-ui/primitives/badge';
import { BjorkButton } from '@/components/bjork-ui/primitives/button';
import { BjorkCard } from '@/components/bjork-ui/primitives/card';

interface DashboardCardModalProps {
  // Client information
  clientName?: string;
  clientAvatar?: string;
  dateOfBirth?: string;
  cityOfResidence?: string;
  idNumber?: string;
  policyNumber?: string;
  
  // Policy details
  policyType?: string;
  expiryDate?: string;
  vehicleInfo?: string;
  qrCodeImage?: string;
  
  // Event handlers
  onUpdatePolicy?: (data: Record<string, unknown>) => void;
  onCopyId?: () => void;
  onCopyPolicy?: () => void;
  
  // Configuration
  enableAnimations?: boolean;
  className?: string;
}

export function DashboardCardModal({
  clientName = "Sarah Johnson",
  clientAvatar = "https://images.unsplash.com/photo-1494790108755-2616b96e6ede?q=80&w=200",
  dateOfBirth = "March 15, 1985",
  cityOfResidence = "San Francisco, CA",
  idNumber = "ID-2589637410",
  policyNumber = "POL-8849271653",
  policyType = "Comprehensive Auto Insurance",
  expiryDate = "Dec 31, 2026",
  vehicleInfo = "2022 Tesla Model 3 • Blue",
  qrCodeImage = "https://api.qrserver.com/v1/create-qr-code/?size=80x80&data=POL-8849271653",
  onUpdatePolicy,
  onCopyId,
  onCopyPolicy,
  enableAnimations = true,
  className,
}: DashboardCardModalProps) {
  const [showUpdateModule, setShowUpdateModule] = useState(false);
  const [copiedId, setCopiedId] = useState(false);
  const [copiedPolicy, setCopiedPolicy] = useState(false);
  const shouldReduceMotion = useReducedMotion();

  const shouldAnimate = enableAnimations && !shouldReduceMotion;

  // Calculate days until expiry
  const getDaysUntilExpiry = () => {
    // Parse date string manually for better compatibility
    const [month, day, year] = expiryDate.split(' ');
    const months = {
      'Jan': 0, 'Feb': 1, 'Mar': 2, 'Apr': 3, 'May': 4, 'Jun': 5,
      'Jul': 6, 'Aug': 7, 'Sep': 8, 'Oct': 9, 'Nov': 10, 'Dec': 11
    };
    const cleanDay = parseInt(day.replace(',', ''));
    const cleanYear = parseInt(year);

    const expiry = new Date(cleanYear, months[month as keyof typeof months], cleanDay);
    const today = new Date();
    const diffTime = expiry.getTime() - today.getTime();
    const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
    return diffDays;
  };

  const daysUntilExpiry = getDaysUntilExpiry();
  const isExpiringSoon = daysUntilExpiry <= 30;

  const handleCopyId = async () => {
    await navigator.clipboard.writeText(idNumber);
    setCopiedId(true);
    setTimeout(() => setCopiedId(false), 2000);
    onCopyId?.();
  };

  const handleCopyPolicy = async () => {
    await navigator.clipboard.writeText(policyNumber);
    setCopiedPolicy(true);
    setTimeout(() => setCopiedPolicy(false), 2000);
    onCopyPolicy?.();
  };

  const handleUpdatePolicy = () => {
    setShowUpdateModule(true);
  };

  const handleConfirmUpdate = () => {
    const updateData = {
      policyNumber,
      clientName,
      timestamp: new Date().toISOString(),
    };
    onUpdatePolicy?.(updateData);
    setShowUpdateModule(false);
  };

  const handleCloseModule = () => {
    setShowUpdateModule(false);
  };

  // Animation variants
  const containerVariants = {
    hidden: { 
      opacity: 0, 
      y: 20, 
      scale: 0.95,
      filter: "blur(4px)",
    },
    visible: {
      opacity: 1,
      y: 0,
      scale: 1,
      filter: "blur(0px)",
      transition: {
        type: "spring",
        stiffness: 300,
        damping: 30,
        mass: 0.8,
        staggerChildren: 0.08,
        delayChildren: 0.1,
      },
    },
  };

  const itemVariants = {
    hidden: { 
      opacity: 0, 
      x: -15, 
      scale: 0.95,
      filter: "blur(2px)",
    },
    visible: {
      opacity: 1,
      x: 0,
      scale: 1,
      filter: "blur(0px)",
      transition: {
        type: "spring",
        stiffness: 400,
        damping: 28,
        mass: 0.6,
      },
    },
  };

  return (
    <BjorkCard
      variant="elevated"
      padding="none"
      className={cn(
        "mx-auto w-full max-w-md rounded-[20px]",
        "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_18px_34px_-16px_rgba(55,47,36,0.28)] 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_18px_34px_-16px_rgba(0,0,0,0.9)]",
        className
      )}
      initial={false}
      animate="visible"
      variants={shouldAnimate ? containerVariants : {}}
    >
      <div className="relative">
        {/* Main Content */}
        <motion.div
          initial={false}
          animate={{ 
            opacity: showUpdateModule ? 0.3 : 1,
            scale: showUpdateModule ? 0.95 : 1,
            filter: showUpdateModule ? "blur(1px)" : "blur(0px)"
          }}
          transition={{ 
            type: "spring", 
            stiffness: 300, 
            damping: 30,
            mass: 0.8
          }}
          className="p-6 space-y-4"
        >
          {/* Header with Avatar and Expiry */}
          <motion.div 
            className="flex items-start justify-between"
            variants={shouldAnimate ? itemVariants : {}}
          >
            <div className="flex items-center gap-3">
              <Avatar className="w-12 h-12 ring-2 ring-primary/20">
                <AvatarImage src={clientAvatar} alt={clientName} />
                <AvatarFallback className="bg-primary/10 text-primary font-semibold">
                  {clientName.split(' ').map(n => n[0]).join('')}
                </AvatarFallback>
              </Avatar>
              <div>
                <BjorkBadge
                  variant={isExpiringSoon ? "outline" : "accent"}
                  className={cn(
                    "mb-1 text-xs font-medium",
                    isExpiringSoon && "border-[#ec5c13]/28 bg-[#ec5c13]/12 text-[#bd4514] dark:text-[#d86a2c]"
                  )}
                >
                  {isExpiringSoon ? `Expires in ${daysUntilExpiry} days` : "Active Policy"}
                </BjorkBadge>
                <p className="text-sm text-muted-foreground">{expiryDate}</p>
              </div>
            </div>
            <div className="h-12 w-12 overflow-hidden rounded-xl border border-[#d8d3c7] bg-[#eee9df] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] dark:border-[#232323] dark:bg-[#090909] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
              <img
                src={qrCodeImage}
                alt="Policy QR Code"
                className="w-full h-full object-cover rounded"
              />
            </div>
          </motion.div>

          {/* Client Information */}
          <motion.div 
            className="space-y-3"
            variants={shouldAnimate ? itemVariants : {}}
          >
            <div className="grid grid-cols-2 gap-4">
              <div>
                <div className="flex items-center gap-1 mb-1">
                  <User className="w-3 h-3 text-muted-foreground" />
                  <span className="text-xs text-muted-foreground uppercase tracking-wide">Client Name</span>
                </div>
                <p className="text-sm font-medium text-foreground">{clientName}</p>
              </div>
              <div>
                <div className="flex items-center gap-1 mb-1">
                  <Calendar className="w-3 h-3 text-muted-foreground" />
                  <span className="text-xs text-muted-foreground uppercase tracking-wide">Date of Birth</span>
                </div>
                <p className="text-sm font-medium text-foreground">{dateOfBirth}</p>
              </div>
            </div>

            <div>
              <div className="flex items-center gap-1 mb-1">
                <MapPin className="w-3 h-3 text-muted-foreground" />
                <span className="text-xs text-muted-foreground uppercase tracking-wide">City of Residence</span>
              </div>
              <p className="text-sm font-medium text-foreground">{cityOfResidence}</p>
            </div>

            {/* ID Number with Copy */}
            <div className="flex items-center justify-between">
              <div className="flex-1">
                <div className="flex items-center gap-1 mb-1">
                  <CreditCard className="w-3 h-3 text-muted-foreground" />
                  <span className="text-xs text-muted-foreground uppercase tracking-wide">ID Number</span>
                </div>
                <p className="text-sm font-medium text-foreground">{idNumber}</p>
              </div>
              <motion.button
                whileHover={{ scale: 1.05 }}
                whileTap={{ scale: 0.95 }}
                onClick={handleCopyId}
                className="ml-2 flex h-7 w-7 items-center justify-center rounded-lg border border-[#d8d3c7] bg-[#eee9df] shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] transition-colors hover:bg-[#e7e1d5] dark:border-[#232323] dark:bg-[#090909] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] dark:hover:bg-[#141414]"
              >
                {copiedId ? (
                  <Check className="w-3 h-3 text-[#ec5c13]" />
                ) : (
                  <Copy className="w-3 h-3 text-muted-foreground" />
                )}
              </motion.button>
            </div>

            {/* Policy Number with Copy */}
            <div className="flex items-center justify-between">
              <div className="flex-1">
                <div className="flex items-center gap-1 mb-1">
                  <Shield className="w-3 h-3 text-muted-foreground" />
                  <span className="text-xs text-muted-foreground uppercase tracking-wide">Policy Number</span>
                </div>
                <p className="text-sm font-medium text-foreground">{policyNumber}</p>
              </div>
              <motion.button
                whileHover={{ scale: 1.05 }}
                whileTap={{ scale: 0.95 }}
                onClick={handleCopyPolicy}
                className="ml-2 flex h-7 w-7 items-center justify-center rounded-lg border border-[#d8d3c7] bg-[#eee9df] shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] transition-colors hover:bg-[#e7e1d5] dark:border-[#232323] dark:bg-[#090909] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] dark:hover:bg-[#141414]"
              >
                {copiedPolicy ? (
                  <Check className="w-3 h-3 text-[#ec5c13]" />
                ) : (
                  <Copy className="w-3 h-3 text-muted-foreground" />
                )}
              </motion.button>
            </div>
          </motion.div>

          {/* Separator */}
          <motion.div variants={shouldAnimate ? itemVariants : {}}>
            <Separator className="my-4" />
          </motion.div>

          {/* Policy Details */}
          <motion.div 
            className="space-y-3"
            variants={shouldAnimate ? itemVariants : {}}
          >
            <div>
              <div className="flex items-center gap-1 mb-1">
                <Shield className="w-3 h-3 text-muted-foreground" />
                <span className="text-xs text-muted-foreground uppercase tracking-wide">Type of Insurance</span>
              </div>
              <p className="text-sm font-medium text-foreground">{policyType}</p>
            </div>

            <div>
              <div className="flex items-center gap-1 mb-1">
                <Car className="w-3 h-3 text-muted-foreground" />
                <span className="text-xs text-muted-foreground uppercase tracking-wide">Vehicle Information</span>
              </div>
              <p className="text-sm font-medium text-foreground">{vehicleInfo}</p>
            </div>
          </motion.div>

          {/* Update Policy Button */}
          <motion.div
            className="pt-4"
            variants={shouldAnimate ? itemVariants : {}}
          >
            <motion.div
              whileHover={{ scale: 1.02, y: -1 }}
              whileTap={{ scale: 0.98 }}
              transition={{ type: "spring", stiffness: 400, damping: 25 }}
            >
            <BjorkButton
              onClick={handleUpdatePolicy}
              variant="accent"
              className="w-full flex items-center justify-center gap-2"
            >
              <Shield className="w-4 h-4" />
              Update Policy
            </BjorkButton>
            </motion.div>
          </motion.div>
        </motion.div>

        {/* Update Policy Module Overlay */}
        <AnimatePresence>
          {showUpdateModule && (
            <motion.div
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.2 }}
              className="absolute inset-0 bg-background/60 backdrop-blur-sm flex items-center justify-center rounded-xl z-10"
            >
              <motion.div
                initial={{ scale: 0.8, opacity: 0, y: 20 }}
                animate={{ scale: 1, opacity: 1, y: 0 }}
                exit={{ scale: 0.8, opacity: 0, y: 20 }}
                transition={{ type: "spring", stiffness: 300, damping: 30, mass: 0.8 }}
                className="relative mx-6 rounded-[18px] border border-[#d8d3c7] bg-[#f4f1e9] p-6 shadow-[inset_0_7px_14px_rgba(255,255,255,0.62),inset_0_0.5px_0.5px_rgba(255,255,255,0.9),0_18px_34px_-16px_rgba(55,47,36,0.36)] dark:border-[#232323] dark:bg-[#121212] 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_18px_34px_-16px_rgba(0,0,0,0.9)]"
              >
                {/* Close button */}
                <button
                  onClick={handleCloseModule}
                  className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full border border-[#d8d3c7] bg-[#eee9df] transition-colors hover:bg-[#e7e1d5] dark:border-[#232323] dark:bg-[#090909] dark:hover:bg-[#141414]"
                >
                  <X className="w-3 h-3 text-muted-foreground" />
                </button>

                <div className="text-center space-y-4">
                  <div className="w-12 h-12 bg-primary/10 rounded-full mx-auto flex items-center justify-center">
                    <Shield className="w-6 h-6 text-primary" />
                  </div>
                  
                  <div>
                    <h3 className="text-lg font-semibold text-foreground mb-1">
                      Update Policy
                    </h3>
                    <p className="text-sm text-muted-foreground">
                      Confirm policy update for {clientName}
                    </p>
                  </div>

                  <div className="rounded-xl border border-[#d8d3c7] bg-[#eee9df] p-3 text-left shadow-[inset_0_1px_0_rgba(255,255,255,0.72)] dark:border-[#232323] dark:bg-[#090909] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
                    <div className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
                      Policy Number
                    </div>
                    <div className="text-sm font-medium text-foreground">
                      {policyNumber}
                    </div>
                  </div>

                  <div className="flex gap-3">
                    <BjorkButton
                      onClick={handleCloseModule}
                      variant="outline"
                      className="flex-1"
                    >
                      Cancel
                    </BjorkButton>
                    <motion.div
                      className="flex-1"
                      whileHover={{ scale: 1.02 }}
                      whileTap={{ scale: 0.98 }}
                    >
                      <BjorkButton
                        onClick={handleConfirmUpdate}
                        variant="accent"
                        className="w-full"
                      >
                        Confirm Update
                      </BjorkButton>
                    </motion.div>
                  </div>
                </div>
              </motion.div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
    </BjorkCard>
  );
}
components/ui/avatar.tsx
"use client"

import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"

import { cn } from "@/lib/utils"

function Avatar({
  className,
  ...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
  return (
    <AvatarPrimitive.Root
      data-slot="avatar"
      className={cn(
        "relative flex size-8 shrink-0 overflow-hidden rounded-full",
        className
      )}
      {...props}
    />
  )
}

function AvatarImage({
  className,
  ...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
  return (
    <AvatarPrimitive.Image
      data-slot="avatar-image"
      className={cn("aspect-square size-full", className)}
      {...props}
    />
  )
}

function AvatarFallback({
  className,
  ...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
  return (
    <AvatarPrimitive.Fallback
      data-slot="avatar-fallback"
      className={cn(
        "bg-muted flex size-full items-center justify-center rounded-full",
        className
      )}
      {...props}
    />
  )
}

export { Avatar, AvatarImage, AvatarFallback }
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
components/ui/separator.tsx
"use client"

import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"

import { cn } from "@/lib/utils"

function Separator({
  className,
  orientation = "horizontal",
  decorative = true,
  ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
  return (
    <SeparatorPrimitive.Root
      data-slot="separator-root"
      decorative={decorative}
      orientation={orientation}
      className={cn(
        "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
        className
      )}
      {...props}
    />
  )
}

export { Separator }
components/bjork-ui/primitives/badge.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 bjorkBadgeVariants = cva(
  "inline-flex w-fit shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-[9px] border font-medium outline-none transition-[background-color,border-color,color,box-shadow,opacity,transform] duration-200 focus-visible:ring-2 focus-visible:ring-[color:var(--bjork-accent-muted)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bjork-ring-offset)] [&_svg]:size-3",
  {
    variants: {
      variant: {
        default:
          "border-transparent bg-[var(--bjork-text)] text-[color:var(--bjork-inverted-text)] shadow-[var(--bjork-shadow-soft)]",
        secondary:
          "border-transparent bg-[var(--bjork-surface-active)] text-[color:var(--bjork-text)] shadow-[var(--bjork-shadow-soft)]",
        outline:
          "border-[color:var(--bjork-border)] bg-[var(--bjork-field-inset)] text-[color:var(--bjork-text-medium)]",
        muted:
          "border-transparent bg-[var(--bjork-field-muted)] text-[color:var(--bjork-text-soft)]",
        accent:
          "border-[color:var(--bjork-accent-muted)] bg-[var(--bjork-accent-badge)] text-[color:var(--bjork-accent-badge-foreground)] shadow-[var(--bjork-shadow-soft)]",
      },
      size: {
        sm: "px-1.5 py-0.5 text-[11px]",
        md: "px-2 py-1 text-xs",
        lg: "px-2.5 py-1.5 text-sm",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "md",
    },
  },
);

export interface BjorkBadgeProps
  extends Omit<HTMLMotionProps<"span">, "ref">,
    VariantProps<typeof bjorkBadgeVariants> {}

export function BjorkBadge({
  className,
  variant,
  size,
  ...props
}: BjorkBadgeProps) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.span
      data-slot="bjork-badge"
      className={cn(bjorkBadgeVariants({ variant, size, className }))}
      whileHover={shouldReduceMotion ? undefined : { y: -1, opacity: 0.92 }}
      transition={{ type: "spring", stiffness: 420, damping: 30 }}
      {...props}
    />
  );
}

export { bjorkBadgeVariants };
components/bjork-ui/primitives/button.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 { useWebHaptics } from "web-haptics/react";
import type { HapticInput, TriggerOptions } from "web-haptics";
import { cn } from "@/lib/utils";

const bjorkButtonVariants = cva(
  "inline-flex shrink-0 transform-gpu items-center justify-center gap-2 whitespace-nowrap rounded-[13px] text-sm font-medium tracking-[-0.01em] outline-none backdrop-blur-md transition-[background-color,border-color,color,box-shadow,opacity] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-[#ec5c13]/45 focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bjork-ring-offset)] disabled:pointer-events-none disabled:opacity-35 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default:
          "border border-transparent bg-[var(--bjork-field)] text-[color:var(--bjork-text)] shadow-[var(--bjork-shadow-surface)] hover:bg-[var(--bjork-surface-hover)]",
        secondary:
          "border border-[color:var(--bjork-border)] bg-[var(--bjork-surface-active)] text-[color:var(--bjork-text)] shadow-[var(--bjork-shadow-soft)] hover:bg-[var(--bjork-surface-hover)]",
        outline:
          "border border-[color:var(--bjork-border)] bg-[var(--bjork-field-inset)] text-[color:var(--bjork-text-strong)] shadow-[var(--bjork-shadow-soft)] hover:border-[color:var(--bjork-border-strong)] hover:bg-[var(--bjork-surface)] hover:text-[color:var(--bjork-text)]",
        ghost:
          "border border-transparent bg-transparent text-[color:var(--bjork-text-muted)] hover:bg-[var(--bjork-surface-hover)] hover:text-[color:var(--bjork-text)]",
        quiet:
          "border border-transparent bg-[var(--bjork-field-muted)] text-[color:var(--bjork-text-soft)] hover:bg-[var(--bjork-surface-hover)] hover:text-[color:var(--bjork-text-medium)]",
        accent:
          "bjork-layered-button-accent tracking-tight text-white",
        raised:
          "bjork-layered-button-raised tracking-tight",
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-[45px] px-4",
        lg: "h-12 px-5 text-[15px]",
        icon: "size-[45px] p-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "md",
    },
  },
);

export interface BjorkButtonProps
  extends Omit<HTMLMotionProps<"button">, "ref" | "size">,
    VariantProps<typeof bjorkButtonVariants> {
  haptics?: boolean;
  hapticPattern?: HapticInput;
  hapticOptions?: TriggerOptions;
}

export function BjorkButton({
  className,
  variant,
  size,
  disabled,
  haptics = true,
  hapticPattern = "light",
  hapticOptions,
  onPointerDown,
  ...props
}: BjorkButtonProps) {
  const shouldReduceMotion = useReducedMotion();
  const { trigger } = useWebHaptics();

  const handlePointerDown = React.useCallback(
    (event: React.PointerEvent<HTMLButtonElement>) => {
      if (!disabled && haptics) {
        void trigger(hapticPattern, hapticOptions);
      }

      onPointerDown?.(event);
    },
    [disabled, hapticOptions, hapticPattern, haptics, onPointerDown, trigger],
  );

  return (
    <motion.button
      data-slot="bjork-button"
      className={cn(bjorkButtonVariants({ variant, size, className }))}
      disabled={disabled}
      onPointerDown={handlePointerDown}
      whileHover={disabled || shouldReduceMotion ? undefined : { scale: 1.05 }}
      whileTap={disabled || shouldReduceMotion ? undefined : { scale: 0.95 }}
      transition={{ type: "spring", stiffness: 400, damping: 25 }}
      {...props}
    />
  );
}

export { bjorkButtonVariants };
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 →