diff --git a/.changeset/add-document-progress-card.md b/.changeset/add-document-progress-card.md
new file mode 100644
index 00000000..4c45781d
--- /dev/null
+++ b/.changeset/add-document-progress-card.md
@@ -0,0 +1,21 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+Add `DocumentProgressCard` — a generic, presentational card for a document's lifecycle/fulfilment state: an optional headline percentage, a stacked progress bar, and a status legend driven by arbitrary `segments`. It's domain-agnostic, so it stretches to any status breakdown (shipped / cancelled / pending, PO received / returned / yet-to-receive, etc.).
+
+```tsx
+import { DocumentProgressCard } from "@tailor-platform/app-shell";
+
+;
+```
+
+Each segment is `{ label, value, color }`. Pass an explicit `percent`, an optional `total` (leave a value larger than the segment sum to render an unfilled track remainder), and an optional `legend` override for when the legend should differ from the bar (e.g. overlapping buckets). Percentage and any domain-specific breakdown are derived in the consumer — see the docs for a purchase-order fulfilment recipe.
diff --git a/catalogue/src/fundamental/components.md b/catalogue/src/fundamental/components.md
index 8b5e935c..a3070bdb 100644
--- a/catalogue/src/fundamental/components.md
+++ b/catalogue/src/fundamental/components.md
@@ -424,6 +424,27 @@ const table = useDataTable({
**Used in patterns:** KPI tiles, dashboards, **`detail/*`** metric strips where specs call for them.
+### `DocumentProgressCard`
+
+**Import:** `import { DocumentProgressCard } from '@tailor-platform/app-shell'`
+**Purpose:** Generic document lifecycle/fulfilment state — optional percentage, stacked progress bar, status legend; arbitrary `segments`.
+**API:** `DocumentProgressCardProps` — `segments` (`{ label, value, color? }[]`), `title?`, `percent?`, `legend?` (defaults to `segments`), `total?` (bar denominator; larger than the sum leaves an empty track), `className?`. View-only — `percent` is explicit.
+**Example:**
+
+```tsx
+
+```
+
+**Used in patterns:** **`detail/*`** right-rail cards for arbitrary status breakdowns (shipped/cancelled/pending, PO received/returned/yet-to-receive, etc.). For the purchase-order fulfilment recipe (derived %, net-received/returned bar split, legend override), see `docs/components/document-progress-card.md`.
+
### `Avatar`
**Import:** `import { Avatar } from '@tailor-platform/app-shell'`
diff --git a/docs/components/document-progress-card.md b/docs/components/document-progress-card.md
new file mode 100644
index 00000000..ec148a17
--- /dev/null
+++ b/docs/components/document-progress-card.md
@@ -0,0 +1,137 @@
+---
+title: DocumentProgressCard
+description: Generic card visualising a document's lifecycle state — optional percentage, stacked progress bar, and status legend
+---
+
+# DocumentProgressCard
+
+`DocumentProgressCard` is a generic, presentational card for communicating the lifecycle/fulfilment state of a document in a record detail right rail. It shows an optional completion percentage, a stacked progress bar, and a legend — driven by an arbitrary set of status `segments`.
+
+It is view-only and domain-agnostic: pass the segments and an explicit `percent`. Any domain-specific math — deriving the percentage, or decomposing overlapping buckets into bar segments — lives in the consumer. See [Example: purchase-order fulfilment](#example-purchase-order-fulfilment) for a complete, copyable pattern.
+
+## Import
+
+```tsx
+import { DocumentProgressCard } from "@tailor-platform/app-shell";
+```
+
+## Basic Usage
+
+```tsx
+
+```
+
+## Props
+
+| Prop | Type | Default | Description |
+| ----------- | --------------------------- | ------------- | --------------------------------------------------------------------------------------------------------- |
+| `segments` | `DocumentProgressSegment[]` | **Required** | Status segments rendered as a stacked bar (and, by default, the legend). |
+| `title` | `React.ReactNode` | - | Optional card title shown top-left. |
+| `percent` | `number` | - | Optional headline percentage (0–100), shown top-right. Explicit — the generic card derives no progress. |
+| `legend` | `DocumentProgressSegment[]` | `segments` | Optional legend rows; override only when the legend should differ from the bar (see [Legend](#legend)). |
+| `total` | `number` | sum of values | Denominator used to size the bar. A value larger than the segment sum leaves an unfilled track remainder. |
+| `className` | `string` | - | Additional CSS classes for the card root. |
+
+### `DocumentProgressSegment`
+
+| Field | Type | Description |
+| ------- | ----------------------- | ------------------------------------------------------ |
+| `label` | `string` | Legend label. |
+| `value` | `number` | Amount — shown in the legend and used to size the bar. |
+| `color` | `DocumentProgressColor` | Bar / marker color (required). |
+
+`DocumentProgressColor` is one of: `"indigo"`, `"pink"`, `"green"`, `"amber"`, `"red"`, `"blue"`, `"neutral"`.
+
+## Progress Bar
+
+The bar tiles `segments` left-to-right, each sized as `value / (total ?? sum of values)`. When `total` exceeds the segment sum, the shortfall renders as an empty `bg-muted` track — useful for a "remaining" portion you don't want as a colored segment. A `"neutral"` segment reads as a muted/track-like fill.
+
+## Legend
+
+By default the legend mirrors `segments`. Pass `legend` to render different rows — for example when buckets overlap and the bar shows a decomposition while the legend shows the raw figures:
+
+```tsx
+
+```
+
+## Example: purchase-order fulfilment
+
+A common use is communicating a purchase order's receiving state — **received**, **returned**, and **yet to receive**. `DocumentProgressCard` stays generic, so derive the percentage and the bar breakdown in your component and pass them in. This recipe reproduces the recommended look (labels, indigo/pink/neutral colors, and a bar that decomposes received into "kept" + "returned" against the ordered total):
+
+```tsx
+function PurchaseOrderFulfilment({
+ received,
+ returned,
+ yetToReceive,
+ // Whether returned items still count as fulfilled. Set false to subtract them.
+ returnedCountsAsComplete = true,
+}: {
+ received: number;
+ returned: number;
+ yetToReceive: number;
+ returnedCountsAsComplete?: boolean;
+}) {
+ // Returned is a subset of received; clamp so the breakdown can't exceed it.
+ const effectiveReturned = Math.min(Math.max(returned, 0), Math.max(received, 0));
+ const total = Math.max(received, 0) + Math.max(yetToReceive, 0); // the ordered quantity
+ const complete = returnedCountsAsComplete ? received : received - effectiveReturned;
+ const percent = total > 0 ? Math.round((complete / total) * 100) : 0;
+
+ return (
+
+ );
+}
+
+// → 30%
+```
+
+Notes for adapting it:
+
+- **Colors:** `indigo` (received), `pink` (returned), `neutral` (yet to receive) is the recommended set; swap any of the seven palette colors to fit your domain.
+- **Bar vs. legend:** the bar uses `received − returned` so the two colored segments don't double-count the returned items, while the `legend` override keeps "Received items" showing the full received figure.
+- **`total`:** pass the ordered quantity so the unfilled portion of the bar represents "yet to receive". Omit it (or use the segment sum) if you want a fully-tiled bar instead.
+- **Percentage policy:** flip `returnedCountsAsComplete` to decide whether returned items count toward completion.
+
+## Input handling
+
+Segment values are expected to be non-negative numbers. Non-finite or negative values are coerced to `0`, and `percent` is rounded and clamped to `[0, 100]`.
+
+## Related
+
+- [MetricCard](./metric-card.md) — Compact KPI summary card.
+- [Card](./card.md) — The underlying card primitive.
diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx
index 76c7f3fb..fba2c8d2 100644
--- a/examples/vite-app/src/App.tsx
+++ b/examples/vite-app/src/App.tsx
@@ -34,6 +34,7 @@ const App = () => {
+
diff --git a/examples/vite-app/src/pages/dashboard/document-progress/page.tsx b/examples/vite-app/src/pages/dashboard/document-progress/page.tsx
new file mode 100644
index 00000000..2d94de0e
--- /dev/null
+++ b/examples/vite-app/src/pages/dashboard/document-progress/page.tsx
@@ -0,0 +1,155 @@
+import {
+ Layout,
+ Grid,
+ DocumentProgressCard,
+ type AppShellPageProps,
+} from "@tailor-platform/app-shell";
+
+const GaugeIcon = () => (
+
+);
+
+/**
+ * Purchase-order fulfilment, built on the generic DocumentProgressCard — the
+ * recommended recipe from the docs. Derives the percentage and decomposes
+ * received into "kept" + "returned" in the consumer, keeping the card generic.
+ */
+const PurchaseOrderFulfilment = ({
+ title = "Fulfilment rate",
+ received,
+ returned,
+ yetToReceive,
+ returnedCountsAsComplete = true,
+}: {
+ title?: string;
+ received: number;
+ returned: number;
+ yetToReceive: number;
+ returnedCountsAsComplete?: boolean;
+}) => {
+ const effectiveReturned = Math.min(Math.max(returned, 0), Math.max(received, 0));
+ const total = Math.max(received, 0) + Math.max(yetToReceive, 0);
+ const complete = returnedCountsAsComplete ? received : received - effectiveReturned;
+ const percent = total > 0 ? Math.round((complete / total) * 100) : 0;
+
+ return (
+
+ );
+};
+
+const DocumentProgressPage = () => {
+ return (
+
+
+
+
+
Arbitrary status segments
+
+ Domain-agnostic — any set of status segments plus an explicit percentage.
+
+
+ {/* Arbitrary lifecycle: shipped / returned / pending */}
+
+
+ {/* More than three statuses — stretches to any workflow */}
+
+
+ {/* No percentage — just a composition bar + legend */}
+
+
+ {/* Unfilled remainder via total */}
+
+
+
+
Recipe: purchase-order fulfilment
+
+ Received / returned / yet-to-receive, derived in the consumer (see{" "}
+ PurchaseOrderFulfilment in this file and the component docs).
+
+
+ {/* Matches the Figma baseline — nothing received yet */}
+
+
+ {/* Partially received with some returns */}
+
+
+ {/* Same data, but returns subtracted from progress */}
+
+
+ {/* Fully received */}
+
+
+
+
+ );
+};
+
+DocumentProgressPage.appShellPageProps = {
+ meta: {
+ title: "Document Progress",
+ icon: ,
+ },
+} satisfies AppShellPageProps;
+
+export default DocumentProgressPage;
diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts
index 586629b2..93a66bcf 100644
--- a/examples/vite-app/src/routes.generated.ts
+++ b/examples/vite-app/src/routes.generated.ts
@@ -17,6 +17,7 @@ import { createTypedPaths } from "@tailor-platform/app-shell";
export type GeneratedRouteParams = {
"/": {};
"/dashboard": {};
+ "/dashboard/document-progress": {};
"/dashboard/orders": {};
"/dashboard/orders/:id": { id: string };
"/dashboard/products": {};
diff --git a/packages/core/__snapshots__/src__components__document-progress-card__DocumentProgressCard.test.tsx.snap b/packages/core/__snapshots__/src__components__document-progress-card__DocumentProgressCard.test.tsx.snap
new file mode 100644
index 00000000..0d886bf3
--- /dev/null
+++ b/packages/core/__snapshots__/src__components__document-progress-card__DocumentProgressCard.test.tsx.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`DocumentProgressCard > snapshots > legend override distinct from bar segments 1`] = `"
30%
Received items
12
Returned items
2
Yet to receive
28
"`;
+
+exports[`DocumentProgressCard > snapshots > title, percent, and three segments 1`] = `"
Shipment status60%
Shipped
30
Returned
3
Pending
17
"`;
+
+exports[`DocumentProgressCard > snapshots > with an unfilled remainder via total 1`] = `"
25%
Done
10
"`;
diff --git a/packages/core/src/components/document-progress-card/DocumentProgressCard.test.tsx b/packages/core/src/components/document-progress-card/DocumentProgressCard.test.tsx
new file mode 100644
index 00000000..94fa101f
--- /dev/null
+++ b/packages/core/src/components/document-progress-card/DocumentProgressCard.test.tsx
@@ -0,0 +1,202 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { DocumentProgressCard } from "./DocumentProgressCard";
+
+afterEach(() => {
+ cleanup();
+});
+
+const getRoot = (container: HTMLElement) =>
+ container.querySelector('[data-slot="document-progress-card"]') as HTMLElement;
+
+const getPercentText = () =>
+ screen.getByText((_, el) => el?.getAttribute("data-slot") === "document-progress-percent")
+ .textContent;
+
+const segments = (container: HTMLElement) =>
+ Array.from(
+ container.querySelectorAll('[data-slot="document-progress-segment"]'),
+ ) as HTMLElement[];
+
+describe("DocumentProgressCard", () => {
+ describe("snapshots", () => {
+ it("title, percent, and three segments", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("with an unfilled remainder via total", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("legend override distinct from bar segments", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+ });
+
+ it("renders segment labels and values in the legend", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("Shipped")).toBeDefined();
+ expect(screen.getByText("Pending")).toBeDefined();
+ expect(screen.getByText("30")).toBeDefined();
+ expect(screen.getByText("10")).toBeDefined();
+ });
+
+ it("renders the headline percentage when provided", () => {
+ render(
+ ,
+ );
+ expect(getPercentText()).toBe("42%");
+ });
+
+ it("omits the header when neither title nor percent is provided", () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('[data-slot="document-progress-percent"]')).toBeNull();
+ // only the bar+legend wrapper is a child of the root (no header row)
+ expect(getRoot(container).children.length).toBe(1);
+ });
+
+ it("clamps the percentage to [0, 100] and rounds it", () => {
+ render(
+ ,
+ );
+ expect(getPercentText()).toBe("100%");
+ });
+
+ it("sizes bar segments against the sum of values by default", () => {
+ const { container } = render(
+ ,
+ );
+ const [a, b] = segments(container);
+ // sum = 40 → 75% / 25%
+ expect(a.style.width).toBe("75%");
+ expect(b.style.width).toBe("25%");
+ });
+
+ it("leaves an unfilled remainder when total exceeds the segment sum", () => {
+ const { container } = render(
+ ,
+ );
+ const segs = segments(container);
+ expect(segs).toHaveLength(1);
+ expect(segs[0].style.width).toBe("25%"); // 10 / 40
+ });
+
+ it("renders the legend independently of the bar when `legend` is provided", () => {
+ const { container } = render(
+ ,
+ );
+ // bar reflects segments (2), legend reflects the override (3 rows)
+ expect(segments(container)).toHaveLength(2);
+ expect(container.querySelectorAll('[data-slot="document-progress-legend-row"]')).toHaveLength(
+ 3,
+ );
+ expect(screen.getByText("Received items")).toBeDefined();
+ expect(screen.getByText("12")).toBeDefined();
+ });
+
+ it("applies the segment color to the bar", () => {
+ const { container } = render(
+ ,
+ );
+ const [a, b] = segments(container);
+ expect(a.getAttribute("data-color")).toBe("green");
+ expect(b.getAttribute("data-color")).toBe("amber");
+ });
+
+ it("sanitizes non-finite and negative values to zero", () => {
+ render(
+ ,
+ );
+ expect(screen.getAllByText("0")).toHaveLength(3);
+ });
+
+ it("renders the bar as a decorative element", () => {
+ const { container } = render(
+ ,
+ );
+ const bar = container.querySelector('[data-slot="document-progress-bar"]') as HTMLElement;
+ expect(bar.getAttribute("aria-hidden")).toBe("true");
+ });
+
+ it("accepts a custom className on the root", () => {
+ const { container } = render(
+ ,
+ );
+ expect(getRoot(container).className).toContain("custom-card");
+ });
+});
diff --git a/packages/core/src/components/document-progress-card/DocumentProgressCard.tsx b/packages/core/src/components/document-progress-card/DocumentProgressCard.tsx
new file mode 100644
index 00000000..2dec88ed
--- /dev/null
+++ b/packages/core/src/components/document-progress-card/DocumentProgressCard.tsx
@@ -0,0 +1,183 @@
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+import { Card } from "../card";
+import type {
+ DocumentProgressCardProps,
+ DocumentProgressColor,
+ DocumentProgressSegment,
+} from "./types";
+
+// ============================================================================
+// CONSTANTS
+// ============================================================================
+
+/** Maps a palette color to its fill class (used for the bar and legend marker). */
+const colorFill: Record = {
+ indigo: "astw:bg-indigo-500",
+ pink: "astw:bg-pink-500",
+ green: "astw:bg-green-500",
+ amber: "astw:bg-amber-500",
+ red: "astw:bg-red-500",
+ blue: "astw:bg-blue-500",
+ neutral: "astw:bg-muted-foreground/40",
+};
+
+// ============================================================================
+// HELPERS
+// ============================================================================
+
+/** Clamp a fraction to the [0, 1] range, guarding against a zero denominator. */
+const calculateRate = (numerator: number, denominator: number) =>
+ denominator > 0 ? Math.min(1, Math.max(0, numerator / denominator)) : 0;
+
+/** Coerce an input amount to a non-negative, finite number (NaN/Infinity/negative → 0). */
+const sanitizeValue = (value: number) => (Number.isFinite(value) ? Math.max(0, value) : 0);
+
+/** Sanitize each segment's value. */
+const resolveSegments = (segments: DocumentProgressSegment[]) =>
+ segments.map((segment) => ({
+ label: segment.label,
+ value: sanitizeValue(segment.value),
+ color: segment.color,
+ }));
+
+// ============================================================================
+// DOCUMENT PROGRESS CARD
+// ============================================================================
+
+/**
+ * DocumentProgressCard — a generic, presentational card for a document's
+ * lifecycle/fulfilment state: an optional headline percentage, a stacked
+ * progress bar, and a status legend.
+ *
+ * View-only and domain-agnostic — pass an arbitrary set of `segments` (e.g.
+ * shipped / returned / pending, or cancelled / blocked / …) plus an explicit
+ * `percent`. Derive the percentage and, where buckets overlap, the bar
+ * breakdown in the consumer (see the docs for a purchase-order example).
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function DocumentProgressCard({
+ title,
+ percent,
+ segments,
+ legend,
+ total,
+ className,
+}: DocumentProgressCardProps) {
+ const barSegments = resolveSegments(segments);
+ const legendItems = resolveSegments(legend ?? segments);
+
+ const segmentSum = barSegments.reduce((sum, segment) => sum + segment.value, 0);
+ const requestedTotal = total != null && Number.isFinite(total) ? total : 0;
+ // Never let the denominator fall below the segment sum (would overflow the bar).
+ const denominator = Math.max(segmentSum, requestedTotal);
+
+ const hasPercent = percent != null && Number.isFinite(percent);
+ const clampedPercent = hasPercent ? Math.round(Math.min(100, Math.max(0, percent))) : null;
+
+ return (
+
+ {/* Header: optional title + optional completion percentage */}
+ {(title != null || hasPercent) && (
+
+ );
+}
+
+// ============================================================================
+// EXPORTS
+// ============================================================================
+
+export default DocumentProgressCard;
diff --git a/packages/core/src/components/document-progress-card/index.ts b/packages/core/src/components/document-progress-card/index.ts
new file mode 100644
index 00000000..164ad5b4
--- /dev/null
+++ b/packages/core/src/components/document-progress-card/index.ts
@@ -0,0 +1,2 @@
+export { DocumentProgressCard, default } from "./DocumentProgressCard";
+export type { DocumentProgressCardProps } from "./types";
diff --git a/packages/core/src/components/document-progress-card/types.ts b/packages/core/src/components/document-progress-card/types.ts
new file mode 100644
index 00000000..401cf7d9
--- /dev/null
+++ b/packages/core/src/components/document-progress-card/types.ts
@@ -0,0 +1,65 @@
+import type { ReactNode } from "react";
+
+// ============================================================================
+// COLOR
+// ============================================================================
+
+/**
+ * Curated palette for progress segments. Each value maps to a fixed fill used
+ * for both the stacked bar and the legend marker.
+ */
+export type DocumentProgressColor =
+ | "indigo"
+ | "pink"
+ | "green"
+ | "amber"
+ | "red"
+ | "blue"
+ | "neutral";
+
+// ============================================================================
+// SEGMENT
+// ============================================================================
+
+/**
+ * A single status segment: its amount, a legend label, and a color.
+ */
+export interface DocumentProgressSegment {
+ /** Legend label (e.g. "Shipped"). */
+ label: string;
+ /** Amount — shown in the legend and used to size the bar. */
+ value: number;
+ /** Bar / legend-marker color. */
+ color: DocumentProgressColor;
+}
+
+// ============================================================================
+// COMPONENT PROPS
+// ============================================================================
+
+/**
+ * Props for the generic DocumentProgressCard.
+ */
+export interface DocumentProgressCardProps {
+ /** Optional card title shown top-left. */
+ title?: ReactNode;
+ /**
+ * Headline percentage shown top-right (0–100). Optional and explicit — the
+ * generic card has no notion of "complete", so the consumer supplies it.
+ */
+ percent?: number;
+ /** Status segments rendered as a single stacked bar (and, by default, the legend). */
+ segments: DocumentProgressSegment[];
+ /**
+ * Legend rows. Defaults to `segments`. Provide this only when the legend
+ * should differ from the bar (e.g. overlapping buckets shown distinctly).
+ */
+ legend?: DocumentProgressSegment[];
+ /**
+ * Denominator used to size the bar. Defaults to the sum of segment values
+ * (the bar is fully tiled). A larger value leaves an unfilled track remainder.
+ */
+ total?: number;
+ /** Additional CSS classes for the card root. */
+ className?: string;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 61eb11df..6a0767fd 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -109,6 +109,10 @@ export {
type UseAttachmentOptions,
} from "./components/attachment";
export { MetricCard, type MetricCardProps } from "./components/metric-card";
+export {
+ DocumentProgressCard,
+ type DocumentProgressCardProps,
+} from "./components/document-progress-card";
export { Layout, type LayoutProps } from "./components/layout/Layout";
export { Grid, type GridProps, type GridItemProps } from "./components/grid";
export { Button, buttonVariants, type ButtonProps } from "./components/button";