Pipeline Board New
An opportunities kanban with drag and drop between stages, column totals and weighted pipeline, and a celebration on Won.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/crm-pipeline-board.json02
Or copy the source below. Dependencies: lucide-react.
Source
components/niceui/crm/PipelineBoard.tsx
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
import { CalendarDays, GripVertical, PartyPopper, Plus } from 'lucide-react';
import { cn } from '@/lib/cn';
export type StageId = 'lead' | 'qualified' | 'proposal' | 'negotiation' | 'won';
export type Deal = { id: string; title: string; company: string; amount: number; close: string; owner: string; stage: StageId; prob: number };
export const PIPELINE_STAGES: { id: StageId; label: string; dot: string; prob: number }[] = [
{ id: 'lead', label: 'Lead', dot: 'bg-sky-400', prob: 10 },
{ id: 'qualified', label: 'Qualified', dot: 'bg-violet-400', prob: 30 },
{ id: 'proposal', label: 'Proposal', dot: 'bg-amber-400', prob: 55 },
{ id: 'negotiation', label: 'Negotiation', dot: 'bg-orange-400', prob: 75 },
{ id: 'won', label: 'Won', dot: 'bg-emerald-400', prob: 100 },
];
const MONO = ['bg-sky-500/20 text-sky-200', 'bg-violet-500/20 text-violet-200', 'bg-emerald-500/20 text-emerald-200', 'bg-amber-500/20 text-amber-200', 'bg-rose-500/20 text-rose-200', 'bg-teal-500/20 text-teal-200'];
const mono = (s: string) => MONO[[...s].reduce((a, c) => a + c.charCodeAt(0), 0) % MONO.length];
const compact = (n: number) => (n >= 1_000_000 ? `$${(n / 1_000_000).toFixed(1)}M` : `$${Math.round(n / 1000)}k`);
const CONFETTI = ['#38bdf8', '#34d399', '#fbbf24', '#fb7185', '#a78bfa', '#f0f9ff'];
type Press = { id: string; x: number; y: number; ox: number; oy: number; w: number; started: boolean };
type Drag = { id: string; x: number; y: number; over: StageId | null; index: number };
type Burst = { id: number; x: number; y: number; text: string };
export type PipelineBoardProps = {
initialDeals: Deal[];
onMove?: (deal: Deal, to: StageId) => void;
className?: string;
};
export function PipelineBoard({ initialDeals, onMove, className }: PipelineBoardProps) {
const [deals, setDeals] = useState(initialDeals);
const [drag, setDrag] = useState<Drag | null>(null);
const [burst, setBurst] = useState<Burst | null>(null);
const [announce, setAnnounce] = useState('');
const wrap = useRef<HTMLDivElement>(null);
const cols = useRef(new Map<StageId, HTMLDivElement>());
const press = useRef<Press | null>(null);
const reduce = useRef(false);
useEffect(() => {
reduce.current = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}, []);
useEffect(() => {
if (!burst) return;
const id = window.setTimeout(() => setBurst(null), 2600);
return () => clearTimeout(id);
}, [burst]);
const move = (id: string, to: StageId, index: number | null, at?: { x: number; y: number }) => {
const deal = deals.find((d) => d.id === id);
if (!deal) return;
const prob = PIPELINE_STAGES.find((s) => s.id === to)!.prob;
const moved = { ...deal, stage: to, prob: to === deal.stage ? deal.prob : prob };
setDeals((ds) => {
const rest = ds.filter((d) => d.id !== id);
const col = rest.filter((d) => d.stage === to);
const before = index === null ? undefined : col[index];
const pos = before ? rest.indexOf(before) : col.length ? rest.indexOf(col[col.length - 1]) + 1 : rest.length;
rest.splice(pos, 0, moved);
return rest;
});
if (to !== deal.stage) {
onMove?.(moved, to);
const label = PIPELINE_STAGES.find((s) => s.id === to)!.label;
setAnnounce(`Moved ${deal.title} to ${label}`);
if (to === 'won') {
const r = wrap.current?.getBoundingClientRect();
const x = at && r ? at.x - r.left : (r?.width ?? 600) - 120;
const y = at && r ? at.y - r.top : 120;
setBurst({ id: Date.now(), x, y, text: `${deal.company} closed · $${deal.amount.toLocaleString('en-US')}` });
}
}
};
const onDown = (e: PointerEvent<HTMLDivElement>, deal: Deal) => {
if (e.button !== 0) return;
if (e.pointerType !== 'mouse' && !(e.target as HTMLElement).closest('[data-handle]')) return;
const r = e.currentTarget.getBoundingClientRect();
press.current = { id: deal.id, x: e.clientX, y: e.clientY, ox: e.clientX - r.left, oy: e.clientY - r.top, w: r.width, started: false };
e.currentTarget.setPointerCapture(e.pointerId);
};
const onMovePointer = (e: PointerEvent<HTMLDivElement>) => {
const p = press.current;
if (!p) return;
if (!p.started && Math.hypot(e.clientX - p.x, e.clientY - p.y) < 5) return;
p.started = true;
let over: StageId | null = null;
let index = 0;
for (const [id, el] of cols.current) {
const r = el.getBoundingClientRect();
if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top - 40 && e.clientY <= r.bottom + 40) {
over = id;
const cards = [...el.querySelectorAll<HTMLElement>('[data-card]')].filter((c) => c.dataset.card !== p.id);
index = cards.findIndex((c) => {
const cr = c.getBoundingClientRect();
return e.clientY < cr.top + cr.height / 2;
});
if (index < 0) index = cards.length;
}
}
setDrag({ id: p.id, x: e.clientX, y: e.clientY, over, index });
};
const onUp = (e: PointerEvent<HTMLDivElement>) => {
const p = press.current;
press.current = null;
if (p?.started && drag?.over) move(p.id, drag.over, drag.index, { x: e.clientX, y: e.clientY });
setDrag(null);
};
const onKey = (e: KeyboardEvent<HTMLDivElement>, deal: Deal) => {
const i = PIPELINE_STAGES.findIndex((s) => s.id === deal.stage);
const next = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : -1;
if (next < 0 || next >= PIPELINE_STAGES.length || (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft')) return;
e.preventDefault();
move(deal.id, PIPELINE_STAGES[next].id, null);
requestAnimationFrame(() => document.querySelector<HTMLElement>(`[data-card="${deal.id}"]`)?.focus());
};
const dragged = drag ? deals.find((d) => d.id === drag.id) : null;
const wr = wrap.current?.getBoundingClientRect();
return (
<div ref={wrap} className={cn('relative w-full rounded-2xl bg-ink-900 shadow-card', className)}>
<style>{`@keyframes crm-confetti{0%{transform:translate(0,0) rotate(0);opacity:1}100%{transform:translate(var(--dx),var(--dy)) rotate(var(--r));opacity:0}}`}</style>
<div className="flex items-center justify-between gap-3 border-b border-white/[0.06] px-4 py-3">
<div className="min-w-0">
<p className="text-[13.5px] font-medium text-ink-50">Q4 pipeline</p>
<p className="text-[12px] text-ink-500">
<span className="font-mono tabular-nums text-ink-300">{compact(deals.filter((d) => d.stage !== 'won').reduce((s, d) => s + d.amount, 0))}</span> open ·{' '}
<span className="font-mono tabular-nums text-ink-300">{compact(deals.filter((d) => d.stage !== 'won').reduce((s, d) => s + (d.amount * d.prob) / 100, 0))}</span> weighted
</p>
</div>
<span className="hidden text-[11.5px] text-ink-500 sm:block">Drag cards, or focus one and press ← →</span>
</div>
<div className="overflow-x-auto [scrollbar-width:thin]">
<div className="flex gap-2 p-3">
{PIPELINE_STAGES.map((stage) => {
const list = deals.filter((d) => d.stage === stage.id);
const total = list.reduce((s, d) => s + d.amount, 0);
const isOver = drag?.over === stage.id;
return (
<div
key={stage.id}
ref={(el) => {
if (el) cols.current.set(stage.id, el);
else cols.current.delete(stage.id);
}}
className={cn('flex w-[200px] shrink-0 flex-col rounded-xl p-1.5 transition-colors duration-200 sm:w-auto sm:min-w-[150px] sm:flex-1', isOver ? 'bg-brand-500/[0.07] ring-1 ring-brand-400/30' : stage.id === 'won' ? 'bg-emerald-500/[0.04]' : 'bg-white/[0.02]')}
>
<div className="flex items-center justify-between px-1.5 pb-2 pt-1">
<span className="flex min-w-0 items-center gap-1.5 text-[12.5px] font-medium text-ink-100">
<span className={cn('h-2 w-2 shrink-0 rounded-full', stage.dot)} />
<span className="truncate">{stage.label}</span>
<span className="rounded-full bg-white/[0.06] px-1.5 font-mono text-[10.5px] text-ink-400">{list.length}</span>
</span>
<span className="font-mono text-[11px] tabular-nums text-ink-500">{compact(total)}</span>
</div>
<div className="flex min-h-[120px] flex-1 flex-col gap-1.5">
{list.map((d, i) => (
<div key={d.id}>
{isOver && drag?.index === i && <div className="mb-1.5 h-0.5 rounded-full bg-brand-400 shadow-[0_0_8px_rgba(56,189,248,0.8)]" />}
<DealTile deal={d} ghost={drag?.id === d.id} onPointerDown={(e) => onDown(e, d)} onPointerMove={onMovePointer} onPointerUp={onUp} onKeyDown={(e) => onKey(e, d)} />
</div>
))}
{isOver && drag && drag.index >= list.filter((d) => d.id !== drag.id).length && <div className="h-0.5 rounded-full bg-brand-400 shadow-[0_0_8px_rgba(56,189,248,0.8)]" />}
<button className="focus-ring mt-auto flex items-center gap-1 rounded-lg px-2 py-1.5 text-[11.5px] text-ink-500 transition-colors hover:bg-white/5 hover:text-ink-200">
<Plus className="h-3 w-3" /> New
</button>
</div>
</div>
);
})}
</div>
</div>
{drag && dragged && wr && drag.x !== 0 && (
<div className="pointer-events-none absolute z-50 rotate-2 scale-[1.03]" style={{ left: drag.x - wr.left - (press.current?.ox ?? 20), top: drag.y - wr.top - (press.current?.oy ?? 20), width: press.current?.w ?? 180 }}>
<DealTile deal={dragged} floating />
</div>
)}
{burst && (
<div key={burst.id} className="pointer-events-none absolute inset-0 z-50 overflow-hidden rounded-2xl">
{!reduce.current &&
Array.from({ length: 22 }, (_, i) => {
const a = (i / 22) * Math.PI * 2;
const d = 60 + ((i * 37) % 70);
return (
<span
key={i}
className="absolute h-1.5 w-1.5 rounded-[2px]"
style={{ left: burst.x, top: burst.y, background: CONFETTI[i % CONFETTI.length], animation: `crm-confetti 900ms cubic-bezier(.2,.7,.3,1) ${i * 8}ms forwards`, ['--dx' as string]: `${Math.cos(a) * d}px`, ['--dy' as string]: `${Math.sin(a) * d - 30}px`, ['--r' as string]: `${(i * 47) % 360}deg` }}
/>
);
})}
<div className="absolute inset-x-0 bottom-3 flex justify-center px-3">
<div className="flex items-center gap-2 rounded-xl bg-emerald-500/15 px-3 py-2 text-[12.5px] font-medium text-emerald-200 ring-1 ring-emerald-400/30 backdrop-blur animate-fade-up">
<PartyPopper className="h-4 w-4 shrink-0" /> <span className="truncate">{burst.text}</span>
</div>
</div>
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
type TileProps = {
deal: Deal;
ghost?: boolean;
floating?: boolean;
onPointerDown?: (e: PointerEvent<HTMLDivElement>) => void;
onPointerMove?: (e: PointerEvent<HTMLDivElement>) => void;
onPointerUp?: (e: PointerEvent<HTMLDivElement>) => void;
onKeyDown?: (e: KeyboardEvent<HTMLDivElement>) => void;
};
function DealTile({ deal, ghost, floating, ...handlers }: TileProps) {
const won = deal.stage === 'won';
return (
<div
data-card={floating ? undefined : deal.id}
tabIndex={floating ? -1 : 0}
role="button"
aria-label={`${deal.title}, ${deal.company}, $${deal.amount.toLocaleString('en-US')}. Press left or right arrow to change stage.`}
onPointerDown={handlers.onPointerDown}
onPointerMove={handlers.onPointerMove}
onPointerUp={handlers.onPointerUp}
onPointerCancel={handlers.onPointerUp}
onKeyDown={handlers.onKeyDown}
className={cn(
'focus-ring group relative cursor-grab select-none rounded-lg bg-ink-850 p-2.5 ring-1 ring-white/[0.06] transition-[opacity,box-shadow,background-color] duration-200 hover:bg-ink-800 hover:ring-white/10 active:cursor-grabbing',
ghost && 'opacity-30',
floating && 'shadow-pop ring-brand-400/40',
won && 'ring-emerald-400/20',
)}
>
<div className="flex items-center gap-1.5">
<span className={cn('grid h-5 w-5 shrink-0 place-items-center rounded-[5px] text-[9.5px] font-bold', mono(deal.company))}>{deal.company[0]}</span>
<span className="min-w-0 flex-1 truncate text-[11.5px] text-ink-400">{deal.company}</span>
<span data-handle className="touch-none rounded p-0.5 text-ink-600 transition-colors group-hover:text-ink-400" aria-hidden>
<GripVertical className="h-3.5 w-3.5" />
</span>
</div>
<p className="mt-1.5 truncate text-[12.5px] font-medium text-ink-50">{deal.title}</p>
<div className="mt-1.5 flex items-center justify-between gap-2">
<span className="font-mono text-[12.5px] tabular-nums text-ink-100">${deal.amount.toLocaleString('en-US')}</span>
<span className="flex items-center gap-1 text-[10.5px] text-ink-500">
<CalendarDays className="h-3 w-3" />
{deal.close}
</span>
</div>
<div className="mt-2 flex items-center gap-2">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-white/[0.06]">
<span className={cn('block h-full rounded-full transition-all duration-500', won ? 'bg-emerald-400' : 'bg-brand-400')} style={{ width: `${deal.prob}%` }} />
</span>
<span className="font-mono text-[10px] tabular-nums text-ink-500">{deal.prob}%</span>
<span className="grid h-5 w-5 place-items-center rounded-full bg-white/[0.07] text-[8.5px] font-semibold text-ink-200" title={`Owner ${deal.owner}`}>
{deal.owner}
</span>
</div>
</div>
);
}
export const DEMO_DEALS: Deal[] = [
{ id: 'd1', title: 'Team plan pilot', company: 'Verdant OS', amount: 24000, close: 'Dec 2', owner: 'AR', stage: 'lead', prob: 10 },
{ id: 'd2', title: 'Data residency add-on', company: 'Atlas Freight', amount: 15000, close: 'Dec 9', owner: 'LP', stage: 'lead', prob: 10 },
{ id: 'd3', title: 'EU expansion', company: 'Kestrel Labs', amount: 42000, close: 'Nov 28', owner: 'LP', stage: 'qualified', prob: 30 },
{ id: 'd4', title: 'Support upgrade', company: 'Northbeam', amount: 18000, close: 'Nov 21', owner: 'AR', stage: 'qualified', prob: 30 },
{ id: 'd5', title: 'Analytics suite', company: 'Tidewater AI', amount: 67000, close: 'Nov 19', owner: 'MS', stage: 'proposal', prob: 55 },
{ id: 'd6', title: 'Annual renewal', company: 'Harborline', amount: 56000, close: 'Nov 14', owner: 'MS', stage: 'proposal', prob: 55 },
{ id: 'd7', title: 'Platform rollout', company: 'Sorabase', amount: 120000, close: 'Nov 12', owner: 'LP', stage: 'negotiation', prob: 75 },
{ id: 'd8', title: 'SSO + audit logs', company: 'Brightmill', amount: 38000, close: 'Nov 10', owner: 'AR', stage: 'negotiation', prob: 75 },
{ id: 'd9', title: 'Enterprise seats', company: 'Lumenpay', amount: 84000, close: 'Oct 30', owner: 'AR', stage: 'won', prob: 100 },
];
export function PipelineBoardDemo() {
return (
<div className="w-full max-w-[1040px]">
<PipelineBoard initialDeals={DEMO_DEALS} />
</div>
);
}