Deal Pipeline React component for Tailwind CSS v4
v1.0.1Updated in 1.9.0
A React sales pipeline board: deal stages with weighted totals, a forecast summary, drag between stages with a lost reason, a list view.
The summary on top reads the open pipeline, the weighted forecast, what was won this month and the win rate (a small ring); on a phone it is a 2 x 2 grid that folds to one line ("Pipeline $224,850 · weighted $118,065") with a tap on its chevron. Each stage is a column: its header shows the count and the probability, the line under it the total and the weighted total, and won and lost columns are tinted green and red. Drag a deal with the mouse (after 8px) or a long press on touch (250ms, with a haptic tick) to another stage: while it is lifted its probability bar already grows or shrinks to the stage under it, and on the drop the header totals fade to their new values. Drop it on Lost and a dialog (a bottom sheet on phones) asks why: arrows pick a reason, Enter confirms, the last reason opens a text field (Cmd / Ctrl + Enter confirms from it), Escape or Cancel sends the card back. A deal dropped on Won pops and shows a green check for a moment. While a move saves, the card shows a spinner; if the save fails, the card springs back and the reason is announced. With the keyboard every card is a Tab stop: Space lifts it, arrows move it (Left / Right change stage), Space drops, Escape cancels, Enter opens it, and Shift + F10 or the Menu key opens its ⋯ menu (Move to, Mark as won, Mark as lost and your own actions). The toolbar switches between Board and List (arrows move inside it), filters by owner with avatar chips that toggle (they scroll sideways on a phone) and hides the closed stages with a switch. The list view is a table grouped by stage, with a band per stage and the deal's value, probability, owner, close date and days in stage; rows open on click or Enter, and on a phone every row is a card. Deals past their close date turn red with a crossed calendar, and deals stuck longer than staleAfterDays in a stage get an amber "18 days in stage" chip. On a phone the board shows one stage at a time with the next one peeking and a chip row to jump between stages. Reduced motion drops the pop, the tilt and the growing bars and keeps short fades.
Deal Pipeline is a Pro React component in the React table and list components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add deal-pipeline, together with Avatar, Badge, Button, Dialog, Dropdown Menu and 20 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add deal-pipelineDeal Pipeline is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import DealPipeline from @/components/ui/deal-pipeline.
import { useState } from "react"
import { DealPipeline, type DealData, type DealStage } from "@/components/ui/deal-pipeline"
const stages: DealStage[] = [
{ id: "qualified", name: "Qualified", probability: 0.1 },
{ id: "proposal", name: "Proposal sent", probability: 0.5 },
{ id: "negotiation", name: "Negotiation", probability: 0.75 },
{ id: "won", name: "Won", probability: 1, kind: "won" },
{ id: "lost", name: "Lost", probability: 0, kind: "lost" },
]
export function B2BSales() {
const [deals, setDeals] = useState<DealData[]>([
{
id: "d01",
stageId: "proposal",
title: "CRM licenses, 25 users",
company: { name: "Tech Solutions Inc." },
amount: 18000,
owner: { id: "emma", name: "Emma Carter" },
expectedCloseDate: "2026-09-30",
},
{
id: "d02",
stageId: "negotiation",
title: "ERP integration",
company: { name: "Construct Group" },
amount: 103400,
owner: { id: "liam", name: "Liam Walker" },
expectedCloseDate: "2026-10-15",
},
])
return (
<div className="h-[640px]">
<DealPipeline
stages={stages}
value={deals}
onValueChange={(next) => setDeals(next)}
onDealMove={(move) =>
fetch(`/api/deals/${move.deal.id}`, {
method: "PATCH",
body: JSON.stringify({ stageId: move.to.id, lostReason: move.lostReason }),
}).then((response) => {
if (!response.ok) throw new Error("Not saved")
})
}
lostReasons={["Price", "Chose a competitor", "Not enough budget", "No decision", "Other reason"]}
persistKey="pipeline-sales"
locale="en-US"
labels={{ lostTitle: "Why did we lose this deal?" }}
/>
</div>
)
}Props
stages*DealStage[]The stages in order: { id, name, probability (0..1, clamped), kind? ("open" | "won" | "lost", default open), tone?, color?, limit? }. Won stages count 100% and are tinted green, lost stages 0% and tinted red; tone / color paint the column dot and the probability bars; limit is the kanban WIP limit.
valueDealData[]The deals in order (controlled): { id, stageId, title, company?: { name, logo? }, contact?, amount, currency?, probability? (overrides the stage's), owner?: { id?, name, avatar? }, expectedCloseDate? ("2026-09-30"), stageEnteredAt?, lastActivityAt?, nextStep?, tags?, priority?, lostReason?, href? }. A deal whose stageId does not exist is not shown (a development warning names it).
defaultValueDealData[]The starting deals while uncontrolled. Empty (the default) shows the first-run state.
onValueChange(deals: DealData[], move?: DealMove) => voidThe whole new array after a drop, a Move to, Mark as won / lost or an added deal. The moved deal carries its new stageId, stageEnteredAt (the moment of the move) and lostReason. move is undefined for added deals and for rollbacks after a failed save.
onDealMove(move: DealMove) => (void | boolean | Promise<unknown>)Runs for every move with { itemId, fromColumnId, toColumnId, fromIndex, toIndex, deal, from, to, lostReason? }. Return false and the card springs back; return a promise and the move is optimistic (the card shows a spinner) and a rejection puts it back and announces it. A drop on a lost stage calls it once the reason is picked.
onDealClick(deal: DealData) => voidA click, a tap or Enter on a card or a list row (not after a drag). Without it, a deal with href opens its link.
onAddDeal(stageId: string, title: string) => (DealData | Promise<DealData> | void)Shows + Add deal under every stage. Return the new deal (or a promise of it) and it lands at the end of the stage; a promise shows Add loading and keeps the text if it rejects. Also the action of the empty pipeline's "Add the first deal".
getDealActions(deal: DealData) => MenuEntry[]Your own entries in the card and row ⋯ menu, after Move to, Mark as won and Mark as lost (the dropdown-menu MenuEntry shape; a bottom sheet on phones).
renderCard(deal: DealData, state: { dragging: boolean; overlay: boolean }) => ReactNodeReplaces the default DealCard on the board; the board still wraps it in the draggable, focusable surface with the ⋯ menu at its top end. While a card is lifted, deal.stageId is already the stage under it. Build it from DealCardAmount, DealCardProbability, DealCardOwner, DealCardClose and DealCardStale.
lostReasonsstring[]The lost dialog's reasons, English by default (DEFAULT_LOST_REASONS: Price, Went with a competitor, No budget, No decision, Other). The last one opens a text field for a reason of your own.
requireLostReasondefaulttruebooleanMoving a deal into a lost stage opens the reason dialog first; cancelling it springs the card back. Off, the move commits at once without a reason.
view"board" | "list"The view (controlled); pair it with onViewChange.
defaultViewdefault"board""board" | "list"The starting view: the kanban board, or a table grouped by stage (cards on phones).
onViewChange(view: "board" | "list") => voidFires when the Board / List switch changes.
persistKeydefault""stringRemembers the view, the owner filter and the closed switch in localStorage under this key (key:view, key:owners, key:closed). Empty remembers nothing.
showSummarydefaulttruebooleanThe forecast tiles above the toolbar: open pipeline, weighted forecast, won this month and the win rate.
showWeighteddefaulttruebooleanWeighted totals (amount x probability) in the column headers, the list bands and the summary. Off, the average deal takes the weighted tile's place.
showClosedbooleanThe won and lost stages (controlled); pair it with onShowClosedChange. Their totals stay in the summary.
defaultShowCloseddefaulttruebooleanStarts with the won and lost stages shown; the toolbar's switch toggles them.
onShowClosedChange(show: boolean) => voidFires when the closed switch changes.
ownerFilterstring[]The owners shown (controlled), by owner.id or else the name; empty shows everyone. Pair it with onOwnerFilterChange.
defaultOwnerFilterstring[]The starting owner filter while uncontrolled; empty (the default) shows everyone. The summary follows the filter.
onOwnerFilterChange(owners: string[]) => voidFires when an owner chip or All is pressed.
staleAfterDaysdefault14numberDays in an open stage (from stageEnteredAt) before the card gets the amber "18 days in stage" chip; the list shows the days in the same amber badge. 0 never flags.
periodDateRangeWhat "won this month" and the win rate count, by the day a deal entered its won / lost stage (else its expected close date). Default: the month of now.
columnWidthdefault288numberStage width in px on wide boards. Narrow boards show one stage at 85% of the width with the next one peeking.
cardVariantdefault"surface""surface" | "outline"Cards as raised white (dark: #1A1A1A) panels, or outlined with no fill.
sizedefault"md""sm" | "md"Density: md has 12px card padding, 14px titles and 17px summary values; sm is 10px, 13px and 15px, with compact list rows.
compactAmountsdefaulttruebooleanColumn totals in compact notation ("$56.4K · weighted $5.6K"), and summary values from 1,000,000 ("$1.2M"). Cards and the list always show the full amount.
currencydefault"USD"stringISO 4217 code of the pipeline. Deals in another currency (deal.currency) keep their own on the card and are summed apart with a "+ 2 in EUR" note; nothing is converted.
readOnlydefaultfalsebooleanNo drag, no + Add deal, no Move to or Mark as won / lost; cards still open and your own actions stay.
loadingdefaultfalsebooleanSkeleton tiles in the summary and skeleton cards (or rows) in every stage; the toolbar stays usable.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The open stages' dots and probability bars, the pressed owner chips and the win rate ring. Neutral is black in light mode and white in dark mode; a stage's own tone or color wins.
toolbarActionsReactNodeButtons at the end of the toolbar, like "New deal".
nowDate | numberThe clock for days in stage, overdue dates, the default period and the moment stamped on moved deals. Default useNow(), which is unknown until hydration (those values wait for it).
localedefault""stringIntl locale for amounts, percentages and dates. Empty uses the UIProvider locale, then the browser's.
labelsPartial<DealPipelineLabels>Every string, merged over DEFAULT_DEAL_PIPELINE_LABELS: summary, open, weighted, won, winRate, averageDeal, noClosed, summaryLine(open, weighted), winRateHint(won, closed), weightedShort(amount), otherCurrency(count, currency, total), probability(percent), toolbar, view, board, list, owners, allOwners, ownerChip(name, count, formatted), showClosed, closeDate(date), overdue(date), stale(days, formatted), owner(name), saving, markWon, markLost, moveTo, actions(deal), sheetTitle, back, close, listCaption, column headers (columnDeal, columnCompany, columnValue, columnProbability, columnOwner, columnClose, columnDays), dealCount, days, noDeals, lostReason, the lost dialog (lostTitle, lostOther, lostOtherPlaceholder, lostConfirm, cancel), the empty state (emptyTitle, emptyDescription, addFirst), announcements (moved(deal, stage, weighted), wonAnnounce, lostAnnounce, refused, failed, cancelled) and kanban (the board's own KanbanLabels).
classNamesPartial<Record<"summary" | "stat" | "toolbar" | "view" | "owners" | "owner" | "closed" | "actions" | "board" | "nav" | "column" | "columnMeta" | "card" | "list" | "table" | "listCard" | "empty" | "lostDialog", string>>Extra classes for the inner parts. Full-bleed phone boards pass board: "-mx-4 px-4 scroll-px-4", nav: "-mx-4 px-4" and owners: "-me-4 pe-4".
classNamestringExtra classes for the root. The board fills the root's height: give the pipeline one (or flex-1 in a column) so stages scroll on their own.
Parts
DealCard
componentThe default card: title, company with its logo, amount with the probability and its 3px bar, the stale chip and tags, the next step, then the owner, priority and close date. Inside a pipeline it reads the stage, locale, currency and clock from it; on its own pass them. Props: { deal: DealData; stage?; size?; variant?; currency?; locale?; now?; staleAfterDays?; tone?; labels?; overlay?; classNames? }.
DealCardAmount
componentThe amount in the deal's currency ("$18,000"), tabular and never wrapping. Props: { deal: DealData; currency?; locale? } & span props.
DealCardProbability
componentThe 3px probability bar (a meter) in the stage's tone; it grows on a gentle spring from where it was when the deal changes stage. Props: { deal: DealData; stage?; tone? } & span props.
DealCardOwner
componentThe owner's 24px avatar and name (showName, default true). Props: { deal: DealData; showName?: boolean } & span props.
DealCardClose
componentThe expected close date ("30 sept.", full: "30.09.2026"); red with a crossed calendar and a spoken "Overdue since" once an open deal is past it. Props: { deal: DealData; full?: boolean; now? } & span props.
DealCardStale
componentThe amber "18 days in stage" badge, only while an open deal is stale. Props: { deal: DealData; staleAfterDays?; now? } & span props.
PipelineSummary
componentThe summary strip alone (a <dl> of tiles), for a dashboard: pass deals and stages, or stats you computed. Narrower than 672px it is a 2 x 2 grid, and narrower than 576px a chevron folds it to one line. Props: { deals?; stages?; stats?; period?; now?; currency?; locale?; tone?; size?; showWeighted?; compactAmounts?; staleAfterDays?; loading?; collapsible?; labels?; classNames? }.
computePipelineStats
functionThe pure math behind the summary and the headers, for servers and dashboards: { byStage: Record<stageId, { count, total, weighted, foreign }>, open, openCount, weighted, won, wonCount, lost, lostCount, winRate, averageDeal, stale, staleIds, foreign, hasWonStage }. Weighted = amount x (deal.probability ?? stage.probability) over open stages; won and lost count within the period; other currencies are summed apart. Signature: (deals: DealData[], stages: DealStage[], options?: { now?; period?; currency?; staleAfterDays? }) => PipelineStats.
DEFAULT_DEAL_PIPELINE_LABELS
constantThe English strings, to spread under a translation. Type: DealPipelineLabels.
DEFAULT_LOST_REASONS
constantThe English lost reasons: Price, Went with a competitor, No budget, No decision, Other. Type: string[].
DealStage
typeOne stage: id, name, probability (0 to 1), kind ("open" | "won" | "lost"), tone or color and limit.
DealData
typeOne deal: id, stageId, title, company, contact, amount, currency, probability (overrides the stage's), owner, expectedCloseDate, stageEnteredAt, lastActivityAt, nextStep, tags, priority, lostReason and href.
DealPerson
typeA deal owner: id, name and avatar.
DealMove
typeWhat onDealMove receives: the KanbanMoveEvent fields plus deal, the from and to stages and the lostReason.
PipelineStats
typeWhat computePipelineStats returns: byStage, open, openCount, weighted, won, lost, winRate, averageDeal, stale deals and open totals in other currencies.
Examples
List view grouped by stage
A table with a band per stage (count, total, weighted, probability); cards on phones.
One owner, closed hidden
Only Liam Walker's deals and no won / lost columns; the summary follows the filter.
Compact cards through renderCard
A one-line card from DealCardOwner, DealCardAmount and DealCardProbability.
EUR, no lost reason
currency="EUR", requireLostReason={false} and an info tone; a deal in dollars is flagged apart.
Loading, then an empty pipeline
Skeletons in the summary and the columns, then the first-run state that opens the composer.
Phone board
At 375px: the folding summary, scrolling owner chips, stage chips and one column with a peek.
Dependencies
Files
- components/ui/deal-pipeline.tsx
FAQ
Is the React Deal Pipeline component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Deal Pipeline?
Run npx wingo-ui@latest add deal-pipeline. It also adds the Wingo UI items it builds on (Avatar, Badge, Button, Dialog, Dropdown Menu and 20 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/deal-pipeline.
How does Deal Pipeline behave on phones?
The summary on top reads the open pipeline, the weighted forecast, what was won this month and the win rate (a small ring); on a phone it is a 2 x 2 grid that folds to one line ("Pipeline $224,850 · weighted $118,065") with a tap on its chevron.
See also
Used in
More React table and list components
Articles using this component
- React CRM Template: Pipeline, Forecast, Company Pages
Build it: screens and apps11 min read
- React Kanban Board: Drag, WIP Limits, Phone Layout
Component guides9 min read
- dnd kit Mobile: Touch Drag That Lets the Page Scroll
Component guides11 min read