# Listing grid

> A React listing grid for marketplace results: count, sort, view switch and filter chips over listing cards that load more as you scroll.

- Type: React component (Commerce)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add listing-grid`
- Docs: https://wingo-ui.com/components/listing-grid

## Interaction

Scroll the results and the next batch loads before you reach the end, rising in with a short stagger; the "Show 24 more" button under the grid does the same by click, tap, Enter or Space, and says how many are left. The header gives the count, the sort (a menu on desktop, a bottom sheet on phones), the grid / list switch (a segmented control on wide headers, one toggle button on phones) and a bell to save the search; applied filters sit under it as chips you remove with their × (they scroll sideways on phones), with "Clear filters" at the end. On a phone the grid is two columns of small cards and the promoted block is a rail you swipe; each card is one Tab stop plus its heart. Sorting swaps the list without animation and scrolls the header back into view; switching the view cross-fades the list once. New batches, sort and view changes are announced to screen readers. While loading, skeletons with the cards' shape hold the place; with no results an empty state offers "Clear filters", and an error offers "Try again".

## Installation

```bash
npx wingo-ui@latest add listing-grid
```

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/listing-grid` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/listing-grid.tsx`

npm dependencies: `npm i lucide-react motion`

Wingo UI dependencies (installed automatically):

- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Listing card](https://wingo-ui.com/components/listing-card) ([Markdown](https://wingo-ui.com/components/listing-card.md))
- [Pagination](https://wingo-ui.com/components/pagination) ([Markdown](https://wingo-ui.com/components/pagination.md))
- [Toolbar](https://wingo-ui.com/components/toolbar) ([Markdown](https://wingo-ui.com/components/toolbar.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))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [usePersistedState](https://wingo-ui.com/components/use-persisted-state) ([Markdown](https://wingo-ui.com/components/use-persisted-state.md))
- [useScrollState](https://wingo-ui.com/components/use-scroll-state) ([Markdown](https://wingo-ui.com/components/use-scroll-state.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))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { ListingGrid } from "@/components/ui/listing-grid"
import type { ListingData } from "@/components/ui/listing-card"

export function SearchResults({
  first,
  total,
  fetchPage,
}: {
  first: ListingData[]
  total: number
  fetchPage: (page: number, sort: string) => Promise<ListingData[]>
}) {
  const [items, setItems] = useState(first)
  const [sort, setSort] = useState("newest")
  const [page, setPage] = useState(1)

  return (
    <ListingGrid
      items={items}
      total={total}
      sort={sort}
      onSortChange={async (next) => {
        setSort(next)
        setPage(1)
        setItems(await fetchPage(1, next))
      }}
      hasMore={items.length < total}
      onLoadMore={async () => {
        const next = await fetchPage(page + 1, sort)
        setPage(page + 1)
        setItems((current) => [...current, ...next])
      }}
      viewPersistKey="listings-view"
      locale="en-US"
      sortOptions={[
        { value: "newest", label: "Newest" },
        { value: "price-asc", label: "Price: low to high" },
        { value: "price-desc", label: "Price: high to low" },
        { value: "relevance", label: "Most relevant" },
      ]}
      labels={{ end: "That's every listing" }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `ListingData[]` |  | The listings loaded so far, in order. Appending a batch animates the new cards in; replacing the set (a new search, a sort) swaps without animation. |
| `renderItem?` | `(item: ListingData, context: { view: ListingGridView; index: number; promoted: boolean }) => ReactNode` |  | Replaces the default ListingCard for each item (a custom card, an ad slot every 12 items). Items of the promoted block come with promoted true and view grid. |
| `cardProps?` | `Partial<ListingCardProps>` |  | Passed to every default card: the favorite handler, labels, the page's now, promotedStyle. Wins over the grid's own card settings. |
| `defaultView?` | `"grid" \| "list"` | `"grid"` | The starting view while uncontrolled. List shows one column of list rows (a 112px photo on phones) up to 896px wide. |
| `view?` | `"grid" \| "list"` |  | The controlled view; pair it with onViewChange. |
| `onViewChange?` | `(view: ListingGridView) => void` |  | Called when the header's switch changes the view. |
| `viewPersistKey?` | `string` | `""` | Remembers the view in localStorage under this key, so a visitor who prefers the list keeps it. Empty keeps it in memory. |
| `showViewSwitch?` | `boolean` | `true` | The grid / list switch in the header: a segmented control on wide headers, one toggle button on narrow ones. |
| `columns?` | `ListingGridColumns = { base?; sm?; md?; lg?; xl? }` | `"{ base: 2, md: 3, lg: 4 }"` | Columns by the grid's own width (base, then 640 / 768 / 1024 / 1280px and up); a missing step inherits the smaller one. CSS variables and container queries, so the server layout is already right and a grid next to a filter sidebar gets fewer columns. |
| `gap?` | `"sm" \| "md" \| "lg"` | `"md"` | Space between cards: 8px; 12px (16px from 768px of grid width); 16px (24px from 768px). |
| `cardSize?` | `"auto" \| "sm" \| "md" \| "lg"` | `"auto"` | The cards' size. auto: sm while the grid is narrower than 768px (two-column phone grids), md from there; list rows stay md so the photo keeps 112px. |
| `cardVariant?` | `"elevated" \| "outline" \| "soft" \| "ghost"` | `"elevated"` | The cards' surface; ghost drops it for a photo-first grid (fashion, Vinted style). |
| `imageAspect?` | `"1/1" \| "4/3" \| "3/2" \| "16/10" \| "3/4"` | `"4/3"` | The photo shape of every card and skeleton. |
| `total?` | `number` | `0` | The full result count ("1,240 listings" in the header, "Showing 24 of 1,240" under the button). 0 means unknown: the count and the "of N" disappear and the button still offers more while hasMore. The playground fills in its live count at 0. |
| `defaultSort?` | `string` | `"newest"` | The starting sort while uncontrolled, one of sortOptions' values. The playground really sorts its 1.240 results. |
| `sort?` | `string` |  | The controlled sort; pair it with onSortChange. |
| `onSortChange?` | `(sort: string) => void` |  | Called with the picked value. Fetch the sorted first page and replace items; the grid scrolls its header back into view. |
| `sortOptions?` | `{ value: string; label: ReactNode }[]` | `"DEFAULT_LISTING_SORT_OPTIONS"` | The sort menu: newest "Newest", price-asc "Price: low to high", price-desc "Price: high to low", relevance "Most relevant". An empty array hides the sort. |
| `appliedFilters?` | `ToolbarAppliedFilter[]` |  | Removable chips under the header ({ id, label }: "Portland +25 km", "Price: $200 to $3,000"). filter-panel's describeFilters builds them. |
| `onRemoveFilter?` | `(id: string) => void` |  | Called by a chip's ×. |
| `onClearFilters?` | `() => void` |  | Adds "Clear filters" after the chips and to the no-results state. |
| `filterTrigger?` | `ReactNode` |  | The phone "Filtre (3)" button that opens your filter sheet; hide it from 768px (md:hidden) when a sidebar takes over. |
| `showHeader?` | `boolean` | `true` | The header with the count, sort, view switch, save search and the chips. |
| `stickyHeader?` | `boolean` | `false` | The header sticks under the top bar (top: var(--topbar-offset, 0px)) on glass, with a hairline once stuck. Scroll the playground to see it. |
| `toolbarProps?` | `Partial<ToolbarProps>` |  | Anything else for the header's Toolbar: size, radius, variant (surface for a raised panel), even a search field. |
| `promoted?` | `ListingData[]` |  | A "Promoted" block above the results: a swipeable rail below 768px of grid width (one photo per card on touch, so swipes never nest), the first grid row from there. |
| `loading?` | `boolean` | `false` | First load: skeleton cards with the real card shape in place of the list (aria-busy on the list), and one announcement. |
| `skeletonCount?` | `number` | `8` | Skeleton cards while loading. |
| `hasMore?` | `boolean` | `false` | Whether there are more listings to load. False with an onLoadMore shows "You have seen all listings". The playground turns it on while its data has more; the control forces it. |
| `onLoadMore?` | `() => (void \| Promise<unknown>)` |  | Loads the next batch and appends it to items. A returned promise keeps the button busy until it settles. |
| `loadingMore?` | `boolean` | `false` | The next batch is loading (the footer button shows its spinner). Ignored without hasMore. |
| `loadMode?` | `"auto" \| "button"` | `"auto"` | auto: loads when the end comes within 200px of the scroll container's edge, with the button kept as the fallback; button: on click only. |
| `pageSize?` | `number` | `24` | Listings per batch, for the button label ("Show 24 more", fewer near the end when total is known). |
| `error?` | `ReactNode` |  | An error message. With no items it replaces the list ("Try again" when onRetry is set); after a failed batch it takes the footer's place under the loaded cards. |
| `onRetry?` | `() => (void \| Promise<unknown>)` |  | The error state's "Try again"; a returned promise keeps it busy. |
| `emptyState?` | `ReactNode` | `"EmptyState preset=\"no-results\""` | Replaces the no-results state (by default "No listings match" with "Clear filters" when onClearFilters is set). |
| `onSaveSearch?` | `() => (void \| Promise<unknown>)` |  | Shows a bell "Save search" button in the header (icon-only on narrow headers). A returned promise keeps it busy. |
| `savedSearch?` | `boolean` | `false` | The save button's saved state: "Search saved" with a ringing bell, aria-pressed. |
| `animateIn?` | `boolean` | `true` | New batches rise in with a 30ms stagger (the first 12 of each batch; a fade only under reduced motion). A replaced or re-sorted set never animates. |
| `locale?` | `string` | `""` | Formats the counts; defaults to the app's locale (UIProvider), then the browser's. Passed on to the cards. |
| `labels?` | `Partial<ListingGridLabels>` | `"DEFAULT_LISTING_GRID_LABELS"` | Every string, English by default: results (n, formatted), sort, view, grid, list, promoted, loadMore (n, formatted), remaining (n, formatted), showing (shown, total), loaded (n, formatted), loading, end, saveSearch, searchSaved, clearFilters, appliedFilters, removeFilter, cancel (the phone sort sheet), emptyTitle, emptyDescription, errorTitle, retry. |
| `classNames?` | `Partial<Record<"header" \| "promoted" \| "items" \| "item" \| "footer" \| "empty", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root, merged last. |

## Parts

### DEFAULT_LISTING_SORT_OPTIONS (constant)

The default sort menu: newest, price-asc, price-desc and relevance. Map their labels to translate, or filter the array to drop one.

### DEFAULT_LISTING_GRID_LABELS (constant)

The English copy of the header, load more, filter chips, empty and error states. Spread it into labels when you translate only a few keys.

### ListingGridRenderContext (type)

What renderItem gets next to the item: { view, index, promoted }. Promoted items always render as grid cards.

## Examples

- **List view, remembered**: Search-result rows with the seller line; switch to the grid and back, the choice survives a reload.
- **First load**: Eight skeletons with the cards' shape for 1.4s, then the listings rise in.
- **No results**: Filters that match nothing; remove a chip or clear them all to get listings back.
- **Error with retry**: The first load failed; “Try again” shows skeletons, then the listings.
- **Promoted block**: l02, l04 and two generated ads: a swipeable rail on a narrow grid, the first row on a wide one.
- **Load on click**: loadMode button with pages of 8: three clicks reach the end message.
- **Phone**: Two columns under a fake app bar; the header sticks below it and the next page loads as you scroll.

## See also

- [Listing card](https://wingo-ui.com/components/listing-card) ([Markdown](https://wingo-ui.com/components/listing-card.md))
- [Filter panel](https://wingo-ui.com/components/filter-panel) ([Markdown](https://wingo-ui.com/components/filter-panel.md))
- [Toolbar](https://wingo-ui.com/components/toolbar) ([Markdown](https://wingo-ui.com/components/toolbar.md))
- [Pagination](https://wingo-ui.com/components/pagination) ([Markdown](https://wingo-ui.com/components/pagination.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Virtual List](https://wingo-ui.com/components/virtual-list) ([Markdown](https://wingo-ui.com/components/virtual-list.md))

---

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