Xiaomi Redmi Note 12 Pro
Charger included
Portland, Downtown, 2.2 km away
Commerce
Listing grid
v1.0.0Added in 1.0.0
A React listing grid for marketplace results: count, sort, view switch and filter chips over listing cards that load more as you scroll.
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".
Listing grid is a Pro React component in the React commerce components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add listing-grid, together with Button, Empty State, Listing card, Pagination, Toolbar and 8 more and the npm packages lucide-react and motion.
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 listing-gridListing grid 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
Import ListingGrid from @/components/ui/listing-grid.
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" }}
/>
)
}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 }) => ReactNodeReplaces 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.
cardPropsPartial<ListingCardProps>Passed to every default card: the favorite handler, labels, the page's now, promotedStyle. Wins over the grid's own card settings.
defaultViewdefault "grid""grid" | "list"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) => voidCalled when the header's switch changes the view.
viewPersistKeydefault ""stringRemembers the view in localStorage under this key, so a visitor who prefers the list keeps it. Empty keeps it in memory.
showViewSwitchdefault truebooleanThe grid / list switch in the header: a segmented control on wide headers, one toggle button on narrow ones.
columnsdefault "{ base: 2, md: 3, lg: 4 }"ListingGridColumns = { base?; sm?; md?; lg?; xl? }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.
gapdefault "md""sm" | "md" | "lg"Space between cards: 8px; 12px (16px from 768px of grid width); 16px (24px from 768px).
cardSizedefault "auto""auto" | "sm" | "md" | "lg"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.
cardVariantdefault "elevated""elevated" | "outline" | "soft" | "ghost"The cards' surface; ghost drops it for a photo-first grid (fashion, Vinted style).
imageAspectdefault "4/3""1/1" | "4/3" | "3/2" | "16/10" | "3/4"The photo shape of every card and skeleton.
totaldefault 0numberThe 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.
defaultSortdefault "newest"stringThe starting sort while uncontrolled, one of sortOptions' values. The playground really sorts its 1.240 results.
sortstringThe controlled sort; pair it with onSortChange.
onSortChange(sort: string) => voidCalled with the picked value. Fetch the sorted first page and replace items; the grid scrolls its header back into view.
sortOptionsdefault "DEFAULT_LISTING_SORT_OPTIONS"{ value: string; label: ReactNode }[]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.
appliedFiltersToolbarAppliedFilter[]Removable chips under the header ({ id, label }: "Portland +25 km", "Price: $200 to $3,000"). filter-panel's describeFilters builds them.
onRemoveFilter(id: string) => voidCalled by a chip's ×.
onClearFilters() => voidAdds "Clear filters" after the chips and to the no-results state.
filterTriggerReactNodeThe phone "Filtre (3)" button that opens your filter sheet; hide it from 768px (md:hidden) when a sidebar takes over.
showHeaderdefault truebooleanThe header with the count, sort, view switch, save search and the chips.
stickyHeaderdefault falsebooleanThe header sticks under the top bar (top: var(--topbar-offset, 0px)) on glass, with a hairline once stuck. Scroll the playground to see it.
toolbarPropsPartial<ToolbarProps>Anything else for the header's Toolbar: size, radius, variant (surface for a raised panel), even a search field.
promotedListingData[]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.
loadingdefault falsebooleanFirst load: skeleton cards with the real card shape in place of the list (aria-busy on the list), and one announcement.
skeletonCountdefault 8numberSkeleton cards while loading.
hasMoredefault falsebooleanWhether 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.
loadingMoredefault falsebooleanThe next batch is loading (the footer button shows its spinner). Ignored without hasMore.
loadModedefault "auto""auto" | "button"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.
pageSizedefault 24numberListings per batch, for the button label ("Show 24 more", fewer near the end when total is known).
errorReactNodeAn 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.
emptyStatedefault "EmptyState preset="no-results""ReactNodeReplaces 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.
savedSearchdefault falsebooleanThe save button's saved state: "Search saved" with a ringing bell, aria-pressed.
animateIndefault truebooleanNew 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.
localedefault ""stringFormats the counts; defaults to the app's locale (UIProvider), then the browser's. Passed on to the cards.
labelsdefault "DEFAULT_LISTING_GRID_LABELS"Partial<ListingGridLabels>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.
classNamesPartial<Record<"header" | "promoted" | "items" | "item" | "footer" | "empty", string>>Extra classes for the inner parts.
classNamestringExtra classes for the root, merged last.
The default sort menu: newest, price-asc, price-desc and relevance. Map their labels to translate, or filter the array to drop one.
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.
What renderItem gets next to the item: { view, index, promoted }. Promoted items always render as grid cards.
Search-result rows with the seller line; switch to the grid and back, the choice survives a reload.
Eight skeletons with the cards' shape for 1.4s, then the listings rise in.
Filters that match nothing; remove a chip or clear them all to get listings back.
The first load failed; “Try again” shows skeletons, then the listings.
l02, l04 and two generated ads: a swipeable rail on a narrow grid, the first row on a wide one.
loadMode button with pages of 8: three clicks reach the end message.
Two columns under a fake app bar; the header sticks below it and the next page loads as you scroll.
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
Run npx wingo-ui@latest add listing-grid. It also adds the Wingo UI items it builds on (Button, Empty State, Listing card, Pagination, Toolbar and 8 more) and the npm packages lucide-react and motion. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/listing-grid.
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.
Build it: screens and apps10 min read
Northwind Market · All categories
router: /listings/portland · sort newest · view grid · 24/1240 loaded · hasMore true · cards auto · loadMode auto · view key none · animateIn true · sticky false · skeletons 8 · total live 1240 · favorite: none yet
Scroll to load more; remove a chip or sort to get a new set; tap a card to open it