# Real estate

> A listing search page: results toolbar with bulk actions, listings as cards or rows, a map with price pins, valuation, energy class and the photo gallery.

Pattern · Pro kit · [Live page](https://ui.twentyfirst.media/pro/patterns/real-estate/)

A listing search page: results toolbar with bulk actions, listings as cards or rows, a map with price pins, valuation, energy class and the photo gallery.

## Code

```tsx
// ── ListingCard ──
import * as React from "react";
import { ListingCard, ResultsToolbar } from "@twentyfirstmedia/pro";
import type { Listing, StatusSelectProps } from "@twentyfirstmedia/pro";

const st: StatusSelectProps["options"] = [
  {
    value: "unread",
    label: "New",
    status: "running",
  },
  {
    value: "read",
    label: "Seen",
    status: "idle",
  },
  {
    value: "contacted",
    label: "Contacted",
    status: "ok",
  },
];
const L0: Listing[] = [
  {
    id: 1,
    title: "Bright 3-room flat with balcony near Isebek canal",
    price: 649000,
    pricePerSqm: 7726,
    area: 84,
    rooms: 3,
    place: "Eppendorf, Hamburg",
    source: "ImmoScout",
    seller: "private",
    phone: true,
    photos: 14,
    isNew: true,
    age: "2 h",
    status: "unread",
    tint: "sky",
  },
  {
    id: 2,
    title: "Loft in HafenCity with water view",
    price: 1190000,
    pricePerSqm: 10625,
    area: 112,
    rooms: 2.5,
    place: "HafenCity, Hamburg",
    source: "Kleinanzeigen",
    seller: "agent",
    photos: 22,
    age: "1 d",
    status: "contacted",
    tint: "lilac",
    wished: true,
  },
  {
    id: 3,
    title: "2-room flat, renovated old building",
    price: 1480,
    rent: true,
    area: 58,
    rooms: 2,
    place: "Altona, Hamburg",
    source: "Immowelt",
    seller: "agent",
    photos: 9,
    age: "3 d",
    status: "read",
    tint: "mint",
  },
];
function App() {
  const sel = React.useState<(string | number)[]>([1]),
    list = React.useState(L0),
    view = React.useState("grid"),
    sort = React.useState("new");
  function tog(x: Listing) {
    sel[1](sel[0].indexOf(x.id) > -1 ? sel[0].filter((i) => i !== x.id) : sel[0].concat([x.id]));
  }
  return (
    <div
      className="tf-col"
      style={{
        gap: 16,
      }}
    >
      <ResultsToolbar
        title="Hamburg · flats to buy"
        total={128}
        meta="updated 06:00"
        selected={sel[0].length}
        onSelectAll={(all) => {
          sel[1](all ? list[0].map((x) => x.id) : []);
        }}
        bulkActions={[
          {
            label: "Send to CRM",
            icon: "send",
            variant: "primary",
          },
          {
            label: "Mark as seen",
            icon: "eye",
          },
        ]}
        sortOptions={[
          {
            value: "new",
            label: "Newest",
          },
          {
            value: "price",
            label: "Price",
          },
          {
            value: "sqm",
            label: "Price per m²",
          },
        ]}
        sort={sort[0]}
        onSort={sort[1]}
        views={["grid", "list", "map"]}
        view={view[0]}
        onView={view[1]}
      />
      <div
        className="tf-grid"
        style={
          view[0] === "list"
            ? {
                display: "flex",
                flexDirection: "column",
                gap: 12,
              }
            : ({
                "--cols": "repeat(3, minmax(0, 1fr))",
                gap: 16,
              } as React.CSSProperties)
        }
      >
        {list[0].map((x) => (
          <ListingCard
            key={x.id}
            listing={x}
            layout={view[0] === "list" ? "row" : "card"}
            selected={sel[0].indexOf(x.id) > -1}
            onSelect={tog}
            onWish={() => {}}
            statusOptions={st}
            onStatus={(l, s) => {
              list[1](
                list[0].map((y) =>
                  y.id === l.id
                    ? Object.assign({}, y, {
                        status: s,
                      })
                    : y,
                ),
              );
            }}
          />
        ))}
      </div>
    </div>
  );
}
export default App;

// ── MapView ──
import * as React from "react";
import { IconButton, MapView } from "@twentyfirstmedia/pro";
import type { MapPoint } from "@twentyfirstmedia/pro";

const world: MapPoint[] = [
  {
    id: "de",
    label: "Germany",
    lat: 51,
    lng: 10,
    value: 820,
    sub: "820 orders today",
  },
  {
    id: "at",
    label: "Austria",
    lat: 47.5,
    lng: 14.5,
    value: 260,
    tint: "mint",
  },
  {
    id: "ch",
    label: "Switzerland",
    lat: 46.8,
    lng: 8.2,
    value: 210,
    tint: "mint",
  },
  {
    id: "us",
    label: "United States",
    lat: 39,
    lng: -98,
    value: 140,
    tint: "butter",
  },
  {
    id: "ae",
    label: "UAE",
    lat: 24,
    lng: 54,
    value: 90,
    tint: "lilac",
    pulse: true,
  },
  {
    id: "br",
    label: "Brazil",
    lat: -12,
    lng: -52,
    value: 60,
    tint: "blush",
  },
];
const city: MapPoint[] = [
  {
    id: 1,
    label: "3-room flat, Eppendorf",
    sub: "€649,000 · 84 m²",
    lat: 53.59,
    lng: 9.98,
    text: "€649k",
  },
  {
    id: 2,
    label: "Loft, HafenCity",
    sub: "€1,190,000 · 112 m²",
    lat: 53.541,
    lng: 10.0,
    text: "€1.19m",
    tint: "lilac",
  },
  {
    id: 3,
    label: "2-room flat, Altona",
    sub: "€1,480 / month",
    lat: 53.55,
    lng: 9.935,
    text: "€1,480",
    tint: "mint",
  },
  {
    id: 4,
    label: "House, Blankenese",
    lat: 53.565,
    lng: 9.81,
    text: "€2.4m",
    tint: "butter",
  },
];
export default function Preview() {
  return (
    <div
      className="tf-grid"
      style={
        {
          "--cols": "1fr 1fr",
          gap: 16,
        } as React.CSSProperties
      }
    >
      <div
        className="tf-panel"
        style={{
          gap: 12,
        }}
      >
        <b>Orders by country</b>
        <MapView bubbles points={world} height={360} legend="Bubble size = orders today" />
      </div>
      <div
        className="tf-panel"
        style={{
          gap: 12,
        }}
      >
        <b>Listings</b>
        <MapView
          points={city}
          bounds={{
            n: 53.62,
            s: 53.52,
            w: 9.78,
            e: 10.06,
          }}
          height={360}
          selected={2}
          controls={[
            <IconButton key={1} icon="plus" label="Zoom in" size="sm" />,
            <IconButton key={2} icon="minus" label="Zoom out" size="sm" />,
          ]}
        />
      </div>
    </div>
  );
}

// ── ValuationCard ──
import * as React from "react";
import { EnergyClass, SavedSearch, ValuationCard } from "@twentyfirstmedia/pro";

export default function Preview() {
  return (
    <div
      className="tf-grid"
      style={
        {
          "--cols": "1fr 1fr",
          gap: 16,
          alignItems: "start",
        } as React.CSSProperties
      }
    >
      <div className="tf-panel">
        <ValuationCard
          value={612000}
          low={571000}
          high={655000}
          asking={649000}
          perSqm={7286}
          compare="district average €6,940"
          confidence={0.78}
          facts={[
            ["Comparables", "42 sales, last 18 months"],
            ["Method", "Comparable sales"],
            ["As of", "Nov 8, 2026"],
          ]}
          note="An estimate, not an appraisal."
        />
      </div>
      <div
        className="tf-col"
        style={{
          gap: 16,
        }}
      >
        <SavedSearch
          name="Eppendorf 3 rooms"
          criteria={[
            "Buy",
            "Hamburg-Eppendorf",
            "€400k–700k",
            "70–110 m²",
            "3+ rooms",
            "Private sellers",
          ]}
          lastRun="06:00"
          frequency="Daily mail"
          newCount={4}
          onToggle={() => {}}
          onEdit={() => {}}
          onOpen={() => {}}
        />
        <div
          className="tf-panel"
          style={{
            gap: 12,
          }}
        >
          <b>Energy</b>
          <EnergyClass value="C" kwh={92} kind="Consumption · gas" />
        </div>
      </div>
    </div>
  );
}

// ── Gallery ──
import * as React from "react";
import { BeforeAfter, Gallery, ImageVariants } from "@twentyfirstmedia/pro";
import type { Tint } from "@twentyfirstmedia/pro";

const art = (t: Tint, s: string) => (
  <div
    style={{
      position: "absolute",
      inset: 0,
      background: "linear-gradient(160deg, var(--" + t + ") 0%, var(--surface) 120%)",
      display: "grid",
      placeItems: "center",
      color: "var(--" + t + "-ink)",
      font: "var(--type-title-weight) 14px/1 var(--font-sans)",
    }}
  >
    {s}
  </div>
);
const before = {
  art: (
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: "linear-gradient(180deg, #ddd8cf 0 62%, #b9ad9c 62%)",
      }}
    />
  ),
};
const after = {
  art: (
    <div
      style={{
        position: "absolute",
        inset: 0,
        background: "linear-gradient(180deg, #e9e2d6 0 62%, #a68b6a 62%)",
      }}
    >
      <div
        style={{
          position: "absolute",
          left: "18%",
          bottom: "22%",
          width: "38%",
          height: "22%",
          borderRadius: "var(--radius-md)",
          background: "#5b6b5a",
        }}
      />
      <div
        style={{
          position: "absolute",
          right: "16%",
          bottom: "38%",
          width: "14%",
          height: "30%",
          borderRadius: "var(--radius-sm)",
          background: "#3d3a36",
        }}
      />
    </div>
  ),
};
export default function Preview() {
  return (
    <div
      className="tf-grid"
      style={
        {
          "--cols": "1fr 1fr",
          gap: 16,
          alignItems: "start",
        } as React.CSSProperties
      }
    >
      <div
        className="tf-panel"
        style={{
          gap: 12,
        }}
      >
        <Gallery
          badge="Virtual staging"
          items={[
            {
              art: art("sky", "Living room"),
              caption: "Living room",
            },
            {
              art: art("mint", "Kitchen"),
            },
            {
              art: art("butter", "Bedroom"),
            },
            {
              art: art("lilac", "Balcony"),
            },
            {
              art: art("blush", "Bathroom"),
            },
          ]}
        />
      </div>
      <div
        className="tf-col"
        style={{
          gap: 16,
        }}
      >
        <div
          className="tf-panel"
          style={{
            gap: 12,
          }}
        >
          <b>Virtual staging</b>
          <BeforeAfter before={before} after={after} afterLabel="Staged · Scandinavian" />
        </div>
        <div className="tf-panel">
          <ImageVariants
            tint="sky"
            variants={[
              {
                id: 1,
                label: "Original",
                size: "4032 × 3024",
                format: "JPEG",
                weight: "3.8 MB",
                ratio: "4 / 3",
              },
              {
                id: 2,
                label: "Listing",
                size: "1600 × 1200",
                format: "WebP",
                weight: "214 KB",
                ratio: "4 / 3",
                status: "ok",
              },
              {
                id: 3,
                label: "Square",
                size: "1080 × 1080",
                format: "WebP",
                weight: "168 KB",
                ratio: "1 / 1",
                status: "ok",
              },
              {
                id: 4,
                label: "Upscaled 2×",
                size: "8064 × 6048",
                format: "PNG",
                status: "running",
                ratio: "4 / 3",
              },
            ]}
          />
        </div>
      </div>
    </div>
  );
}
```

## Props

### ListingCard

| Prop | Type | Notes |
| --- | --- | --- |
| `listing` | `Listing` | Required |
| `layout?` | `"card" \| "row"` |  |
| `selected?` | `boolean` |  |
| `onSelect?` | `(l: Listing) => void` |  |
| `onWish?` | `(l: Listing) => void` |  |
| `statusOptions?` | `StatusSelectProps["options"]` |  |
| `onStatus?` | `(l: Listing, status: string) => void` |  |
| `onOpen?` | `(l: Listing) => void` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### ResultsToolbar

| Prop | Type | Notes |
| --- | --- | --- |
| `title?` | `string` |  |
| `total` | `number` | Required |
| `meta?` | `string` |  |
| `selected?` | `number` |  |
| `onSelectAll?` | `(all: boolean) => void` |  |
| `bulkActions?` | `{ label: string; icon?: IconName; variant?: ButtonProps["variant"]; busy?: boolean; busyLabel?: string; onClick?: () => void; }[]` |  |
| `sortOptions?` | `{ value: string; label: string }[]` |  |
| `sort?` | `string` |  |
| `onSort?` | `(v: string) => void` |  |
| `views?` | `("grid" \| "list" \| "map")[]` |  |
| `view?` | `string` |  |
| `onView?` | `(v: string) => void` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### IconButton

| Prop | Type | Notes |
| --- | --- | --- |
| `icon` | `IconName` | Required |
| `label` | `string` | Accessible name and tooltip (`aria-label` and `title`); `aria-label` or `title` override them. |
| `variant?` | `"soft" \| "solid" \| "outline" \| "ghost"` |  |
| `size?` | `"sm" \| "md" \| "lg"` |  |
| `type?` | `"button" \| "submit" \| "reset"` |  |
| `onClick?` | `(e: MouseEvent<HTMLButtonElement>) => void` |  |
| `disabled?` | `boolean` |  |
| `asChild?` | `boolean` | Render the single child element (a link) with the button's look; the icon is placed inside it. |
| `children?` | `ReactNode` |  |
| `id?` | `string` |  |
| `name?` | `string` | Form field name and value, sent with the form when this button submits it. |
| `value?` | `string` |  |
| `form?` | `string` | The id of the form this button belongs to, when it sits outside it. |
| `title?` | `string` |  |
| `tabIndex?` | `number` |  |
| `autoFocus?` | `boolean` |  |
| `aria-label?` | `string` |  |
| `aria-labelledby?` | `string` |  |
| `aria-describedby?` | `string` |  |
| `aria-expanded?` | `boolean \| "true" \| "false"` | For disclosure and menu triggers: whether what the button controls is open. |
| `aria-controls?` | `string` |  |
| `aria-haspopup?` | `boolean \| "true" \| "false" \| "menu" \| "listbox" \| "tree" \| "grid" \| "dialog"` |  |
| `aria-pressed?` | `boolean \| "true" \| "false" \| "mixed"` | For toggle buttons. |
| `aria-current?` | `boolean \| "true" \| "false" \| "page" \| "step" \| "location" \| "date" \| "time"` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### MapView

| Prop | Type | Notes |
| --- | --- | --- |
| `points?` | `MapPoint[]` |  |
| `bounds?` | `{ n: number; s: number; w: number; e: number }` |  |
| `background?` | `ReactNode` |  |
| `bubbles?` | `boolean` |  |
| `selected?` | `MapPoint["id"] \| null` |  |
| `onSelect?` | `(id: MapPoint["id"] \| null) => void` |  |
| `renderPopup?` | `(p: MapPoint) => ReactNode` |  |
| `legend?` | `ReactNode` |  |
| `controls?` | `ReactNode` |  |
| `height?` | `number \| string` | Height in px (default 320) or any CSS length, e.g. "100%" to fill a FloatingLayout. |
| `label?` | `string` |  |
| `surface?` | `Surface` | `"glass"`: the legend, controls and popup float as glass over the map (for a live map in `background`). |
| `className?` | `string` |  |
| `style?` | `CSSProperties` | Inline style on the root element. |

### EnergyClass

| Prop | Type | Notes |
| --- | --- | --- |
| `value` | `string` | Required |
| `kwh?` | `number` |  |
| `kind?` | `string` |  |
| `scale?` | `string[]` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### SavedSearch

| Prop | Type | Notes |
| --- | --- | --- |
| `name` | `string` | Required |
| `criteria?` | `ReactNode[]` |  |
| `lastRun?` | `string` |  |
| `frequency?` | `string` |  |
| `newCount?` | `number` |  |
| `enabled?` | `boolean` |  |
| `onToggle?` | `() => void` |  |
| `onEdit?` | `() => void` |  |
| `onOpen?` | `() => void` |  |
| `openLabel?` | `string` |  |
| `icon?` | `IconName` |  |
| `tint?` | `Tint` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### ValuationCard

| Prop | Type | Notes |
| --- | --- | --- |
| `value` | `number` | Required |
| `low` | `number` | Required |
| `high` | `number` | Required |
| `asking?` | `number` |  |
| `perSqm?` | `number` |  |
| `compare?` | `string` |  |
| `currency?` | `string` |  |
| `confidence?` | `number` |  |
| `confidenceLabel?` | `string` |  |
| `facts?` | `KeyValueItem[]` |  |
| `note?` | `ReactNode` |  |
| `title?` | `string` |  |
| `action?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### BeforeAfter

| Prop | Type | Notes |
| --- | --- | --- |
| `before` | `MediaPane` | Required |
| `after` | `MediaPane` | Required |
| `start?` | `number` |  |
| `ratio?` | `string` |  |
| `beforeLabel?` | `string` |  |
| `afterLabel?` | `string` |  |
| `label?` | `string` | Accessible name of the slider (default "Compare before and after"). |
| `valueText?` | `(percent: number) => string` | What screen readers hear for a position (0–100, the share of "before"). Default: "Before 40%, After 60%", with your `beforeLabel`/`afterLabel`. |
| `loading?` | `"lazy" \| "eager"` | Loading of both images (default `"lazy"`). |
| `referrerPolicy?` | `ImageReferrerPolicy` | `referrerPolicy` of both images. |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### Gallery

| Prop | Type | Notes |
| --- | --- | --- |
| `items` | `GalleryItem[]` | Required |
| `loading?` | `"lazy" \| "eager"` | Loading of the main image: `"lazy"` (default) waits until the gallery is near the viewport; use `"eager"` for a gallery at the top of the page. Thumbnails always load lazily. |
| `referrerPolicy?` | `ImageReferrerPolicy` | `referrerPolicy` of every image, e.g. `"no-referrer"` for photos from other hosts. |
| `start?` | `number` | First image when uncontrolled. |
| `index?` | `number` | The shown image (controlled, 0-based), with `onIndexChange`. |
| `onIndexChange?` | `(index: number) => void` |  |
| `label?` | `string` | Accessible name of the gallery (default "Images"), e.g. the listing's title. |
| `ratio?` | `string` |  |
| `thumbs?` | `boolean` |  |
| `fullscreen?` | `boolean` |  |
| `badge?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |

### ImageVariants

| Prop | Type | Notes |
| --- | --- | --- |
| `variants` | `ImageVariant[]` | Required |
| `selected?` | `string \| number` |  |
| `onSelect?` | `(id: string \| number) => void` |  |
| `alt?` | `string` |  |
| `tint?` | `Tint` |  |
| `actions?` | `ReactNode` |  |
| `className?` | `string` | Extra class on the root element. |
| `style?` | `CSSProperties` | Inline style on the root element. |
