# Listing card

> A React listing card for marketplaces: swipeable photos, price, place and time, badges and a heart, in grid, list and compact layouts.

- 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-card`
- Docs: https://wingo-ui.com/components/listing-card

## Interaction

Tap or click anywhere on the card to open the listing: the title is one link stretched over the whole card, so long-press and middle-click give the native link menu. Swipe the photos sideways on a phone (vertical scrolling always wins, and a swipe never opens the listing); with a mouse, drag them or use the arrows that appear on hover, and the photo zooms in a touch. Tap the heart to save the listing: it pops with a small burst, and rolls back with a shake if saving fails. Keyboard: Tab meets the title link, the photo arrows (they show while the card holds focus) and the heart once each; Left / Right change the photo while focus is on the photo's controls; Enter opens the listing, Space or Enter toggles the heart. Dots under the photo show where you are (at most five, sliding along); a camera chip tells how many photos there are when not all of them are in the card. The first photo is a plain image until you touch, hover or focus it, so a grid of 48 cards stays light; under reduced motion the photo does not zoom.

## Installation

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

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

Files:

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

npm dependencies: `npm i lucide-react`

Wingo UI dependencies (installed automatically):

- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Card](https://wingo-ui.com/components/card) ([Markdown](https://wingo-ui.com/components/card.md))
- [Carousel](https://wingo-ui.com/components/carousel) ([Markdown](https://wingo-ui.com/components/carousel.md))
- [Favorite button](https://wingo-ui.com/components/favorite-button) ([Markdown](https://wingo-ui.com/components/favorite-button.md))
- [Price](https://wingo-ui.com/components/price) ([Markdown](https://wingo-ui.com/components/price.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.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))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Navigation](https://wingo-ui.com/components/navigation) ([Markdown](https://wingo-ui.com/components/navigation.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { ListingCard, type ListingData } from "@/components/ui/listing-card"

const bike: ListingData = {
  id: "l01",
  title: "Trek FX 2 city bike",
  href: "/listings/l01",
  images: ["/demo/bicycle.jpg"],
  price: 280,
  currency: "USD",
  priceType: "negotiable",
  location: "Portland, Pearl District",
  postedAt: "2026-09-24T14:32:00",
  delivery: true,
  condition: "used",
  seller: { name: "Daniel Reed", verified: true, rating: 4.8 },
}

export function SearchResult({ onSave }: { onSave: (id: string, saved: boolean) => Promise<void> }) {
  return (
    <ListingCard
      listing={bike}
      size="sm"
      showSeller
      locale="en-US"
      onFavoriteChange={(saved, listing) => onSave(listing.id, saved)}
      labels={{ delivery: "Ships", price: { negotiable: "OBO" } }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `listing` | `ListingData` |  | The ad: { id, title, href?, images?: (string \| { src, alt?, width?, height? })[], price (null: on request), currency?, compareAtPrice?, priceType?, priceSuffix?, unit?: { quantity, unit }, location?, distanceKm?, postedAt?, promoted?, delivery?, condition?: "new" \| "used", attributes?: string[], badges?: { label, tone?, icon? }[], seller?: { name, avatar?, business?, verified?, rating? }, status?: "active" \| "reserved" \| "sold" \| "expired", favorite?, favoriteCount? }. |
| `layout?` | `"grid" \| "list" \| "compact"` | `"grid"` | grid: the photo on top (result grids). list: the photo at the start, a 112px square while the card is narrow and 240px wide in imageAspect once the card is 560px wide (search results). compact: a 64px thumbnail row with no carousel and no distance (recently viewed, a seller's other listings). ListingCardSkeleton (see Parts) has the same layouts for loading states. |
| `variant?` | `"elevated" \| "outline" \| "soft" \| "ghost"` | `"elevated"` | The card surfaces: a raised panel, a hairline frame or the flat gray tile. ghost has no surface: a rounded photo with the text under it, like a fashion grid. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Paddings, title and price size. sm is for two-column phone grids, lg for a featured listing. |
| `radius?` | `"sm" \| "md" \| "lg"` | `"md"` | Card corners: 12 / 16 / 24px. The photo of a list row or a ghost card takes the matching inner corner. |
| `imageAspect?` | `"1/1" \| "4/3" \| "3/2" \| "16/10" \| "3/4"` | `"4/3"` | The photo box in grid and list layouts (list rows stay square on phones). 3/4 suits fashion, 16/10 cars and homes. |
| `maxImages?` | `number` | `5` | Photos in the card's carousel. 1 shows a single photo with no swipe: use it for cards inside horizontal rails, which must never nest a carousel. When the listing has more photos, a camera chip shows the real total. |
| `showDots?` | `boolean` | `true` | Dots under the photo (at most five, sliding along with the photo), over a soft shade so they read on any image. |
| `showArrows?` | `boolean` | `true` | Previous / next buttons on the photo. They appear while the card is hovered or holds focus, on fine pointers only (phones swipe). |
| `showFavorite?` | `boolean` | `true` | The heart: round glass on the photo in the grid layout, a plain icon button at the end of list and compact rows. |
| `defaultFavorite?` | `boolean` | `false` | Starts saved (uncontrolled). Falls back to listing.favorite. |
| `favorite?` | `boolean` |  | Controlled saved state; pair it with onFavoriteChange. |
| `onFavoriteChange?` | `((favorite: boolean, listing: ListingData) => void \| Promise<unknown>)` |  | Called when the heart toggles. Return a promise to keep the heart in its new state while it is pending; a rejection rolls it back, shakes it and announces the error. |
| `favoriteTone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The saved heart's color. Neutral is white on photos and black / white elsewhere; danger gives the classic red heart. |
| `showBadges?` | `boolean` | `true` | The promoted chip on the photo and the small badges under the text: delivery, new or used, business seller and listing.badges. |
| `showAttributes?` | `boolean` | `true` | The attributes line ("2019 · 51,000 mi · Gasoline"): one line in grids, up to two in list rows. |
| `showSeller?` | `boolean` | `false` | The seller line: a small avatar, the name, a check when verified and the rating. In grids it sits at the bottom, over a hairline. |
| `promotedStyle?` | `"badge" \| "ring" \| "both"` | `"badge"` | How a promoted listing stands out: a labelled chip on the photo, a 2px ring in promotedTone around the card, or both. |
| `promotedTone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"warning"` | The promoted chip and ring color. Promotion is a status, so it defaults to the amber warning tone; the chip always says it in words. |
| `timeFormat?` | `"day" \| "relative" \| "date"` | `"day"` | day: "Today at 2:32 PM", "Yesterday at 9:10 AM", then "Sep 22" this year and the full date before. relative: "2 hours ago". date: the short numeric date. The full date and time is in the tooltip. formatListingTime (see Parts) prints the same text outside the card. |
| `priority?` | `boolean` | `false` | Loads the first photo eagerly with high fetch priority. Set it on the first row of a grid; every other photo loads lazily. |
| `locale?` | `string` | `""` | Formats the price, distance and time ("$1,200", "4.2 km", "2:32 PM" in en-US). Empty uses the UIProvider locale, then the page. |
| `href?` | `string` | `""` | Where the card leads; empty uses listing.href. Rendered through linkComponent, so a Next.js Link keeps client navigation. The playground's pretend router prints it. |
| `onSelect?` | `(listing: ListingData) => void` |  | Without an href, the title becomes a button that calls it (and a click anywhere on the card does too). Without both, the card is not interactive. |
| `actions?` | `ReactNode` |  | Controls at the top end of the text ("⋯" on your own listings, an edit button). They sit above the stretched link, so a click on them never opens the listing. |
| `now?` | `number \| null` | `"useNow()"` | The reference time in ms for "Today at 2:32 PM". Pass the page's clock (one useNow() in the grid) so the cards re-render with it; without it every card on the page shares one minute timer (not one each). null prints dates. |
| `linkComponent?` | `(props: LinkComponentProps) => ReactNode` |  | The router link for the title and the photos. Defaults to the one given to LinkProvider (lib/navigation), else a plain <a>. |
| `labels?` | `Partial<ListingCardLabels>` |  | Copy, English by default: promoted, delivery, new, used, business, verified, reserved, sold, expired, today, yesterday, at, photo(title, i, n) (alt text), photos(n), noImage, distance(formatted), previous, next, rating(formatted), plus price (forwarded PriceLabels) and favorite (forwarded FavoriteButtonLabels). Pass only the keys you translate. |
| `classNames?` | `Partial<Record<"media" \| "image" \| "carousel" \| "dots" \| "favorite" \| "body" \| "title" \| "price" \| "attributes" \| "meta" \| "tags" \| "seller" \| "actions" \| "status", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the card (the <article>), merged last. |

## Parts

### ListingCardSkeleton (component)

The loading placeholder with the card's exact shape: the photo block, a price bar, the title lines and a meta line. Takes layout, variant, size, radius and imageAspect like the card.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `layout?` | `"grid" \| "list" \| "compact"` | `"grid"` | Matches the card layout it stands in for. |
| `variant?` | `"elevated" \| "outline" \| "soft" \| "ghost"` | `"elevated"` | Matches the card surface. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | Matches the card size. |
| `radius?` | `"sm" \| "md" \| "lg"` | `"md"` | Matches the card corners. |
| `imageAspect?` | `"1/1" \| "4/3" \| "3/2" \| "16/10" \| "3/4"` | `"4/3"` | Aspect ratio of the photo block. |
| `lines?` | `number` | `3` | Text bars under the price: the title lines, then the meta line. |
| `announce?` | `boolean` | `true` | Wraps it in a role="status" region named by label. Turn off inside a grid that announces its own loading. |
| `label?` | `string` | `"Loading"` | The accessible name of the status region. |

### formatListingTime (function)

The card's time text as a function, for a detail page or a table: formatListingTime(value, { now, locale?, labels?, format? }) returns "Today at 2:32 PM", "Yesterday at 9:10 AM", "Sep 22" or the full date. With now null it prints the date, so server and browser agree.

### DEFAULT_LISTING_CARD_LABELS (constant)

The English copy of the card. Spread it into labels when you translate only a few keys.

### ListingData (type)

One listing: id, title, href, images, price, currency, priceType, location, distanceKm, postedAt, promoted, delivery, condition, attributes, badges, status and an optional seller.

## Examples

- **Grid**: l01 to l08: two columns on phones with size sm, four on desktop, the first row loads eagerly.
- **List**: Search-result rows with the attributes and the seller line; a square photo on phones.
- **Compact**: A “Recently viewed” rail: compact rows with one photo each, so no carousel nests in the rail.
- **Promoted**: The same promoted listing with the chip, the ring and both.
- **States**: Reserved, sold (the photo goes gray) and expired (the whole card dims), each with its chip.
- **Skeletons**: Four skeletons with the card's shape, then the cards after 1.4s.
- **Real estate**: A large card with six photos, the unit price and its EUR twin.
- **Ghost variant**: No surface, a 3:4 photo and a red heart: a fashion grid, two columns on phones, with a ⋯ action on your own listing.

## See also

- [Price](https://wingo-ui.com/components/price) ([Markdown](https://wingo-ui.com/components/price.md))
- [Favorite button](https://wingo-ui.com/components/favorite-button) ([Markdown](https://wingo-ui.com/components/favorite-button.md))
- [Card](https://wingo-ui.com/components/card) ([Markdown](https://wingo-ui.com/components/card.md))
- [Carousel](https://wingo-ui.com/components/carousel) ([Markdown](https://wingo-ui.com/components/carousel.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))

---

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