Card New
A surface primitive with an optional cursor-follow spotlight and glowing border, driven by CSS variables.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/components-card.json02
Or copy the source below (3 files). Dependencies: lucide-react.
Source
components/niceui/ui/core/Card.tsx
import { useRef, type PointerEvent, type ReactNode } from 'react';
import { ArrowUpRight, Check, Github, MapPin, TrendingUp, Zap } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Badge } from './Badge';
import { Button } from './Button';
export type CardProps = {
children: ReactNode;
className?: string;
/** Enables the cursor-follow spotlight, hover lift and border glow. */
interactive?: boolean;
};
export function Card({ children, className, interactive = false }: CardProps) {
const ref = useRef<HTMLDivElement>(null);
const onMove = (e: PointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
el.style.setProperty('--x', `${e.clientX - r.left}px`);
el.style.setProperty('--y', `${e.clientY - r.top}px`);
};
return (
<div
ref={ref}
onPointerMove={interactive ? onMove : undefined}
className={cn(
'group/card relative overflow-hidden rounded-2xl bg-ink-900 shadow-card',
interactive && 'transition-[transform,box-shadow] duration-300 ease-smooth hover:-translate-y-0.5 hover:shadow-pop',
className,
)}
>
{interactive && (
<>
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover/card:opacity-100"
style={{ background: 'radial-gradient(420px circle at var(--x, 50%) var(--y, 50%), rgba(56,189,248,0.10), transparent 45%)' }}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-2xl opacity-0 transition-opacity duration-300 group-hover/card:opacity-100"
style={{
padding: 1,
background: 'radial-gradient(260px circle at var(--x, 50%) var(--y, 50%), rgba(89,182,255,0.55), transparent 60%)',
WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
}}
/>
</>
)}
<div className="relative">{children}</div>
</div>
);
}
const BARS = [32, 48, 40, 64, 52, 72, 58, 84, 70, 92, 78, 100];
export function CardDemo() {
return (
<div className="mx-auto grid w-full max-w-3xl grid-cols-1 gap-4 md:grid-cols-2">
<div className="animate-fade-up">
<Card interactive className="h-full p-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand-500/10 text-brand-300 ring-1 ring-inset ring-brand-500/25">
<Zap className="h-5 w-5" />
</div>
<h3 className="mt-5 text-base font-medium text-ink-50">Edge functions</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-400">
Run server code within 50ms of every user. Hover to see the spotlight follow your cursor.
</p>
<a href="#" onClick={(e) => e.preventDefault()} className="mt-5 inline-flex items-center gap-1 text-sm font-medium text-brand-300 hover:text-brand-200">
Read the docs <ArrowUpRight className="h-4 w-4 transition-transform group-hover/card:-translate-y-0.5 group-hover/card:translate-x-0.5" />
</a>
</Card>
</div>
<div className="animate-fade-up [animation-delay:80ms]">
<Card interactive className="h-full p-6">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-ink-400">Monthly revenue</p>
<p className="mt-1 text-3xl font-semibold tracking-tight text-ink-50 tabular-nums">$48,290</p>
</div>
<Badge tone="success">
<TrendingUp className="h-3 w-3" /> 12.4%
</Badge>
</div>
<div className="mt-6 flex h-20 items-end gap-1.5">
{BARS.map((h, i) => (
<div
key={i}
className="flex-1 origin-bottom rounded-sm bg-gradient-to-t from-brand-600/40 to-brand-400 transition-all duration-300 hover:to-brand-200"
style={{ height: `${h}%`, animation: `fade-up 500ms cubic-bezier(0.16,1,0.3,1) ${200 + i * 40}ms both` }}
/>
))}
</div>
</Card>
</div>
<div className="animate-fade-up [animation-delay:160ms]">
<Card interactive className="h-full p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-ink-100">Pro</p>
<Badge tone="brand">Most popular</Badge>
</div>
<p className="mt-3 flex items-baseline gap-1">
<span className="text-4xl font-semibold tracking-tight text-ink-50">$24</span>
<span className="text-sm text-ink-400">/ seat / month</span>
</p>
<ul className="mt-5 space-y-2.5 text-sm text-ink-300">
{['Unlimited projects', 'Preview deployments', 'Advanced analytics', 'Priority support'].map((f) => (
<li key={f} className="flex items-center gap-2.5">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-brand-500/15 text-brand-300">
<Check className="h-3 w-3" strokeWidth={3} />
</span>
{f}
</li>
))}
</ul>
<Button className="mt-6 w-full">Start 14-day trial</Button>
</Card>
</div>
<div className="animate-fade-up [animation-delay:240ms]">
<Card interactive className="h-full p-6">
<div className="flex items-center gap-4">
<img
src="https://images.pexels.com/photos/33680700/pexels-photo-33680700.jpeg?auto=compress&cs=tinysrgb&w=160&h=160&fit=crop"
alt="Maya Chen"
className="h-14 w-14 rounded-full object-cover ring-2 ring-ink-800"
/>
<div className="min-w-0">
<p className="truncate font-medium text-ink-50">Maya Chen</p>
<p className="truncate text-sm text-ink-400">Staff engineer · Platform</p>
<p className="mt-1 flex items-center gap-1 text-xs text-ink-500">
<MapPin className="h-3 w-3" /> Lisbon, PT
</p>
</div>
</div>
<div className="mt-5 grid grid-cols-3 divide-x divide-white/5 rounded-xl bg-ink-950/50 py-3 text-center shadow-hairline">
{[
['1.2k', 'Commits'],
['86', 'Reviews'],
['14', 'Repos'],
].map(([v, l]) => (
<div key={l}>
<p className="text-sm font-medium text-ink-50 tabular-nums">{v}</p>
<p className="text-xs text-ink-500">{l}</p>
</div>
))}
</div>
<div className="mt-4 flex gap-2">
<Button variant="secondary" size="sm" className="flex-1">
Follow
</Button>
<Button variant="outline" size="sm" leftIcon={<Github />} className="flex-1">
Profile
</Button>
</div>
</Card>
</div>
</div>
);
}components/niceui/ui/core/Badge.tsx
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { Bell, Minus, Plus, Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
export type BadgeTone = 'neutral' | 'brand' | 'success' | 'warning' | 'error';
const TONES: Record<BadgeTone, { badge: string; dot: string }> = {
neutral: { badge: 'bg-white/5 text-ink-200 ring-white/10', dot: 'bg-ink-400' },
brand: { badge: 'bg-brand-500/10 text-brand-300 ring-brand-500/25', dot: 'bg-brand-400' },
success: { badge: 'bg-emerald-500/10 text-emerald-300 ring-emerald-500/25', dot: 'bg-emerald-400' },
warning: { badge: 'bg-amber-500/10 text-amber-300 ring-amber-500/25', dot: 'bg-amber-400' },
error: { badge: 'bg-rose-500/10 text-rose-300 ring-rose-500/25', dot: 'bg-rose-400' },
};
export type BadgeProps = {
children: ReactNode;
tone?: BadgeTone;
dot?: boolean;
pulse?: boolean;
className?: string;
};
export function Badge({ children, tone = 'neutral', dot = false, pulse = false, className }: BadgeProps) {
const t = TONES[tone];
return (
<span
className={cn(
'inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-xs font-medium ring-1 ring-inset',
t.badge,
className,
)}
>
{(dot || pulse) && (
<span className="relative flex h-1.5 w-1.5">
{pulse && <span className={cn('absolute inset-0 animate-pulse-ring rounded-full', t.dot)} />}
<span className={cn('relative h-1.5 w-1.5 rounded-full', t.dot)} />
</span>
)}
{children}
</span>
);
}
function once(el: HTMLElement | null) {
if (!el || el.dataset.played) return null;
el.dataset.played = '1';
return el;
}
/** Numeric badge that rolls the digits vertically when the count changes. */
export function CountBadge({ count, max = 99, className }: { count: number; max?: number; className?: string }) {
const prev = useRef(count);
const [anim, setAnim] = useState<{ from: number; dir: 1 | -1; key: number } | null>(null);
useLayoutEffect(() => {
if (prev.current === count) return;
setAnim({ from: prev.current, dir: count > prev.current ? 1 : -1, key: Date.now() });
prev.current = count;
const t = window.setTimeout(() => setAnim(null), 400);
return () => window.clearTimeout(t);
}, [count]);
const fmt = (n: number) => (n > max ? `${max}+` : String(n));
if (count <= 0) return null;
return (
<span
className={cn(
'relative inline-flex h-5 min-w-[20px] animate-scale-in items-center justify-center overflow-hidden rounded-full bg-rose-500 px-1.5 text-[11px] font-semibold tabular-nums text-white shadow-[0_0_0_2px_#131316]',
className,
)}
>
{anim && (
<span
key={`o${anim.key}`}
className="absolute"
ref={(el) =>
once(el)?.animate([{ transform: 'translateY(0)', opacity: 1 }, { transform: `translateY(${-anim.dir * 100}%)`, opacity: 0 }], {
duration: 320,
easing: 'cubic-bezier(0.16,1,0.3,1)',
fill: 'forwards',
})
}
>
{fmt(anim.from)}
</span>
)}
<span
key={`n${anim?.key ?? 0}`}
ref={(el) =>
anim &&
once(el)?.animate([{ transform: `translateY(${anim.dir * 100}%)`, opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], {
duration: 380,
easing: 'cubic-bezier(0.34,1.56,0.64,1)',
})
}
>
{fmt(count)}
</span>
</span>
);
}
export function ShimmerBadge({ children = 'New', className }: { children?: ReactNode; className?: string }) {
return (
<span
className={cn(
'relative inline-flex h-6 items-center gap-1 overflow-hidden rounded-full bg-brand-500/10 px-2.5 text-xs font-medium text-brand-200 ring-1 ring-inset ring-brand-400/30',
className,
)}
>
<Sparkles className="h-3 w-3" />
{children}
<span
aria-hidden
className="absolute inset-0 animate-shimmer bg-[linear-gradient(110deg,transparent_35%,rgba(255,255,255,0.22)_50%,transparent_65%)] bg-[length:200%_100%]"
/>
</span>
);
}
export function AnimatedBadgeDemo() {
const [count, setCount] = useState(3);
const tones: BadgeTone[] = ['neutral', 'brand', 'success', 'warning', 'error'];
const labels = ['Draft', 'In review', 'Deployed', 'Degraded', 'Failed'];
return (
<div className="mx-auto flex w-full max-w-lg flex-col items-center gap-8">
<div className="flex flex-wrap justify-center gap-2">
{tones.map((tone, i) => (
<span key={tone} className="animate-fade-up" style={{ animationDelay: `${i * 50}ms` }}>
<Badge tone={tone} dot>
{labels[i]}
</Badge>
</span>
))}
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge tone="success" pulse>
All systems operational
</Badge>
<Badge tone="error" pulse>
Live
</Badge>
<ShimmerBadge />
</div>
<div className="flex items-center gap-4 rounded-2xl bg-ink-900 p-3 pr-4 shadow-card">
<div className="relative flex h-10 w-10 items-center justify-center rounded-xl bg-ink-850 text-ink-200 shadow-hairline">
<Bell className="h-4 w-4" />
<CountBadge count={count} className="absolute -right-1.5 -top-1.5" />
</div>
<div className="text-sm">
<p className="font-medium text-ink-100">Notifications</p>
<p className="text-xs text-ink-400">{count === 0 ? 'You’re all caught up' : `${count} unread`}</p>
</div>
<div className="ml-4 flex gap-1">
{[
{ icon: Minus, fn: () => setCount((c) => Math.max(0, c - 1)), label: 'Decrease' },
{ icon: Plus, fn: () => setCount((c) => c + 1), label: 'Increase' },
].map(({ icon: Icon, fn, label }) => (
<button
key={label}
onClick={fn}
aria-label={label}
className="focus-ring flex h-8 w-8 items-center justify-center rounded-lg text-ink-300 transition hover:bg-white/5 hover:text-ink-50 active:scale-90"
>
<Icon className="h-4 w-4" />
</button>
))}
</div>
</div>
</div>
);
}components/niceui/ui/core/Button.tsx
import { useState, type ButtonHTMLAttributes, type PointerEvent, type ReactNode } from 'react';
import { ArrowRight, Check, Download, Loader2, Plus, Rocket, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'outline' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
};
const VARIANTS: Record<ButtonVariant, string> = {
primary:
'bg-brand-600 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_1px_2px_rgba(0,0,0,0.4),0_4px_14px_-4px_rgba(27,119,245,0.6)] hover:bg-brand-500',
secondary: 'bg-ink-800 text-ink-50 shadow-hairline hover:bg-ink-700',
ghost: 'text-ink-300 hover:bg-white/5 hover:text-ink-50',
outline: 'text-ink-100 ring-1 ring-inset ring-white/10 hover:bg-white/[0.03] hover:ring-white/20',
danger: 'bg-rose-500/10 text-rose-300 ring-1 ring-inset ring-rose-500/25 hover:bg-rose-500/15 hover:text-rose-200',
};
const SIZES: Record<ButtonSize, string> = {
sm: 'h-8 gap-1.5 rounded-lg px-3 text-xs [&_svg]:h-3.5 [&_svg]:w-3.5',
md: 'h-9 gap-2 rounded-lg px-4 text-sm [&_svg]:h-4 [&_svg]:w-4',
lg: 'h-11 gap-2 rounded-xl px-5 text-sm [&_svg]:h-4 [&_svg]:w-4',
};
type Ripple = { id: number; x: number; y: number; size: number };
export function Button({
variant = 'primary',
size = 'md',
loading = false,
leftIcon,
rightIcon,
className,
children,
disabled,
onPointerDown,
type = 'button',
...rest
}: ButtonProps) {
const [ripples, setRipples] = useState<Ripple[]>([]);
const handlePointerDown = (e: PointerEvent<HTMLButtonElement>) => {
onPointerDown?.(e);
const rect = e.currentTarget.getBoundingClientRect();
const size = Math.max(rect.width, rect.height) * 2.2;
const id = Date.now() + Math.random();
setRipples((r) => [...r, { id, x: e.clientX - rect.left, y: e.clientY - rect.top, size }]);
window.setTimeout(() => setRipples((r) => r.filter((x) => x.id !== id)), 650);
};
return (
<button
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
onPointerDown={handlePointerDown}
className={cn(
'focus-ring relative inline-flex select-none items-center justify-center overflow-hidden whitespace-nowrap font-medium transition-all duration-200 ease-smooth active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50',
VARIANTS[variant],
SIZES[size],
className,
)}
{...rest}
>
{ripples.map((r) => (
<span
key={r.id}
aria-hidden
ref={(el) => {
if (el && !el.dataset.played) {
el.dataset.played = '1';
el.animate(
[
{ transform: 'translate(-50%,-50%) scale(0)', opacity: 0.35 },
{ transform: 'translate(-50%,-50%) scale(1)', opacity: 0 },
],
{ duration: 600, easing: 'cubic-bezier(0.16,1,0.3,1)', fill: 'forwards' },
);
}
}}
className={cn('pointer-events-none absolute rounded-full', variant === 'primary' ? 'bg-white/40' : 'bg-white/20')}
style={{ left: r.x, top: r.y, width: r.size, height: r.size, transform: 'translate(-50%,-50%) scale(0)' }}
/>
))}
{loading ? <Loader2 className="animate-spin" /> : leftIcon}
{children && <span className="relative">{children}</span>}
{rightIcon && !loading && <span className="relative inline-flex">{rightIcon}</span>}
</button>
);
}
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<span className="w-20 shrink-0 text-xs font-medium uppercase tracking-wider text-ink-500">{label}</span>
<div className="flex flex-wrap items-center gap-2.5">{children}</div>
</div>
);
}
export function ButtonDemo() {
const [state, setState] = useState<'idle' | 'loading' | 'done'>('idle');
const deploy = () => {
if (state !== 'idle') return;
setState('loading');
window.setTimeout(() => setState('done'), 1600);
window.setTimeout(() => setState('idle'), 3400);
};
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-6">
<Row label="Variants">
<Button>Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
</Row>
<Row label="Sizes">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</Row>
<Row label="Icons">
<Button variant="secondary" leftIcon={<Plus />}>New project</Button>
<Button variant="outline" leftIcon={<Download />}>Export</Button>
<Button variant="ghost" rightIcon={<ArrowRight />}>Continue</Button>
<Button variant="danger" leftIcon={<Trash2 />} aria-label="Delete" />
</Row>
<Row label="Loading">
<Button
onClick={deploy}
loading={state === 'loading'}
leftIcon={state === 'done' ? <Check className="animate-scale-in" /> : <Rocket />}
className={cn('min-w-[148px]', state === 'done' && '!bg-emerald-600')}
>
{state === 'loading' ? 'Deploying…' : state === 'done' ? 'Deployed' : 'Deploy to prod'}
</Button>
<Button variant="secondary" loading>
Saving
</Button>
</Row>
</div>
);
}