PropertyCard
A card component for displaying property listings with image carousel, details, and wishlist functionality.
pnpm add @wandercom/design-system-web
import { useState } from 'react';
import { PropertyCard } from '@wandercom/design-system-web/blocks/property-card';
export function Example() {
const [isWishlisted, setIsWishlisted] = useState(false);
return (
<PropertyCard
name="Home in Newport beach"
images={[
{ src: "/img1.jpg", alt: "Front view" },
{ src: "/img2.jpg", alt: "Interior" },
]}
price={1060}
nights={2}
rating={4.8}
features={{ bedrooms: 4, beds: 6, baths: 4 }}
href="/property/newport-beach"
isWishlisted={isWishlisted}
onWishlistClick={() => setIsWishlisted(!isWishlisted)}
/>
);
}Basic property card with rating, price, and features.
<PropertyCard
name="Home in Newport beach"
images={images}
price={1060}
nights={2}
rating={4.8}
features={{ bedrooms: 4, beds: 6, baths: 4 }}
href="https://wander.com"
target="_blank"
isWishlisted={isWishlisted}
onWishlistClick={() => setIsWishlisted(!isWishlisted)}
/>When a location is provided, it appears above the property name.
<PropertyCard
name="Oceanfront Villa"
location="Malibu, California"
images={images}
price={2450}
nights={2}
rating={4.9}
features={{ bedrooms: 5, beds: 7, baths: 5 }}
href="#"
isWishlisted={isWishlisted}
onWishlistClick={() => setIsWishlisted(!isWishlisted)}
/>Provide a description to show a short tagline below the property name and above the features.
<PropertyCard
name="Wander Forest Retreat"
description="Forest views with a hot tub and deck"
images={images}
price={1060}
nights={2}
rating={4.8}
features={{ bedrooms: 4, beds: 6, baths: 4 }}
href="#"
isWishlisted={isWishlisted}
onWishlistClick={() => setIsWishlisted(!isWishlisted)}
/>When no rating is provided, the card displays "New" with a star icon.
<PropertyCard
name="Mountain Retreat"
location="Aspen, Colorado"
images={images}
price={850}
nights={1}
features={{ bedrooms: 3, beds: 4, baths: 2 }}
href="#"
isWishlisted={isWishlisted}
onWishlistClick={() => setIsWishlisted(!isWishlisted)}
/>Use the badge prop to overlay any node in the image's top-left corner. Today this slot is used to render a tier badge, but it accepts any ReactNode so consumers can render their own component. Style the wrapper via slots.badge.
import { Badge } from '@wandercom/design-system-web/ui/badge';
<PropertyCard
name="Home in Newport beach"
badge={<Badge variant="info">Tier 1</Badge>}
images={images}
price={1060}
nights={2}
rating={4.8}
features={{ bedrooms: 4, beds: 6, baths: 4 }}
href="#"
isWishlisted={isWishlisted}
onWishlistClick={() => setIsWishlisted(!isWishlisted)}
/>Set isSwipeable={false} to disable touch swiping on the image carousel. The progress bar is hidden when the card container is narrower than 65rem (1040px), but remains visible in wider cards where arrow navigation is available.
<PropertyCard
name="Home in Newport beach"
images={images}
price={1060}
nights={2}
rating={4.8}
features={{ bedrooms: 4, beds: 6, baths: 4 }}
href="#"
isSwipeable={false}
isWishlisted={isWishlisted}
onWishlistClick={() => setIsWishlisted(!isWishlisted)}
/>Set showWishlist={false} to hide the wishlist button entirely. Useful for contexts where wishlisting isn't available.
<PropertyCard
name="Beachfront Bungalow"
images={images}
price={1200}
href="#"
showWishlist={false}
/>Set showRating={false} to hide the rating and star icon entirely, including the "New" fallback state. Useful when rating data is unavailable or not relevant.
<PropertyCard
name="Home in Newport beach"
images={images}
price={1060}
nights={2}
href="#"
showRating={false}
isWishlisted={isWishlisted}
onWishlistClick={() => setIsWishlisted(!isWishlisted)}
/>Use the asChild prop to compose with routing libraries like Next.js Link.
import Link from 'next/link';
<PropertyCard
name="Home in Newport beach"
images={[{ src: "/img.jpg", alt: "Property" }]}
price={1060}
nights={2}
asChild
>
<Link href="/property/newport-beach" />
</PropertyCard>The image carousel follows the WAI-ARIA Carousel Pattern (basic, non-auto-rotating variant).
Carousel ARIA structure
- Carousel container uses
role="region"witharia-roledescription="carousel"and an accessible label derived from the property name - Each slide uses
role="group"witharia-roledescription="slide"and anaria-labelindicating position (e.g., "Front view (1 of 3)") - Non-visible slides are hidden from the accessibility tree via
aria-hidden - The slides container uses
aria-live="polite"so screen readers announce slide changes
Keyboard navigation
ArrowLeft/ArrowRightnavigate between slides when the primary property action is focusedTabmoves focus through the primary property action, wishlist, previous/next buttons, and optional tooltip- Previous/next buttons become visible on focus for keyboard discoverability
Touch navigation
- Swipe gestures that begin over the image navigate the carousel when
isSwipeableis enabled - Swiping an actionable card does not activate its primary link or button
- Cards without a primary action remain swipeable directly from the image carousel
Rendering semantics
- The card root always renders as a non-interactive
<article> hrefrenders a dedicated primary link named by the property title and location when availableonClickwithouthrefrenders a dedicated primary button with the same naming behaviorprimaryActionLabeloverrides the generated accessible name when cards need additional context to distinguish similar propertiesasChildcomposes the dedicated primary action with routing components such as Next.jsLink- Wishlist, carousel, tooltip, and interactive badge controls render outside the primary action
- Wishlist button has
aria-pressedstate and dynamicaria-labelreflecting wishlisted state - Decorative elements (star icon, progress indicator) are hidden from the accessibility tree
Consumer compatibility
- Existing
href,target,onClick,asChild,className, and slot-based integrations remain supported hrefandonClickcan still be used together, and callbacks that ignore the click event remain source-compatible- Links opened with
target="_blank"continue to receiverel="noopener noreferrer" asChildpreserves the custom action's routing props, class name, ref, and event handlersclassNameandslots.rootcontinue to style the full card layout- The root element is intentionally no longer the primary
<a>or<button>, preventing nested interactive controls
name:
primaryActionLabel?:
images:
badge?:
price?:
currency?:
locale?:
nights?:
rating?:
features?:
location?:
description?:
href?:
target?:
onClick?:
asChild?:
isSwipeable?:
showRating?:
showWishlist?:
isWishlisted?:
onWishlistClick?:
slots?:
className?:
type PropertyCardImage = {
src: string;
alt: string;
thumbhash?: string; // Optional blur placeholder hash
};type PropertyCardFeatures = {
bedrooms: number;
beds: number;
baths: number;
};type PropertyCardSlots = {
root?: string; // Root article
primaryAction?: string; // Primary property link or button
image?: string; // Image container
badge?: string; // Badge container (top-left overlay)
content?: string; // Content container
progress?: string; // Progress bar container
nav?: string; // Navigation arrow buttons
};