bjork-ui
componentprediction-market-cardsource: ok · hit
Prediction market card
Prediction market card is a production-ready cards component from BJORK UI, packaged for shadcn registries.
npx shadcn add "https://ui.isaiahbjork.com/prediction-market-card.json"free
No selection tags yet — 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, 4:05:08 PM
components/bjork-ui/cards/prediction-market-card.tsx
"use client";
import React, { useState, useEffect } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import { Flame, MessageCircle } from 'lucide-react';
import { BjorkBadge } from '@/components/bjork-ui/primitives/badge';
import { BjorkButton } from '@/components/bjork-ui/primitives/button';
import { BjorkCard } from '@/components/bjork-ui/primitives/card';
import { cn } from '@/lib/utils';
interface PredictionMarketCardProps {
question?: string;
teamLogo?: string;
teamName?: string;
initialTimeInSeconds?: number;
totalBank?: number;
yesBank?: number;
noBank?: number;
initialYesVotes?: number;
initialNoVotes?: number;
yesPlayers?: number;
noPlayers?: number;
onBetYes?: () => void;
onBetNo?: () => void;
enableAnimations?: boolean;
}
export function PredictionMarketCard({
question = "Will Bayern win a corner in the next 3 minutes?",
teamLogo = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/8d/FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg/1200px-FC_Bayern_M%C3%BCnchen_logo_%282024%29.svg.png",
teamName = "Bayern",
initialTimeInSeconds = 60,
totalBank = 145450,
yesBank = 64280,
noBank = 81170,
initialYesVotes = 42,
initialNoVotes = 58,
yesPlayers = 1457,
noPlayers = 1899,
onBetYes,
onBetNo,
enableAnimations = true,
}: PredictionMarketCardProps) {
const [timeLeft, setTimeLeft] = useState(initialTimeInSeconds);
const [yesVotes, setYesVotes] = useState(initialYesVotes);
const [noVotes, setNoVotes] = useState(initialNoVotes);
const [showBettingView, setShowBettingView] = useState(false);
const [betType, setBetType] = useState<'yes' | 'no'>('yes');
const [betAmount, setBetAmount] = useState('');
const shouldReduceMotion = useReducedMotion();
// Timer countdown
useEffect(() => {
if (timeLeft <= 0) return;
const timer = setInterval(() => {
setTimeLeft((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [timeLeft]);
// Format time as MM:SS
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
// Format currency
const formatCurrency = (amount: number) => {
if (amount >= 1000) {
return `$${(amount / 1000).toFixed(2)}K`;
}
return `$${amount}`;
};
const handleBetYes = () => {
setBetType('yes');
setShowBettingView(true);
};
const handleBetNo = () => {
setBetType('no');
setShowBettingView(true);
};
const handleConfirmBet = () => {
const adjustment = Math.floor(Math.random() * 3) + 1; // Random 1-3% change
if (betType === 'yes') {
setYesVotes(prev => Math.min(95, prev + adjustment));
setNoVotes(prev => Math.max(5, prev - adjustment));
onBetYes?.();
} else {
setNoVotes(prev => Math.min(95, prev + adjustment));
setYesVotes(prev => Math.max(5, prev - adjustment));
onBetNo?.();
}
setShowBettingView(false);
setBetAmount('');
};
const handleBackToMain = () => {
setShowBettingView(false);
setBetAmount('');
};
const handleQuickAmount = (amount: number) => {
setBetAmount(amount.toString());
};
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^0-9]/g, '');
setBetAmount(value);
};
const totalVotes = yesVotes + noVotes;
const progressValue = (yesVotes / totalVotes) * 100;
const timerProgress = (timeLeft / initialTimeInSeconds) * 100; // Reversed: starts at 100%, goes to 0%
const shouldAnimate = enableAnimations && !shouldReduceMotion;
// 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,
},
},
};
const badgeVariants = {
hidden: { scale: 0, opacity: 0, filter: "blur(2px)" },
visible: {
scale: 1,
opacity: 1,
filter: "blur(0px)",
transition: {
type: "spring",
stiffness: 500,
damping: 25,
mass: 0.5,
},
},
};
const progressBarVariants = {
hidden: { scaleX: 0, opacity: 0 },
visible: {
scaleX: 1,
opacity: 1,
transition: {
type: "spring",
stiffness: 300,
damping: 30,
delay: 0.4,
},
},
};
const buttonContainerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
delayChildren: 0.5,
},
},
};
return (
<BjorkCard
variant="elevated"
padding="none"
className="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)]"
initial={shouldAnimate ? "hidden" : "visible"}
animate="visible"
variants={shouldAnimate ? containerVariants : {}}
>
<div className="relative h-auto">
{/* Main Content */}
<motion.div
initial={false}
animate={{
y: showBettingView ? "-20px" : "0px",
opacity: showBettingView ? 0.3 : 1,
scale: showBettingView ? 0.95 : 1
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8
}}
className="w-full"
>
<div className="p-3 space-y-3">
{/* Header with badges and timer */}
<motion.div
className="flex items-center justify-between"
variants={shouldAnimate ? itemVariants : {}}
>
<div className="flex items-center gap-2">
<motion.div variants={shouldAnimate ? badgeVariants : {}}>
<BjorkBadge variant="accent">
<Flame className="w-3 h-3 mr-1" />
HOT BET
</BjorkBadge>
</motion.div>
<motion.div variants={shouldAnimate ? badgeVariants : {}}>
<BjorkBadge variant="outline" className="border-[#d8d3c7] bg-[#eee9df] text-[#171717]/60 dark:border-[#232323] dark:bg-[#090909] dark:text-[#ededed]/55">
<MessageCircle className="w-3 h-3 mr-1" />
SOCCER
</BjorkBadge>
</motion.div>
</div>
<motion.div variants={shouldAnimate ? badgeVariants : {}}>
<BjorkBadge className="border-[#ec5c13]/24 bg-[#ec5c13]/12 font-mono text-[#bd4514] dark:text-[#d86a2c]">
{formatTime(timeLeft)}
</BjorkBadge>
</motion.div>
</motion.div>
{/* Team info and question */}
<motion.div
className="flex items-start justify-center gap-3 pt-2"
variants={shouldAnimate ? itemVariants : {}}
>
<motion.div variants={shouldAnimate ? badgeVariants : {}}>
<Avatar className="w-10 h-10">
<AvatarImage src={teamLogo} alt={teamName} />
<AvatarFallback className="bg-[#ec5c13] text-[#080808]">
{teamName.charAt(0)}
</AvatarFallback>
</Avatar>
</motion.div>
<motion.div variants={shouldAnimate ? itemVariants : {}}>
<h3 className="text-lg font-semibold leading-tight text-foreground">
{question}
</h3>
</motion.div>
</motion.div>
{/* Gradient separator */}
<motion.div
className="relative py-2"
variants={shouldAnimate ? itemVariants : {}}
>
<Separator className="bg-gradient-to-r from-transparent dark:via-gray-500 via-gray-300 to-transparent" />
</motion.div>
{/* Bank information */}
<motion.div
className="flex justify-between items-center"
variants={shouldAnimate ? itemVariants : {}}
>
<div className="text-left">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Total Bank</p>
<p className="text-xl font-bold text-[#bd4514] dark:text-[#d86a2c]">
{formatCurrency(totalBank)}
</p>
</div>
<div className="text-left">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Bank <span className="font-bold">YES</span></p>
<p className="text-xl font-bold text-[#7c8f5d] dark:text-[#a9bc82]">
{formatCurrency(yesBank)}
</p>
</div>
<div className="text-left">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Bank <span className="font-bold">NO</span></p>
<p className="text-xl font-bold text-[#b45f50] dark:text-[#d58473]">
{formatCurrency(noBank)}
</p>
</div>
</motion.div>
<motion.div
className="relative py-2"
variants={shouldAnimate ? itemVariants : {}}
>
<Separator className="bg-gradient-to-r from-transparent dark:via-gray-500 via-gray-300 to-transparent" />
</motion.div>
{/* Voting statistics */}
<motion.div
className="space-y-2"
variants={shouldAnimate ? itemVariants : {}}
>
<div className="flex items-center justify-between">
<div className="text-left">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Voted <span className="font-bold">YES</span></p>
<p className="text-2xl font-bold text-[#7c8f5d] dark:text-[#a9bc82]">{yesVotes}%</p>
</div>
<div className="text-right">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Voted <span className="font-bold">NO</span></p>
<p className="text-2xl font-bold text-[#b45f50] dark:text-[#d58473]">{noVotes}%</p>
</div>
</div>
{/* Progress bar */}
<motion.div
className="relative h-3 overflow-hidden rounded-md bg-[#b45f50]"
variants={shouldAnimate ? progressBarVariants : {}}
>
<div
className="absolute left-0 top-0 h-full bg-[#7c8f5d] transition-all duration-500 ease-out"
style={{ width: `${progressValue}%` }}
/>
{/* Diagonal separator */}
<div
className="absolute top-0 h-full w-2 origin-bottom rotate-12 transform bg-[#f4f1e9] dark:bg-[#121212]"
style={{
left: `${progressValue}%`,
transform: `translateX(-100%) skewX(-15deg)`,
height: '150%',
top: '-25%'
}}
/>
</motion.div>
{/* Player counts */}
<motion.div
className="flex items-center justify-between text-sm text-muted-foreground"
variants={shouldAnimate ? itemVariants : {}}
>
<span>{yesPlayers.toLocaleString()} PLAYERS</span>
<span>{noPlayers.toLocaleString()} PLAYERS</span>
</motion.div>
</motion.div>
{/* Betting buttons */}
<motion.div
className="grid grid-cols-2 gap-3 pt-1"
variants={shouldAnimate ? buttonContainerVariants : {}}
>
<motion.div
variants={shouldAnimate ? itemVariants : {}}
whileHover={{ scale: 1.02, y: -1 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<BjorkButton
onClick={handleBetYes}
className="group relative w-full cursor-pointer overflow-hidden rounded-xl border border-[#7c8f5d]/35 bg-[#7c8f5d] py-3 font-semibold text-[#080808] transition-all duration-300 hover:bg-[#8fa36b]"
>
<span className="relative z-10">BET YES ↗</span>
{/* Gradient shine effect */}
<div className="absolute inset-0 translate-x-[-100%] bg-gradient-to-r from-transparent via-white/18 to-transparent transition-transform duration-700 ease-out group-hover:translate-x-[100%]" />
{/* Subtle inner glow */}
<div className="absolute inset-0 bg-gradient-to-t from-[#5f7144]/30 to-white/10 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
</BjorkButton>
</motion.div>
<motion.div
variants={shouldAnimate ? itemVariants : {}}
whileHover={{ scale: 1.02, y: -1 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
>
<BjorkButton
onClick={handleBetNo}
className="group relative w-full cursor-pointer overflow-hidden rounded-xl border border-[#b45f50]/35 bg-[#b45f50] py-3 font-semibold text-white transition-all duration-300 hover:bg-[#c66d5d]"
>
<span className="relative z-10">BET NO ↘</span>
{/* Gradient shine effect */}
<div className="absolute inset-0 translate-x-[-100%] bg-gradient-to-r from-transparent via-white/18 to-transparent transition-transform duration-700 ease-out group-hover:translate-x-[100%]" />
{/* Subtle inner glow */}
<div className="absolute inset-0 bg-gradient-to-t from-[#854338]/30 to-white/10 opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
</BjorkButton>
</motion.div>
</motion.div>
</div>
</motion.div>
{/* Betting View */}
<motion.div
initial={false}
animate={{
y: showBettingView ? "0%" : "100%",
opacity: showBettingView ? 1 : 0
}}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
mass: 0.8
}}
className="absolute left-0 top-0 h-full w-full bg-[#f4f1e9] dark:bg-[#121212]"
>
<div className="p-3 space-y-4">
{/* Header with back button */}
<div className="flex items-center justify-between">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={handleBackToMain}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">Back</span>
</motion.button>
<BjorkBadge className="border-[#ec5c13]/24 bg-[#ec5c13]/12 font-mono text-[#bd4514] dark:text-[#d86a2c]">
{formatTime(timeLeft)}
</BjorkBadge>
</div>
{/* Question header */}
<div className="flex items-center justify-center gap-3">
<Avatar className="w-8 h-8">
<AvatarImage src={teamLogo} alt={teamName} />
<AvatarFallback className="bg-[#ec5c13] text-[#080808] text-xs">
{teamName.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<h3 className="text-sm font-semibold leading-tight text-foreground">
{question}
</h3>
</div>
</div>
{/* Bet type indicator */}
<div className="text-center">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-2">YOUR BET</p>
<div className={`inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm font-semibold ${
betType === 'yes'
? 'border border-[#7c8f5d]/30 bg-[#7c8f5d]/18 text-[#5f7144] dark:text-[#a9bc82]'
: 'border border-[#b45f50]/30 bg-[#b45f50]/18 text-[#9b4f43] dark:text-[#d58473]'
}`}>
{betType === 'yes' ? 'YES ↗' : 'NO ↘'}
</div>
</div>
{/* Amount input */}
<div className="space-y-3">
<div className="relative">
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 text-2xl font-bold text-muted-foreground">
$
</div>
<input
type="text"
value={betAmount}
onChange={handleAmountChange}
placeholder=""
className="w-full bg-transparent border border-border/50 rounded-xl pl-12 pr-4 py-4 text-xl font-semibold text-foreground placeholder-muted-foreground focus:outline-none focus:border-border transition-colors"
/>
</div>
{/* Quick amount buttons */}
<div className="grid grid-cols-4 gap-2">
{[10, 20, 50, 100].map((amount) => (
<motion.button
key={amount}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => handleQuickAmount(amount)}
className="rounded-lg border border-[#d8d3c7] bg-[#eee9df] py-2 text-sm font-medium text-[#171717]/55 transition-all duration-200 hover:bg-[#e7e1d5] dark:border-[#232323] dark:bg-[#090909] dark:text-[#ededed]/45 dark:hover:bg-[#141414]"
>
+${amount}
</motion.button>
))}
</div>
</div>
{/* Confirm button */}
<motion.button
whileHover={betAmount ? { scale: 1.02, y: -1 } : {}}
whileTap={betAmount ? { scale: 0.98 } : {}}
onClick={handleConfirmBet}
disabled={!betAmount}
className={`w-full relative overflow-hidden py-3 rounded-full font-semibold transition-all duration-300 ${
betAmount
? `${betType === 'yes' ? 'border-[#7c8f5d]/35 bg-[#7c8f5d] hover:bg-[#8fa36b] text-[#080808]' : 'border-[#b45f50]/35 bg-[#b45f50] hover:bg-[#c66d5d] text-white'} border cursor-pointer group`
: 'cursor-not-allowed border border-[#d8d3c7] bg-[#eee9df] text-[#171717]/38 dark:border-[#232323] dark:bg-[#090909] dark:text-[#ededed]/30'
}`}
>
<span className="relative z-10 flex items-center justify-center gap-2">
CONFIRM BET
{betAmount && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
)}
</span>
{betAmount && (
<>
{/* Gradient shine effect */}
<div className="absolute inset-0 translate-x-[-100%] bg-gradient-to-r from-transparent via-white/18 to-transparent transition-transform duration-700 ease-out group-hover:translate-x-[100%]" />
{/* Subtle inner glow */}
<div className={cn("absolute inset-0 bg-gradient-to-t opacity-0 transition-opacity duration-300 group-hover:opacity-100", betType === 'yes' ? 'from-[#5f7144]/30 to-white/10' : 'from-[#854338]/30 to-white/10')} />
</>
)}
</motion.button>
</div>
</motion.div>
</div>
{/* Timer progress bar - stays at bottom */}
<motion.div
className="px-0 pt-2"
variants={shouldAnimate ? progressBarVariants : {}}
>
<div className="h-1 dark:bg-gray-700 bg-gray-300 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[#bd4514] to-[#ec5c13] transition-all duration-1000 ease-linear"
style={{ width: `${timerProgress}%` }}
/>
</div>
</motion.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 };