---
name: recent-orders
title: Recent Orders
url: https://ui.frontic.com/components/sections/recent-orders.md
tier: sections
install: npx @frontic/ui add recent-orders
files:
  - components/sections/recent-orders/RecentOrders.vue
  - components/sections/recent-orders/index.ts
  - components/sections/recent-orders/types.ts
registryDependencies:
  - badge
  - button
  - card
  - dropdown-menu
  - format
  - pagination
  - price
  - table
  - text
dependencies:
  - "@lucide/vue"
  - reka-ui
---

# Recent Orders

Order history card: a table of orders with date, items, status and total, a per-order menu to view, track, get the invoice or start a return, and pagination.

## Install

```sh
npx @frontic/ui add recent-orders
```

Built from: [Badge](https://ui.frontic.com/components/ui/badge.md), [Button](https://ui.frontic.com/components/ui/button.md), [Card](https://ui.frontic.com/components/ui/card.md), [Dropdown Menu](https://ui.frontic.com/components/ui/dropdown-menu.md), [Format](https://ui.frontic.com/lib/format.md), [Pagination](https://ui.frontic.com/components/ui/pagination.md), [Price](https://ui.frontic.com/components/ui/price.md), [Table](https://ui.frontic.com/components/ui/table.md), [Text](https://ui.frontic.com/lib/text.md)

npm packages: `@lucide/vue`, `reka-ui`

## Examples

```vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import { RecentOrders, type OrderStatus, type RecentOrder } from '@/components/sections/recent-orders'

const PRODUCTS = {
  blizzardDefender: { name: 'Blizzard Defender', amount: 12600 },
  polarLightSkiJacket: { name: 'Polar Light Ski Jacket', amount: 7900 },
  winterwonderParka: { name: 'Winterwonder Parka', amount: 13200 },
  frostEscapeParka: { name: 'Frost Escape Parka', amount: 14900 },
  northernLightHybridJacket: { name: 'Northern Light Hybrid Jacket', amount: 11500 },
  mountainViewSkiGoggles: { name: 'Mountain View Ski Goggles', amount: 6695 },
  snowflakeSkiGoggles: { name: 'Snowflake Ski Goggles', amount: 12700 },
  alpineViewHorizon: { name: 'Alpine View Horizon', amount: 8400 },
  trailMaster22: { name: 'TrailMaster 22', amount: 14100 },
  alpinTechNavigator45: { name: 'AlpinTech Navigator 45', amount: 15000 },
  trailSprinter22: { name: 'Trail Sprinter 22', amount: 4800 },
}
type Product = (typeof PRODUCTS)[keyof typeof PRODUCTS]
const P = PRODUCTS
const ALL = Object.values(PRODUCTS)

const order = (number: string, date: string, status: OrderStatus, items: Product[]): RecentOrder => ({
  number: `#${number}`,
  date,
  status,
  items: items.map((item) => item.name),
  total: { amount: items.reduce((sum, item) => sum + item.amount, 0), currency: 'EUR' },
})

const day = (month: number, date: number) => `2026-${String(month).padStart(2, '0')}-${String(date).padStart(2, '0')}`

const placed: RecentOrder[] = [
  order('FR-20418', day(10, 6), 'processing', [P.blizzardDefender, P.alpineViewHorizon]),
  order('FR-20377', day(10, 2), 'shipped', [P.alpinTechNavigator45]),
  order('FR-20291', day(9, 24), 'delivered', [P.northernLightHybridJacket, P.trailMaster22, P.mountainViewSkiGoggles]),
  order('FR-20155', day(9, 12), 'returned', [P.polarLightSkiJacket]),
  order('FR-20087', day(9, 3), 'delivered', [P.winterwonderParka, P.frostEscapeParka, P.snowflakeSkiGoggles, P.trailSprinter22]),
  // Older orders for the later pages, drawn from the same catalog.
  ...Array.from({ length: 18 }, (_, i) =>
    order(
      `FR-${20030 - i * 57}`,
      day(9, 3 - (i + 1) * 4),
      i % 7 === 3 ? 'returned' : 'delivered',
      Array.from({ length: (i % 3) + 1 }, (_, k) => ALL[(i * 2 + k) % ALL.length]!),
    ),
  ),
]

const PAGE_SIZE = 5
const page = ref(1)
const orders = computed(() => placed.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE))
</script>

<template>
  <div class="mx-auto w-full max-w-5xl">
    <RecentOrders v-model:page="page" :orders="orders" :total="placed.length" :page-size="PAGE_SIZE" />
  </div>
</template>
```

## API reference

### RecentOrders

Lists recent orders with status and total, and actions such as track or return.

Styling: [`line-title`](https://ui.frontic.com/DESIGN.md#line-title); [`hint`](https://ui.frontic.com/DESIGN.md#hint). Change them there and every part that extends them follows.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orders` (required) | `RecentOrder[]` |  | The orders on the current page. |
| `total` | `number` |  | All orders across the pages; the pagination shows when they need more than one. |
| `pageSize` | `number` | `5` | How many orders a page holds. |
| `title` | `string` | `'Recent orders'` | Shown as the card's title. |
| `description` | `string` | `'Everything you ordered in the last three months.'` | Shown under the title. |
| `downloadLabel` | `string` | `'Download all invoices'` | Shown on the button that downloads all invoices. |
| `orderLabel` | `string` | `'Order'` | Heading of the order number column. |
| `dateLabel` | `string` | `'Date'` | Heading of the order date column. |
| `itemsLabel` | `string` | `'Items'` | Heading of the ordered items column. |
| `statusLabel` | `string` | `'Status'` | Heading of the status column. |
| `totalLabel` | `string` | `'Total'` | Heading of the order total column. |
| `actionsLabel` | `string` | `'Actions'` | Screen-reader heading of the column with each order's action menu. |
| `viewLabel` | `string` | `'View order'` | Shown on the menu item that opens the order. |
| `trackLabel` | `string` | `'Track parcel'` | Shown on the menu item that tracks the parcel of a shipped order. |
| `invoiceLabel` | `string` | `'Invoice (PDF)'` | Shown on the menu item that downloads the order's invoice. |
| `returnLabel` | `string` | `'Start a return'` | Shown on the menu item that starts a return for a delivered order. |
| `statusLabels` | `Record<OrderStatus, string>` | `{     processing: 'Processing',     shipped: 'Shipped',     delivered: 'Delivered',     returned: 'Returned', }` | Shown on each order's status badge, by status. |
| `moreLabel` | `((count: number) => string)`: `(count: number): string` | ``+${count} more`` | Shown after the first item name, counting the order's other items. |
| `rangeLabel` | `((from: number, to: number, total: number) => string)`: `(from: number, to: number, total: number): string` | ``Showing ${from}–${to} of ${total} ${total === 1 ? 'order' : 'orders'}`` | Shown under the table, saying which orders the page holds out of how many. |
| `orderActionsLabel` | `((order: RecentOrder) => string)`: `(order: RecentOrder): string` | ``Actions for order ${order.number}`` | Screen-reader label of an order's action menu button. |
| `surface` | `'raised' \| 'bare'` | `'raised'` | Bare drops the edge, shadow and inset, for a section placed inside another Card. |
| `locale` | `string` |  | BCP 47 locale the dates and totals are formatted in; the ConfigProvider's when left out. |
| `class` | `ClassValue` |  | Classes added to the part's own element, after its recipe classes. |
| `page` | `number` | `1` | The current page, starting at 1. |

| Event | Payload | Description |
| --- | --- | --- |
| `update:page` | `value: number` | Fires with the new value when `page` changes; `v-model:page` binds it. |
| `downloadAll` |  | Fires when the customer clicks the button to download all invoices. |
| `view` | `order: RecentOrder` | Fires with the order when the customer chooses to view it. |
| `track` | `order: RecentOrder` | Fires with a shipped order when the customer chooses to track it. |
| `invoice` | `order: RecentOrder` | Fires with the order when the customer requests its invoice. |
| `return` | `order: RecentOrder` | Fires with a delivered order when the customer starts a return for it. |

| Data attribute | Values | Description |
| --- | --- | --- |
| `data-surface` | `'raised' \| 'bare'` | Carries the `surface` prop. |

## Exports

```ts
import { type OrderStatus, type RecentOrder } from '@/components/sections/recent-orders'
```

- `OrderStatus`: `'processing' \| 'shipped' \| 'delivered' \| 'returned'`
- `RecentOrder`: below

```ts
interface RecentOrder {
  /** Shown as the order's reference, e.g. `#FR-20418`. */
  number: string
  /** ISO date the order was placed, shown in the item's locale. */
  date: string
  status: OrderStatus
  /** The names of the ordered products; the first is shown, the rest counted. */
  items: string[]
  total: PriceType
}
```

## Styling

| Part | Recipe |
| --- | --- |
| `line-title` | [`line-title`](https://ui.frontic.com/DESIGN.md#line-title) |
| `hint` | [`hint`](https://ui.frontic.com/DESIGN.md#hint) |

## Source

`components/sections/recent-orders/RecentOrders.vue`

```vue
<script lang="ts">
/** Lists recent orders with status and total, and actions such as track or return. */
export default {}
</script>

<script setup lang="ts">
import { DownloadIcon, MoreHorizontalIcon, PackageSearchIcon, TruckIcon, FileTextIcon, RotateCcwIcon } from '@lucide/vue'

import type { HTMLAttributes } from 'vue'
import type { OrderStatus, RecentOrder } from './types'
import { computed, toRef } from 'vue'
import { Badge } from '@library/components/ui/badge'
import { Button } from '@library/components/ui/button'
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@library/components/ui/card'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@library/components/ui/dropdown-menu'
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationNext, PaginationPrevious } from '@library/components/ui/pagination'
import { Price } from '@library/components/ui/price'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@library/components/ui/table'
import { formatDate } from '@library/lib/format'
import { pageRange } from '@library/lib/text'
import { useLocale } from 'reka-ui'

const props = withDefaults(
  defineProps<{
    /** The orders on the current page. */
    orders: RecentOrder[]
    /** All orders across the pages; the pagination shows when they need more than one. */
    total?: number
    /** How many orders a page holds. */
    pageSize?: number
    /** Shown as the card's title. */
    title?: string
    /** Shown under the title. */
    description?: string
    /** Shown on the button that downloads all invoices. */
    downloadLabel?: string
    /** Heading of the order number column. */
    orderLabel?: string
    /** Heading of the order date column. */
    dateLabel?: string
    /** Heading of the ordered items column. */
    itemsLabel?: string
    /** Heading of the status column. */
    statusLabel?: string
    /** Heading of the order total column. */
    totalLabel?: string
    /** Screen-reader heading of the column with each order's action menu. */
    actionsLabel?: string
    /** Shown on the menu item that opens the order. */
    viewLabel?: string
    /** Shown on the menu item that tracks the parcel of a shipped order. */
    trackLabel?: string
    /** Shown on the menu item that downloads the order's invoice. */
    invoiceLabel?: string
    /** Shown on the menu item that starts a return for a delivered order. */
    returnLabel?: string
    /** Shown on each order's status badge, by status. */
    statusLabels?: Record<OrderStatus, string>
    /** Shown after the first item name, counting the order's other items. */
    moreLabel?: (count: number) => string
    /** Shown under the table, saying which orders the page holds out of how many. */
    rangeLabel?: (from: number, to: number, total: number) => string
    /** Screen-reader label of an order's action menu button. */
    orderActionsLabel?: (order: RecentOrder) => string
    /** Bare drops the edge, shadow and inset, for a section placed inside another Card. */
    surface?: 'raised' | 'bare'
    /** BCP 47 locale the dates and totals are formatted in; the ConfigProvider's when left out. */
    locale?: string
    class?: HTMLAttributes['class']
  }>(),
  {
    surface: 'raised',
    pageSize: 5,
    title: 'Recent orders',
    description: 'Everything you ordered in the last three months.',
    downloadLabel: 'Download all invoices',
    orderLabel: 'Order',
    dateLabel: 'Date',
    itemsLabel: 'Items',
    statusLabel: 'Status',
    totalLabel: 'Total',
    actionsLabel: 'Actions',
    viewLabel: 'View order',
    trackLabel: 'Track parcel',
    invoiceLabel: 'Invoice (PDF)',
    returnLabel: 'Start a return',
    statusLabels: () => ({
      processing: 'Processing',
      shipped: 'Shipped',
      delivered: 'Delivered',
      returned: 'Returned',
    }),
    moreLabel: (count: number) => `+${count} more`,
    rangeLabel: (from: number, to: number, total: number) => `Showing ${from}–${to} of ${total} ${total === 1 ? 'order' : 'orders'}`,
    orderActionsLabel: (order: RecentOrder) => `Actions for order ${order.number}`,
  },
)
const locale = useLocale(toRef(() => props.locale))
const dateText = (date: string) => formatDate(date, { year: 'numeric', month: 'short', day: 'numeric' }, locale.value)
const emit = defineEmits<{
  /** Fires when the customer clicks the button to download all invoices. */
  downloadAll: []
  /** Fires with the order when the customer chooses to view it. */
  view: [order: RecentOrder]
  /** Fires with a shipped order when the customer chooses to track it. */
  track: [order: RecentOrder]
  /** Fires with the order when the customer requests its invoice. */
  invoice: [order: RecentOrder]
  /** Fires with a delivered order when the customer starts a return for it. */
  return: [order: RecentOrder]
}>()

/** The current page, starting at 1. */
const page = defineModel<number>('page', { default: 1 })

const STATUS_VARIANT: Record<OrderStatus, 'success' | 'info' | 'primary' | 'neutral'> = {
  processing: 'primary',
  shipped: 'info',
  delivered: 'success',
  returned: 'neutral',
}

const count = computed(() => props.total ?? props.orders.length)
const shown = computed(() => pageRange(page.value, props.pageSize, count.value))
</script>

<template>
  <Card :surface="surface" :class="props.class">
    <CardHeader>
      <CardTitle>{{ title }}</CardTitle>
      <CardDescription v-if="description">{{ description }}</CardDescription>
      <CardAction>
        <div class="hidden @md/card-header:block">
          <Button variant="outline" color="neutral" size="sm" @click="emit('downloadAll')">{{ downloadLabel }}</Button>
        </div>
        <div class="@md/card-header:hidden">
          <Button variant="outline" color="neutral" size="icon-sm" :aria-label="downloadLabel" @click="emit('downloadAll')">
            <DownloadIcon data-icon />
          </Button>
        </div>
      </CardAction>
    </CardHeader>
    <CardContent class="@container">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>{{ orderLabel }}</TableHead>
            <TableHead class="hidden @lg:table-cell">{{ dateLabel }}</TableHead>
            <TableHead class="hidden @2xl:table-cell">{{ itemsLabel }}</TableHead>
            <TableHead class="hidden @sm:table-cell">{{ statusLabel }}</TableHead>
            <TableHead class="text-end">{{ totalLabel }}</TableHead>
            <TableHead
              ><span class="sr-only">{{ actionsLabel }}</span></TableHead
            >
          </TableRow>
        </TableHeader>
        <TableBody>
          <TableRow v-for="order in orders" :key="order.number">
            <TableCell>
              <div class="flex flex-col items-start gap-1">
                <span data-slot="line-title" class="fui-line-title">{{ order.number }}</span>
                <div class="flex flex-col items-start gap-1 @lg:hidden">
                  <time data-slot="hint" class="fui-hint" :datetime="order.date">{{ dateText(order.date) }}</time>
                  <Badge :variant="STATUS_VARIANT[order.status]" size="sm" class="@sm:hidden">{{ statusLabels[order.status] }}</Badge>
                </div>
              </div>
            </TableCell>
            <TableCell class="hidden @lg:table-cell">
              <time data-slot="hint" class="fui-hint" :datetime="order.date">{{ dateText(order.date) }}</time>
            </TableCell>
            <TableCell class="hidden @2xl:table-cell">
              {{ order.items[0] }}
              <span v-if="order.items.length > 1" data-slot="hint" class="fui-hint">{{ moreLabel(order.items.length - 1) }}</span>
            </TableCell>
            <TableCell class="hidden @sm:table-cell">
              <Badge :variant="STATUS_VARIANT[order.status]" size="sm">{{ statusLabels[order.status] }}</Badge>
            </TableCell>
            <TableCell class="text-end">
              <Price align="end" :price="order.total" size="sm" :locale="locale" />
            </TableCell>
            <TableCell class="text-end">
              <DropdownMenu>
                <DropdownMenuTrigger as-child>
                  <Button variant="ghost" color="neutral" size="icon-sm" :aria-label="orderActionsLabel(order)">
                    <MoreHorizontalIcon data-icon />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem @select="emit('view', order)">
                    <PackageSearchIcon data-icon />
                    {{ viewLabel }}
                  </DropdownMenuItem>
                  <DropdownMenuItem :disabled="order.status !== 'shipped'" @select="emit('track', order)">
                    <TruckIcon data-icon />
                    {{ trackLabel }}
                  </DropdownMenuItem>
                  <DropdownMenuItem @select="emit('invoice', order)">
                    <FileTextIcon data-icon />
                    {{ invoiceLabel }}
                  </DropdownMenuItem>
                  <DropdownMenuSeparator />
                  <DropdownMenuItem :disabled="order.status !== 'delivered'" @select="emit('return', order)">
                    <RotateCcwIcon data-icon />
                    {{ returnLabel }}
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </TableCell>
          </TableRow>
        </TableBody>
      </Table>
    </CardContent>
    <CardFooter class="@container flex-col items-start gap-4">
      <div class="flex w-full flex-col gap-4 @xl:flex-row @xl:items-center @xl:justify-between">
        <span v-if="shown" data-slot="hint" class="fui-hint shrink-0">{{ rangeLabel(shown.from, Math.min(shown.to, shown.from + orders.length - 1), count) }}</span>
        <Pagination v-if="count > pageSize" v-model:page="page" :items-per-page="pageSize" :total="count" class="mx-0 w-auto justify-start @xl:justify-end">
          <PaginationContent v-slot="{ items }">
            <PaginationPrevious />
            <template v-for="(item, index) in items" :key="index">
              <PaginationItem v-if="item.type === 'page'" :value="item.value">{{ item.value }}</PaginationItem>
              <PaginationEllipsis v-else :index="index" />
            </template>
            <PaginationNext />
          </PaginationContent>
        </Pagination>
      </div>
    </CardFooter>
  </Card>
</template>
```

`components/sections/recent-orders/types.ts`

```ts
import type { PriceType } from '@library/components/ui/price'

export type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'returned'

export interface RecentOrder {
  /** Shown as the order's reference, e.g. `#FR-20418`. */
  number: string
  /** ISO date the order was placed, shown in the item's locale. */
  date: string
  status: OrderStatus
  /** The names of the ordered products; the first is shown, the rest counted. */
  items: string[]
  total: PriceType
}
```
