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 · mediumui-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 };