# Deal Pipeline

> A React sales pipeline board: deal stages with weighted totals, a forecast summary, drag between stages with a lost reason, a list view.

- Type: React component (Tables & Lists)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.1
- Install: `npx wingo-ui@latest add deal-pipeline`
- Docs: https://wingo-ui.com/components/deal-pipeline

## Interaction

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.

## Installation

```bash
npx wingo-ui@latest add deal-pipeline
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/deal-pipeline` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/deal-pipeline.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Dialog](https://wingo-ui.com/components/dialog) ([Markdown](https://wingo-ui.com/components/dialog.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Kanban](https://wingo-ui.com/components/kanban) ([Markdown](https://wingo-ui.com/components/kanban.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Radio Group](https://wingo-ui.com/components/radio-group) ([Markdown](https://wingo-ui.com/components/radio-group.md))
- [Segmented Control](https://wingo-ui.com/components/segmented-control) ([Markdown](https://wingo-ui.com/components/segmented-control.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [Switch](https://wingo-ui.com/components/switch) ([Markdown](https://wingo-ui.com/components/switch.md))
- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [Commerce](https://wingo-ui.com/components/commerce) ([Markdown](https://wingo-ui.com/components/commerce.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `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. |
| `value?` | `DealData[]` |  | 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). |
| `defaultValue?` | `DealData[]` |  | The starting deals while uncontrolled. Empty (the default) shows the first-run state. |
| `onValueChange?` | `(deals: DealData[], move?: DealMove) => void` |  | The 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) => void` |  | A 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 }) => ReactNode` |  | Replaces 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. |
| `lostReasons?` | `string[]` |  | 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. |
| `requireLostReason?` | `boolean` | `true` | Moving 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. |
| `defaultView?` | `"board" \| "list"` | `"board"` | The starting view: the kanban board, or a table grouped by stage (cards on phones). |
| `onViewChange?` | `(view: "board" \| "list") => void` |  | Fires when the Board / List switch changes. |
| `persistKey?` | `string` | `""` | Remembers the view, the owner filter and the closed switch in localStorage under this key (key:view, key:owners, key:closed). Empty remembers nothing. |
| `showSummary?` | `boolean` | `true` | The forecast tiles above the toolbar: open pipeline, weighted forecast, won this month and the win rate. |
| `showWeighted?` | `boolean` | `true` | Weighted totals (amount x probability) in the column headers, the list bands and the summary. Off, the average deal takes the weighted tile's place. |
| `showClosed?` | `boolean` |  | The won and lost stages (controlled); pair it with onShowClosedChange. Their totals stay in the summary. |
| `defaultShowClosed?` | `boolean` | `true` | Starts with the won and lost stages shown; the toolbar's switch toggles them. |
| `onShowClosedChange?` | `(show: boolean) => void` |  | Fires when the closed switch changes. |
| `ownerFilter?` | `string[]` |  | The owners shown (controlled), by owner.id or else the name; empty shows everyone. Pair it with onOwnerFilterChange. |
| `defaultOwnerFilter?` | `string[]` |  | The starting owner filter while uncontrolled; empty (the default) shows everyone. The summary follows the filter. |
| `onOwnerFilterChange?` | `(owners: string[]) => void` |  | Fires when an owner chip or All is pressed. |
| `staleAfterDays?` | `number` | `14` | Days 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. |
| `period?` | `DateRange` |  | What "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. |
| `columnWidth?` | `number` | `288` | Stage width in px on wide boards. Narrow boards show one stage at 85% of the width with the next one peeking. |
| `cardVariant?` | `"surface" \| "outline"` | `"surface"` | Cards as raised white (dark: #1A1A1A) panels, or outlined with no fill. |
| `size?` | `"sm" \| "md"` | `"md"` | Density: md has 12px card padding, 14px titles and 17px summary values; sm is 10px, 13px and 15px, with compact list rows. |
| `compactAmounts?` | `boolean` | `true` | Column 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. |
| `currency?` | `string` | `"USD"` | ISO 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. |
| `readOnly?` | `boolean` | `false` | No drag, no + Add deal, no Move to or Mark as won / lost; cards still open and your own actions stay. |
| `loading?` | `boolean` | `false` | Skeleton tiles in the summary and skeleton cards (or rows) in every stage; the toolbar stays usable. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | 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. |
| `toolbarActions?` | `ReactNode` |  | Buttons at the end of the toolbar, like "New deal". |
| `now?` | `Date \| number` |  | The 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). |
| `locale?` | `string` | `""` | Intl locale for amounts, percentages and dates. Empty uses the UIProvider locale, then the browser's. |
| `labels?` | `Partial<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). |
| `classNames?` | `Partial<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". |
| `className?` | `string` |  | Extra 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 (component)

The 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 (component)

The amount in the deal's currency ("$18,000"), tabular and never wrapping. Props: { deal: DealData; currency?; locale? } & span props.

### DealCardProbability (component)

The 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 (component)

The owner's 24px avatar and name (showName, default true). Props: { deal: DealData; showName?: boolean } & span props.

### DealCardClose (component)

The 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 (component)

The amber "18 days in stage" badge, only while an open deal is stale. Props: { deal: DealData; staleAfterDays?; now? } & span props.

### PipelineSummary (component)

The 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 (function)

The 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 (constant)

The English strings, to spread under a translation. Type: DealPipelineLabels.

### DEFAULT_LOST_REASONS (constant)

The English lost reasons: Price, Went with a competitor, No budget, No decision, Other. Type: string[].

### DealStage (type)

One stage: id, name, probability (0 to 1), kind ("open" | "won" | "lost"), tone or color and limit.

### DealData (type)

One deal: id, stageId, title, company, contact, amount, currency, probability (overrides the stage's), owner, expectedCloseDate, stageEnteredAt, lastActivityAt, nextStep, tags, priority, lostReason and href.

### DealPerson (type)

A deal owner: id, name and avatar.

### DealMove (type)

What onDealMove receives: the KanbanMoveEvent fields plus deal, the from and to stages and the lostReason.

### PipelineStats (type)

What 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.

## See also

- [Kanban](https://wingo-ui.com/components/kanban) ([Markdown](https://wingo-ui.com/components/kanban.md))
- [CRM Pipeline Page](https://wingo-ui.com/components/crm-pipeline-page) ([Markdown](https://wingo-ui.com/components/crm-pipeline-page.md))
- [Stat Card](https://wingo-ui.com/components/stat-card) ([Markdown](https://wingo-ui.com/components/stat-card.md))
- [Funnel chart](https://wingo-ui.com/components/funnel-chart) ([Markdown](https://wingo-ui.com/components/funnel-chart.md))
- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))

---

Canonical: https://wingo-ui.com/components/deal-pipeline
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
