Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelogContact
Components
Buttons & Actions
ButtonButton GroupCopy ButtonDelete ButtonTheme ToggleToggleToggle Group
Inputs
Avatar UploadCalendarCheckboxColor PickerComboboxCountry SelectCron PickerCurrency InputDate PickerDate Range PickerDate Time PickerDuration PickerEmail InputEmoji PickerFile UploadFilter BuilderInputInput GroupListboxMasked InputMention InputMonth PickerMulti SelectNumber InputOTP InputPassword InputPhone InputRadio GroupRatingSearch InputSegmented ControlSelectSignature PadSliderSwitchTag InputTextareaTime PickerTimezone SelectTree Select
Forms
Address InputBooking WidgetCSV ImportFieldForm WizardInline EditInvoice line itemsRich Text EditorSchema Builder
Navigation
Back To TopBounce SidebarBreadcrumbCommand MenuGlobal SearchGooey NavHook SidebarNav MenuNav TabsPaginationProximity SidebarScroll ProgressSidebarStepsTable Of ContentsTabsTopbarUser MenuWorkspace Switcher
Overlays
Alert DialogContext MenuDialogDrawerDropdown MenuFamily DrawerHover CardPopoverShare DialogSheetShortcuts DialogTooltipTour
Feedback
AlertBannerCookie ConsentEmoji ReactionEmpty StateNotification BellNotification CenterProgressRoute ProgressSetup ChecklistSkeletonSpinnerToast
Data Display
Approval FlowAvatarBadgeCalendar ViewCode BlockComment ThreadContact cardCountdownDescription ListDiff ViewerFile ListFile managerFolderGanttJSON ViewerKbdLog ViewerMarkdownPresenceSchedulerTagTimelineTree View
Tables & Lists
Audit LogData TableDeal PipelineEditable GridKanbanListMembers tablePermissions MatrixTableTask ListVirtual List
Charts & Stats
Animated CounterArea chartBar chartDonut chartFunnel chartGaugeGitHub ActivityHeatmap calendarLeaderboardLine chartRadar chartRadial barSparklineStat CardTrackerTrend badge
Layout
AccordionApp ShellCardCollapsibleDetail LayoutEntity HeaderPage HeaderScroll AreaSeparatorSettings LayoutSplit ViewToolbar
Media
Audio PlayerCarouselCSV ViewerDOCX EditorDOCX ViewerExcel ViewerFile SystemImage compareImage CropperImage GalleryImage LightboxLocation MapPDF ViewerPowerPoint ViewerQR CodeStep PlayerVideo Player
AI Kit
Agent Status CardChat MessageChat ThreadChat WidgetConversation ListFluid OrbGrid RevealMatrix OrbModel PickerPrompt InputReasoning IndicatorRule BuilderSuggestion ChipsTool Call TimelineUsage MeterVoice InputWorkflow Canvas
Text & Effects
Background PatternGravity LettersMarqueeTerminalText AnimateText Rotate
Mobile
Action SheetBottom Tab BarFABHaptic ButtonHero HeaderInstall PromptLarge Title HeaderNotification BannerOnboarding CarouselPicker WheelPull To RefreshSlide To ConfirmSticky CTA BarSwipe DeckSwipeable Tabs
Commerce
Cart drawerCategory pickerCheckout stepsCoupon inputFavorite buttonFilter panelListing cardListing gridOrder trackerPricePrice rangePricing tableProduct galleryQuantity stepperReviewsSeller card
Marketing Sections
App Download SectionBlog Cards SectionCTA SectionFAQ SectionFeature GridFeature ShowcaseHero SectionIntegrations SectionLogo CloudMarketing FooterMarketing HeaderMarketing SectionNewsletter SectionPricing SectionStats SectionSteps SectionTeam SectionTestimonials SectionTimeline SectionVideo Section
Blocks
Admin Dashboard PageAdmin ShellAI Chat PageAPI Keys PageAutomation Builder PageCheckout PageCRM Contact PageCRM Pipeline PageError PagesInbox PageInvoice PageKPI DashboardListing Detail PageLogin PageMarketplace Home PageMarketplace Search PageMarketplace ShellMobile App HomeOnboarding FlowOrder History PagePost Listing WizardPricing PageProfile PageRegister PageSettings PageUsers Admin Page
Templates
About PageAcademy PageAccessibility PageAffiliate PageAgency PageAI Product PageAppointment Booking PageBlog PageBlog Post PageBug Bounty PageCareers PageChangelog PageCommunity PageCompare PageContact PageCustomer Story PageCustomers PageDemo Booking PageDevelopers PageDocs Home PageDownload PageEnterprise PageEvents PageExperts PageFAQ PageFeature PageGlossary PageHelp Article PageHelp Center PageHome Services PageImpact PageIntegration Detail PageIntegrations PageJob Detail PageLanding PageLaunch PageLegal PageManifesto PagePortfolio PagePress PageProduct Tour PagePromo PageReal Estate PageReferral PageResearch Report PageRestaurant PageRoadmap PageROI Calculator PageSecurity PageSolutions PageStartups PageStatus PageStorefront PageSwitch PageTemplate Gallery PageTravel PageWaitlist PageYear in Review Page
Hooks & Utilities
AI ChatChart kitCommerceDate utilsFile exportFile typesFiltersForm validationHapticsInvoicingMotion presetsNavigationNumber formatRelative timeRomanian locationsSpreadsheetText matchTonesUIProvideruseClipboarduseConsentuseControllableStateuseCooldownuseDebounceuseKeyboardInsetuseLocaleuseLongPressuseMediaControlsuseMediaQueryuseOverflowItemsusePersistedStateuseScrollEdgesuseScrollStateuseSelectionuseShortcutuseStickToBottom
GalleryTheme

Commerce

Listing grid

Commerce

Listing grid React component for Tailwind CSS v4

Listing cardOrder tracker
Live props
State
defaultView
defaultSort
loading
skeletonCount8
hasMore
loadingMore
savedSearch
Behavior
viewPersistKey
loadMode
pageSize24
animateIn
locale
Content
showViewSwitch
totalUnknown
showHeader
Layout
gap
stickyHeader
Appearance
cardSize
cardVariant
imageAspect
Code
import { ListingGrid } from "@/components/ui/listing-grid"

<ListingGrid />
  1. Home
  2. Components
  3. Commerce
  4. Listing grid
Pro

Listing grid React component for Tailwind CSS v4

v1.0.0·Added 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.

Add to a project

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-grid

Listing 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

Usage

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" }}
    />
  )
}

Props

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

  • 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) => void

    Called when the header's switch changes the view.

  • viewPersistKeydefault ""
    string

    Remembers the view in localStorage under this key, so a visitor who prefers the list keeps it. Empty keeps it in memory.

  • showViewSwitchdefault true
    boolean

    The 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 0
    number

    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.

  • defaultSortdefault "newest"
    string

    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.

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

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

  • showHeaderdefault true
    boolean

    The header with the count, sort, view switch, save search and the chips.

  • stickyHeaderdefault false
    boolean

    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.

  • loadingdefault false
    boolean

    First load: skeleton cards with the real card shape in place of the list (aria-busy on the list), and one announcement.

  • skeletonCountdefault 8
    number

    Skeleton cards while loading.

  • hasMoredefault false
    boolean

    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.

  • loadingMoredefault false
    boolean

    The 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 24
    number

    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.

  • emptyStatedefault "EmptyState preset="no-results""
    ReactNode

    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.

  • savedSearchdefault false
    boolean

    The save button's saved state: "Search saved" with a ringing bell, aria-pressed.

  • animateIndefault true
    boolean

    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.

  • localedefault ""
    string

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

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

Dependencies

lucide-reactmotionbuttonempty-statelisting-cardpaginationtoolbaruse-controllable-stateuse-localeuse-media-queryuse-persisted-stateuse-scroll-statemotionnumber-formatui-config

Files

  • components/ui/listing-grid.tsx

FAQ

Is the React Listing grid component free?

The preview, props and docs are free; copying the source needs a Pro plan. See plans.

How do I install Listing grid?

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.

How does Listing grid behave on phones?

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.

See also

Listing cardFilter panelToolbarPaginationEmpty StateVirtual List

Used in

Marketplace Search Page

More React commerce components

Pricing tableReviewsSeller cardQuantity stepperPriceCoupon input
All 16 React commerce components

Articles using this component

  • Nextjs Real Estate Template: Search, Map and Mortgage

    Build it: screens and apps · Nov 6, 2026 · 10 min read

PreviousListing cardNext Order tracker

Northwind Market · All categories

Listings in Portland

1,240 results
Portland +25 km
Price: $200 – $3,000
Ships
  • Xiaomi Redmi Note 12 Pro

    $1,620, Negotiable

    Charger included

    Portland, Downtown · , 2.2 km away·, Today at 4:30 PM

    DeliveryUsed
    Xiaomi Redmi Note 12 Pro, photo 1 of 2
  • iPhone 13, 128 GB

    $2,290

    Unlocked · 3-month warranty

    Portland, St. Johns · , 7.2 km away·, Today at 4:29 PM

    DeliveryUsed
    iPhone 13, 128 GB, photo 1 of 2
  • Schwinn city bike

    $1,050, Negotiable

    21 speeds · 29" wheels

    Portland, Buckman · , 6.9 km away·, Today at 4:29 PM

    DeliveryUsed
    Schwinn city bike, photo 1 of 3
  • Extendable wooden table

    $800, Negotiable

    Like new · Pickup only

    Portland, Alberta Arts · , 3.5 km away·, Today at 4:28 PM

    DeliveryUsed
    Extendable wooden table, photo 1 of 1
  • Kids bike, 20"

    $1,850

    29" wheels · Freshly tuned

    Portland, Laurelhurst · , 2.7 km away·, Today at 3:50 PM

    DeliveryUsed
    Kids bike, 20", photo 1 of 1
  • Triban RC 120 road bike

    $2,850, Negotiable

    Frame L · Freshly tuned

    Portland, Sellwood · , 5.1 km away·, Today at 3:34 PM

    DeliveryUsed
    Triban RC 120 road bike, photo 1 of 2
  • Sleeping bag, 15°F

    $1,380

    Bag included · Waterproof

    Portland, Hawthorne · , 3.7 km away·, Today at 3:22 PM

    DeliveryUsed
    Sleeping bag, 15°F, photo 1 of 2
  • Queen bed frame

    $950, Negotiable

    Assembly included · Like new

    Portland, St. Johns · , 500 m away·, Today at 3:20 PM

    DeliveryNew
    Queen bed frame, photo 1 of 1
  • Triban RC 120 road bike

    $350

    Disc brakes

    Beaverton · , 11 km away·, Today at 3:04 PM

    DeliveryUsed
    Triban RC 120 road bike, photo 1 of 3
  • Kids bike, 20"

    $2,650, Negotiable

    29" wheels

    Portland, Kerns · , 7.5 km away·, Today at 2:34 PM

    DeliveryUsed
    Kids bike, 20", photo 1 of 2
  • Trek FX 2 city bike

    $280, Negotiable

    28" wheels · 3 speeds · Frame M

    Portland, Pearl District · , 4.2 km away·, Today at 2:32 PM

    DeliveryUsed
    Trek FX 2 city bike, photo 1 of 2
  • Schwinn city bike

    $750

    21 speeds · Freshly tuned

    Portland, Downtown · , 5.9 km away·, Today at 2:31 PM

    DeliveryNew
    Schwinn city bike, photo 1 of 1
  • Rockrider ST 520 mountain bike

    $950, Negotiable

    Freshly tuned

    Portland, Alberta Arts · , 4.6 km away·, Today at 2:14 PM

    DeliveryUsed
    Rockrider ST 520 mountain bike, photo 1 of 3
  • Rockrider ST 520 mountain bike

    $800, Negotiable

    Freshly tuned

    Portland, Hawthorne · , 2.6 km away·, Today at 1:55 PM

    DeliveryUsed
    Rockrider ST 520 mountain bike, photo 1 of 3
  • MSR 2-person tent

    $1,060, Negotiable

    Bag included

    Portland, Downtown · , 1.2 km away·, Today at 1:44 PM

    DeliveryNew
    MSR 2-person tent, photo 1 of 2
  • Oak bookcase

    $2,400

    Solid wood · Pickup only

    Portland, Alberta Arts · , 700 m away·, Today at 1:41 PM

    DeliveryUsed
    Oak bookcase, photo 1 of 2
  • iPhone 13, 128 GB

    $820

    No scratches

    Portland, Pearl District · , 4.9 km away·, Today at 1:05 PM

    DeliveryNew
    iPhone 13, 128 GB, photo 1 of 1
  • Sleeping bag, 15°F

    $550, Negotiable

    Size M

    Portland, Pearl District · , 3.5 km away·, Today at 1:01 PM

    DeliveryUsed
    Sleeping bag, 15°F, photo 1 of 1
  • Kids bike, 20"

    $600, Negotiable

    21 speeds · Frame L

    Portland, Pearl District · , 1.2 km away·, Today at 12:54 PM

    DeliveryNew
    Kids bike, 20", photo 1 of 2
  • Extendable wooden table

    $1,500

    Pickup only · Washable fabric

    Portland, Buckman · , 1.6 km away·, Today at 12:52 PM

    DeliveryNew
    Extendable wooden table, photo 1 of 2
  • ASUS VivoBook 15 laptop

    $2,850

    US keyboard

    Portland, Kerns · , 3.7 km away·, Today at 12:46 PM

    DeliveryUsed
    ASUS VivoBook 15 laptop, photo 1 of 1
  • Sleeping bag, 15°F

    $1,750, Negotiable

    Bag included · Waterproof

    Beaverton · , 11 km away·, Today at 12:23 PM

    DeliveryUsed
    Sleeping bag, 15°F, photo 1 of 1
  • iPhone 13, 128 GB

    $750

    Unlocked · 3-month warranty

    Portland, St. Johns · , 3 km away·, Today at 11:59 AM

    DeliveryUsed
    iPhone 13, 128 GB, photo 1 of 1
  • Sleeping bag, 15°F

    $350, Negotiable

    Size M · Waterproof

    Portland, St. Johns · , 7.4 km away·, Today at 11:15 AM

    DeliveryUsed
    Sleeping bag, 15°F, photo 1 of 2

Showing 24 of 1,240

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