# Property 2

> Full property listing with status tabs, district filter, sort and pagination (from Marbrook)

A shadcn/ui block from the [@cnippet-blocks](https://blocks.cnippet.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/cnippet-blocks/property-2).

- Page, with a live preview: https://shadcn.exchange/r/cnippet-blocks/property-2
- Screenshot: https://shadcn.exchange/shot/cnippet-blocks/property-2/light.png
- Registry item: https://blocks.cnippet.dev/r/property-2.json
- Categories: property
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/cnippet-blocks/property-2.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `@cnippet/badge`, `@cnippet/button`, `@cnippet/empty`, `@cnippet/pagination`, `@cnippet/select`, `@cnippet/tabs`, `@cnippet/toggle`, `@cnippet/tooltip`, `@cnippet/utils`

## Files

### `registry/default/sections/property-2/index.tsx`

```tsx
"use client";

import { SlidersHorizontal } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";

import { Button } from "@/components/ui/button";
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  Pagination,
  PaginationContent,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";

import type { Surface } from "../_types/common";
import type { PropertyListingContent } from "../_types/property";
import { defaultProperty2Content } from "./data";
import { PropertyCard } from "./property-card";
import { SectionHeader, surfaceClass, toneFor } from "./section-header";

const ALL = "all";

const pageLinkClasses =
  "flex size-9 items-center justify-center rounded-md border border-transparent text-muted-foreground text-sm transition-colors hover:bg-background hover:text-primary";
const pageLinkActiveClasses = "border-input bg-background text-primary";
const pageStepClasses =
  "flex h-9 items-center gap-1.5 px-3 text-muted-foreground text-sm transition-colors hover:text-primary";

/** `"$4,250,000"` and `"$9,400 / mo"` both reduce to a comparable integer. */
function priceValue(price: string): number {
  return Number(price.replace(/[^\d]/g, "")) || 0;
}

/** The full book: status tabs, a district filter, sorting and pagination. */
export default function Property2({
  content = defaultProperty2Content,
  surface = "muted",
}: {
  content?: PropertyListingContent;
  surface?: Surface;
}) {
  const [status, setStatus] = useState(ALL);
  const [district, setDistrict] = useState(ALL);
  const [sort, setSort] = useState(content.sorts[0]?.value ?? "newest");
  const [page, setPage] = useState(1);

  const matched = useMemo(() => {
    const filtered = content.properties.filter(
      (property) =>
        (status === ALL || property.category === status) &&
        (district === ALL || property.district === district),
    );

    return [...filtered].sort((a, b) => {
      if (sort === "price-desc")
        return priceValue(b.price) - priceValue(a.price);
      if (sort === "price-asc")
        return priceValue(a.price) - priceValue(b.price);
      if (sort === "area-desc") return b.area - a.area;
      return (b.year ?? 0) - (a.year ?? 0);
    });
  }, [content.properties, district, sort, status]);

  const pageCount = Math.max(1, Math.ceil(matched.length / content.perPage));
  const current = Math.min(page, pageCount);
  const visible = matched.slice(
    (current - 1) * content.perPage,
    current * content.perPage,
  );

  /** Every filter change goes back to page one — page 4 of a two-page result
   *  is the classic way to land someone on an empty grid. */
  function update(setter: (value: string) => void) {
    return (value: unknown) => {
      setter(String(value));
      setPage(1);
    };
  }

  function reset() {
    setStatus(ALL);
    setDistrict(ALL);
    setSort(content.sorts[0]?.value ?? "newest");
    setPage(1);
  }

  const sortLabel =
    content.sorts.find((option) => option.value === sort)?.label ??
    content.sorts[0]?.label;
  const districtLabel =
    content.districts.find((item) => item.value === district)?.label ??
    content.districtsAll;

  return (
    <section className={cn("py-20 sm:py-28", surfaceClass[surface])}>
      <div className="mx-auto max-w-7xl px-5 sm:px-8 lg:px-12">
        <SectionHeader
          action={
            <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
              <Tabs onValueChange={update(setStatus)} value={status}>
                <TabsList
                  className="w-full gap-0 border border-border bg-background p-0 sm:w-fit"
                  variant="default"
                >
                  {content.statuses.map((item) => (
                    <TabsTab
                      className="h-11 px-4 text-muted-foreground text-sm data-active:text-primary sm:h-11 sm:text-sm"
                      key={item.value}
                      value={item.value}
                    >
                      {item.label}
                    </TabsTab>
                  ))}
                </TabsList>
              </Tabs>

              <Select onValueChange={update(setDistrict)} value={district}>
                <SelectTrigger
                  aria-label="Filter by district"
                  className="h-11 min-h-0 w-full border-border bg-background text-primary text-sm shadow-none before:shadow-none sm:h-11 sm:w-56 sm:text-sm"
                >
                  <SelectValue>{districtLabel}</SelectValue>
                </SelectTrigger>
                <SelectPopup>
                  <SelectItem value={ALL}>{content.districtsAll}</SelectItem>
                  {content.districts.map((item) => (
                    <SelectItem key={item.value} value={item.value}>
                      {item.label}
                    </SelectItem>
                  ))}
                </SelectPopup>
              </Select>
            </div>
          }
          actionAt="lg"
          className="border-border border-b pb-8"
          description={content.description}
          eyebrow={content.eyebrow}
          title={content.title}
          tone={toneFor(surface)}
        />

        <div className="mt-8 flex flex-wrap items-center justify-between gap-4">
          <p className="text-muted-foreground text-sm">
            {matched.length === 0
              ? "No matching homes"
              : matched.length === 1
                ? "1 home matches"
                : `${matched.length} homes match`}
            {matched.length > 0 && pageCount > 1 && (
              <span className="text-muted-foreground/70">
                {" "}
                · page {current} of {pageCount}
              </span>
            )}
          </p>

          <div className="flex items-center gap-2 text-muted-foreground text-sm">
            <SlidersHorizontal className="size-4" />
            <Select onValueChange={update(setSort)} value={sort}>
              <SelectTrigger
                aria-label="Sort listings"
                className="h-9 min-h-0 w-48 border-border bg-background text-primary text-sm shadow-none before:shadow-none sm:h-9 sm:text-sm"
              >
                <SelectValue>{sortLabel}</SelectValue>
              </SelectTrigger>
              <SelectPopup>
                {content.sorts.map((option) => (
                  <SelectItem key={option.value} value={option.value}>
                    {option.label}
                  </SelectItem>
                ))}
              </SelectPopup>
            </Select>
          </div>
        </div>

        {visible.length > 0 ? (
          <div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
            {visible.map((property, index) => (
              <PropertyCard
                key={property.href}
                priority={index < 3}
                property={property}
              />
            ))}
          </div>
        ) : (
          <Empty className="mt-8 rounded-lg border border-border border-dashed bg-card py-16">
            <EmptyTitle className="font-display font-normal text-2xl text-primary">
              {content.empty.title}
            </EmptyTitle>
            <EmptyDescription>{content.empty.description}</EmptyDescription>
            <EmptyContent className="mt-6 flex flex-row flex-wrap justify-center gap-3">
              {content.empty.actions?.map((action) => (
                <Button
                  className="border-none bg-primary px-7 text-primary-foreground shadow-none hover:bg-primary/90"
                  key={action.href}
                  render={<Link href={action.href} />}
                  size="xl"
                >
                  {action.label}
                </Button>
              ))}
              <Button
                className="border-input bg-background px-7 text-primary shadow-none before:shadow-none hover:bg-muted"
                onClick={reset}
                size="xl"
                variant="outline"
              >
                {content.resetLabel}
              </Button>
            </EmptyContent>
          </Empty>
        )}

        {pageCount > 1 && (
          <Pagination className="mt-12 border-border border-t pt-8">
            <PaginationContent className="gap-1">
              <PaginationItem>
                <PaginationPrevious
                  className={cn(
                    pageStepClasses,
                    current === 1 && "pointer-events-none opacity-40",
                  )}
                  render={
                    <button
                      disabled={current === 1}
                      onClick={() => setPage(current - 1)}
                      type="button"
                    />
                  }
                />
              </PaginationItem>

              {Array.from({ length: pageCount }, (_, index) => index + 1).map(
                (number) => (
                  <PaginationItem key={number}>
                    <PaginationLink
                      className={cn(
                        pageLinkClasses,
                        number === current && pageLinkActiveClasses,
                      )}
                      isActive={number === current}
                      render={
                        <button onClick={() => setPage(number)} type="button" />
                      }
                    >
                      {number}
                    </PaginationLink>
                  </PaginationItem>
                ),
              )}

              <PaginationItem>
                <PaginationNext
                  className={cn(
                    pageStepClasses,
                    current === pageCount && "pointer-events-none opacity-40",
                  )}
                  render={
                    <button
                      disabled={current === pageCount}
                      onClick={() => setPage(current + 1)}
                      type="button"
                    />
                  }
                />
              </PaginationItem>
            </PaginationContent>
          </Pagination>
        )}
      </div>
    </section>
  );
}
```

### `registry/default/sections/property-2/data.ts`

```ts
import type { PropertyListingContent } from "../_types/property";

export const defaultProperty2Content: PropertyListingContent = {
  description:
    "Twelve live instructions across six districts. Each one visited, measured, and photographed by our own team.",
  districts: [
    {
      label: "Harbour District",
      value: "harbour-district",
    },
    {
      label: "Northgate Heights",
      value: "northgate-heights",
    },
    {
      label: "Marina Bay",
      value: "marina-bay",
    },
    {
      label: "Old Town",
      value: "old-town",
    },
    {
      label: "Cedar Park",
      value: "cedar-park",
    },
    {
      label: "Riverside Quarter",
      value: "riverside-quarter",
    },
  ],
  districtsAll: "Every district",
  empty: {
    actions: [
      {
        href: "/contact",
        label: "Register a search",
      },
    ],
    description:
      "Roughly a third of what we sell is never advertised. Register your search and an advisor will call when something fits.",
    title: "Nothing on the books matches that",
  },
  eyebrow: "The whole book",
  perPage: 6,
  properties: [
    {
      address: "24 Rowan Street, Harbour District",
      area: 4280,
      areaUnit: "ft²",
      baths: 4,
      beds: 5,
      category: "sale",
      district: "harbour-district",
      href: "/properties/blackwood-house",
      image: {
        alt: "Blackwood House",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook002.jpg",
      },
      price: "$4,250,000",
      status: "For sale",
      title: "Blackwood House",
      type: "Architectural home",
      year: 1893,
    },
    {
      address: "9 Vantage Lane, Northgate Heights",
      area: 3110,
      areaUnit: "ft²",
      baths: 3,
      beds: 4,
      category: "sale",
      district: "northgate-heights",
      href: "/properties/the-coastline-villa",
      image: {
        alt: "The Coastline Villa",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook003.jpg",
      },
      price: "$3,180,000",
      status: "For sale",
      title: "The Coastline Villa",
      type: "Sea-facing villa",
      year: 1961,
    },
    {
      address: "141 Alder Row, Old Town",
      area: 2650,
      areaUnit: "ft²",
      baths: 3,
      beds: 4,
      category: "rent",
      district: "old-town",
      href: "/properties/alder-row-cottage",
      image: {
        alt: "Alder Row Cottage",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook004.jpg",
      },
      price: "$9,400 / mo",
      status: "For rent",
      title: "Alder Row Cottage",
      type: "Heritage cottage",
      year: 1824,
    },
    {
      address: "3 Marina Bay Terrace, Marina Bay",
      area: 5120,
      areaUnit: "ft²",
      baths: 5,
      beds: 6,
      category: "new",
      district: "marina-bay",
      href: "/properties/marina-bay-residences",
      image: {
        alt: "Marina Bay Residences",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook005.jpg",
      },
      price: "From $5,900,000",
      status: "New development",
      title: "Marina Bay Residences",
      type: "Six new-build homes",
      year: 2026,
    },
    {
      address: "58 Cedar Park Drive, Cedar Park",
      area: 3640,
      areaUnit: "ft²",
      baths: 4,
      beds: 5,
      category: "sale",
      district: "cedar-park",
      href: "/properties/cedar-park-lodge",
      image: {
        alt: "Cedar Park Lodge",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook006.jpg",
      },
      price: "$2,740,000",
      status: "For sale",
      title: "Cedar Park Lodge",
      type: "Family lodge",
      year: 2011,
    },
    {
      address: "72 Riverside Crescent, Riverside Quarter",
      area: 2980,
      areaUnit: "ft²",
      baths: 3,
      beds: 4,
      category: "rent",
      district: "riverside-quarter",
      href: "/properties/riverside-colonial",
      image: {
        alt: "Riverside Colonial",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook007.jpg",
      },
      price: "$11,200 / mo",
      status: "For rent",
      title: "Riverside Colonial",
      type: "Pool residence",
      year: 1908,
    },
    {
      address: "11 Quay Steps, Harbour District",
      area: 1740,
      areaUnit: "ft²",
      baths: 2,
      beds: 2,
      category: "sale",
      district: "harbour-district",
      href: "/properties/quay-steps-apartment",
      image: {
        alt: "Quay Steps Apartment",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786954292/templates/estate/marbrook/cndn_marbrook016.jpg",
      },
      price: "$1,480,000",
      status: "For sale",
      title: "Quay Steps Apartment",
      type: "Warehouse conversion",
      year: 1871,
    },
    {
      address: "4 Kerr Close, Northgate Heights",
      area: 2410,
      areaUnit: "ft²",
      baths: 3,
      beds: 3,
      category: "sale",
      district: "northgate-heights",
      href: "/properties/kerr-close-house",
      image: {
        alt: "Kerr Close House",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786954292/templates/estate/marbrook/cndn_marbrook019.jpg",
      },
      price: "$2,150,000",
      status: "For sale",
      title: "Kerr Close House",
      type: "Mid-century bungalow",
      year: 1968,
    },
    {
      address: "27 Lantern Yard, Old Town",
      area: 1290,
      areaUnit: "ft²",
      baths: 2,
      beds: 2,
      category: "sale",
      district: "old-town",
      href: "/properties/lantern-yard-garden-flat",
      image: {
        alt: "Lantern Yard Garden Flat",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786954292/templates/estate/marbrook/cndn_marbrook020.jpg",
      },
      price: "$865,000",
      status: "For sale",
      title: "Lantern Yard Garden Flat",
      type: "Garden apartment",
      year: 1801,
    },
    {
      address: "Pier House, Marina Bay",
      area: 1980,
      areaUnit: "ft²",
      baths: 2,
      beds: 3,
      category: "new",
      district: "marina-bay",
      href: "/properties/pier-house",
      image: {
        alt: "Pier House",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786954292/templates/estate/marbrook/cndn_marbrook018.jpg",
      },
      price: "From $1,240,000",
      status: "New development",
      title: "Pier House",
      type: "Twenty-two apartments",
      year: 2027,
    },
    {
      address: "16 Willow Bank, Cedar Park",
      area: 2240,
      areaUnit: "ft²",
      baths: 3,
      beds: 4,
      category: "rent",
      district: "cedar-park",
      href: "/properties/willow-bank-house",
      image: {
        alt: "Willow Bank House",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786954292/templates/estate/marbrook/cndn_marbrook014.jpg",
      },
      price: "$6,800 / mo",
      status: "For rent",
      title: "Willow Bank House",
      type: "Family house",
      year: 2007,
    },
    {
      address: "2 Tolliver Reach, Riverside Quarter",
      area: 6100,
      areaUnit: "ft²",
      baths: 6,
      beds: 7,
      category: "sale",
      district: "riverside-quarter",
      href: "/properties/tolliver-reach",
      image: {
        alt: "Tolliver Reach",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786954292/templates/estate/marbrook/cndn_marbrook017.jpg",
      },
      price: "$7,950,000",
      status: "For sale",
      title: "Tolliver Reach",
      type: "Riverside estate",
      year: 1904,
    },
  ],
  resetLabel: "Clear filters",
  sorts: [
    {
      label: "Newest first",
      value: "newest",
    },
    {
      label: "Price: high to low",
      value: "price-desc",
    },
    {
      label: "Price: low to high",
      value: "price-asc",
    },
    {
      label: "Largest first",
      value: "area-desc",
    },
  ],
  statuses: [
    {
      label: "All homes",
      value: "all",
    },
    {
      label: "For sale",
      value: "sale",
    },
    {
      label: "For rent",
      value: "rent",
    },
    {
      label: "New developments",
      value: "new",
    },
  ],
  title: "Every home we are currently instructed on",
};
```

### `registry/default/sections/property-2/property-card.tsx`

```tsx
"use client";

import { Bath, BedDouble, Heart, MapPin, Maximize } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import type React from "react";
import { useState } from "react";

import { Badge } from "@/components/ui/badge";
import { Toggle } from "@/components/ui/toggle";
import { Tooltip, TooltipPopup, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

import type { Property } from "../_types/property";

export function PropertyCard({
  priority = false,
  property,
  sizes = "(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw",
}: {
  priority?: boolean;
  property: Property;
  sizes?: string;
}): React.ReactElement {
  const [saved, setSaved] = useState(false);

  return (
    <article className="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-colors hover:border-input">
      <div className="relative aspect-4/3 overflow-hidden">
        <Image
          alt={property.image.alt}
          className="object-cover transition-transform duration-700 ease-out group-hover:scale-105"
          fill
          priority={priority}
          sizes={sizes}
          src={property.image.src}
        />
        <div className="pointer-events-none absolute inset-x-0 bottom-0 h-28 bg-linear-to-t from-primary/70 to-transparent" />

        <Badge className="absolute top-4 left-4 bg-white/95 px-3 py-1 font-medium text-primary text-xs uppercase tracking-[0.14em]">
          {property.status}
        </Badge>

        <Tooltip>
          <TooltipTrigger
            render={
              <Toggle
                aria-label={
                  saved
                    ? `Remove ${property.title} from saved`
                    : `Save ${property.title}`
                }
                className="absolute top-4 right-4 size-9 min-w-0 border-none bg-white/95 px-0 text-primary shadow-none hover:bg-white data-pressed:bg-white"
                onPressedChange={setSaved}
                pressed={saved}
              >
                <Heart
                  className={cn(
                    "size-4 transition-colors",
                    saved && "fill-primary text-primary",
                  )}
                />
              </Toggle>
            }
          />
          <TooltipPopup>
            {saved ? "Remove from saved" : "Save property"}
          </TooltipPopup>
        </Tooltip>

        <p className="absolute bottom-4 left-4 font-display text-2xl text-white">
          {property.price}
        </p>
      </div>

      <div className="flex flex-1 flex-col p-6">
        <p className="inline-flex items-center gap-2 font-medium text-[0.6875rem] text-muted-foreground/70 uppercase tracking-[0.22em]">
          {property.type}
        </p>

        <h3 className="mt-3 font-display text-2xl text-primary">
          {/* Deliberately not a stretched link: the image carries the save
              toggle, which a full-card overlay would swallow. */}
          <Link
            className="transition-colors hover:text-primary/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
            href={property.href}
          >
            {property.title}
          </Link>
        </h3>

        <p className="mt-2 flex items-start gap-1.5 text-muted-foreground text-sm">
          <MapPin className="mt-0.5 size-3.5 shrink-0" />
          {property.address}
        </p>

        <dl className="mt-6 grid grid-cols-3 gap-2 border-border border-t pt-5 text-muted-foreground text-sm">
          <div className="flex items-center gap-2">
            <BedDouble className="size-4 text-muted-foreground/70" />
            <dt className="sr-only">Bedrooms</dt>
            <dd>{property.beds} bd</dd>
          </div>
          <div className="flex items-center gap-2">
            <Bath className="size-4 text-muted-foreground/70" />
            <dt className="sr-only">Bathrooms</dt>
            <dd>{property.baths} ba</dd>
          </div>
          <div className="flex items-center gap-2">
            <Maximize className="size-4 text-muted-foreground/70" />
            <dt className="sr-only">Floor area</dt>
            <dd>
              {property.area.toLocaleString("en-US")}{" "}
              {property.areaUnit ?? "ft²"}
            </dd>
          </div>
        </dl>
      </div>
    </article>
  );
}
```

### `registry/default/sections/property-2/section-header.tsx`

```tsx
import type React from "react";

import { cn } from "@/lib/utils";

import type { Surface } from "../_types/common";

/** The small uppercase label that opens most headers. */
export const eyebrowClass =
  "inline-flex items-center gap-2 font-medium text-[0.6875rem] uppercase tracking-[0.22em]";

export const surfaceClass: Record<Surface, string> = {
  default: "bg-background text-foreground",
  muted: "bg-muted text-foreground",
  primary: "bg-primary text-primary-foreground",
  secondary: "bg-secondary text-secondary-foreground",
};

/**
 * Which band the header sits on. `photo` keeps literal white, because
 * `--primary-foreground` inverts in dark mode and would be unreadable over a
 * photograph.
 */
export type SectionTone = "light" | "photo" | "primary" | "secondary";

export function toneFor(surface: Surface): SectionTone {
  if (surface === "primary") return "primary";
  if (surface === "secondary") return "secondary";
  return "light";
}

const toneClasses: Record<
  SectionTone,
  { eyebrow: string; heading: string; lede: string; tick: string }
> = {
  light: {
    eyebrow: "text-muted-foreground/70",
    heading: "text-primary",
    lede: "text-muted-foreground",
    tick: "bg-secondary",
  },
  photo: {
    eyebrow: "text-white/50",
    heading: "text-white",
    lede: "text-white/65",
    tick: "bg-secondary",
  },
  primary: {
    eyebrow: "text-primary-foreground/40",
    heading: "text-primary-foreground",
    lede: "text-primary-foreground/60",
    tick: "bg-secondary",
  },
  secondary: {
    eyebrow: "text-secondary-foreground/50",
    heading: "text-secondary-foreground",
    lede: "text-secondary-foreground/70",
    tick: "bg-primary",
  },
};

export function SectionHeader({
  action,
  actionAt = "sm",
  className,
  description,
  eyebrow,
  size = "default",
  title,
  tone = "light",
}: {
  /** Rendered beside the heading block once `actionAt` is reached. */
  action?: React.ReactNode;
  /** A text link fits from `sm`; a row of filter tabs needs `lg`. */
  actionAt?: "lg" | "sm";
  className?: string;
  description?: React.ReactNode;
  eyebrow: string;
  /** `sm` is the narrow-sidebar size used by 4/8 splits. */
  size?: "default" | "sm";
  title: React.ReactNode;
  tone?: SectionTone;
}): React.ReactElement {
  const tones = toneClasses[tone];

  return (
    <div
      className={cn(
        action && "flex flex-col gap-6",
        action &&
          (actionAt === "lg"
            ? "gap-8 lg:flex-row lg:items-end lg:justify-between"
            : "sm:flex-row sm:items-end sm:justify-between"),
        className,
      )}
    >
      <div className={cn(action && "max-w-xl")}>
        <p className={cn(eyebrowClass, tones.eyebrow)}>
          <span className={cn("size-1", tones.tick)} />
          {eyebrow}
        </p>

        <h2
          className={cn(
            "mt-5 font-display",
            tones.heading,
            size === "sm"
              ? "text-3xl leading-[1.1] sm:text-4xl"
              : "text-4xl leading-[1.05] sm:text-5xl",
          )}
        >
          {title}
        </h2>

        {description && (
          <p className={cn("mt-4 max-w-xl leading-relaxed", tones.lede)}>
            {description}
          </p>
        )}
      </div>

      {action}
    </div>
  );
}
```

### `registry/default/sections/_types/common.ts`

```ts
/**
 * Shapes shared by every token-based section. Each section ships this file
 * alongside its own category types, so installing several sections simply
 * rewrites the same file.
 */

/** Which band a section sits on. A style choice, never part of `content`. */
export type Surface = "default" | "muted" | "primary" | "secondary";

export type ImageAsset = {
  alt: string;
  src: string;
};

export type LinkAction = {
  href: string;
  label: string;
};

/** Eyebrow + heading + optional lede, the header most sections open with. */
export type SectionIntro = {
  description?: string;
  eyebrow: string;
  title: string;
};

/** A figure that counts up when it scrolls into view. */
export type Stat = {
  decimals?: number;
  label: string;
  prefix?: string;
  suffix?: string;
  value: number;
};

/** A figure that is displayed as written ("1,240+", "4.9/5"). */
export type Highlight = {
  label: string;
  value: string;
};

export type FormField = {
  label: string;
  name: string;
  /** Only for `type: "select"`. The first option is the default. */
  options?: string[];
  placeholder?: string;
  required?: boolean;
  /** `full` spans both columns of a two-column form. */
  span?: "full" | "half";
  type?: "email" | "select" | "tel" | "text" | "textarea";
};

export type FormContent = {
  description?: string;
  fields: FormField[];
  note?: string;
  submitLabel: string;
  success: {
    description: string;
    resetLabel: string;
    title: string;
  };
  title: string;
};
```

### `registry/default/sections/_types/property.ts`

```ts
import type { ImageAsset, LinkAction, SectionIntro } from "./common";

export type Property = {
  address: string;
  area: number;
  areaUnit?: string;
  baths: number;
  beds: number;
  /** Matches a filter `value`, e.g. "sale" | "rent" | "new". */
  category: string;
  /** Matches a `districts` option value on the listing section. */
  district?: string;
  href: string;
  image: ImageAsset;
  price: string;
  status: string;
  title: string;
  type: string;
  year?: number;
};

export type PropertyFilter = { label: string; value: string };

export type PropertyEmptyState = {
  actions?: LinkAction[];
  description: string;
  title: string;
};

/** A grid of property cards, optionally filtered by tabs. */
export type PropertyGridContent = SectionIntro & {
  action?: LinkAction;
  empty: PropertyEmptyState;
  /** The first filter should be the "all" option, with value "all". */
  filters?: PropertyFilter[];
  properties: Property[];
  /** Footer line; `{visible}` and `{total}` are replaced. */
  summary?: string;
};

/** The full book: status tabs, district filter, sort and pagination. */
export type PropertyListingContent = SectionIntro & {
  districts: PropertyFilter[];
  /** Label for "any district" (value "all"). */
  districtsAll: string;
  empty: PropertyEmptyState;
  perPage: number;
  properties: Property[];
  /** Label of the button that clears every filter. */
  resetLabel: string;
  sorts: PropertyFilter[];
  statuses: PropertyFilter[];
};

export type PropertyDetailContent = {
  agent?: {
    bio: string;
    email: string;
    image: ImageAsset;
    label: string;
    name: string;
    phone: string;
    role: string;
  };
  description: string[];
  eyebrow: string;
  facts: { label: string; value: string }[];
  features: string[];
  featuresTitle: string;
  location?: {
    description: string;
    href: string;
    label: string;
    meta: string;
    name: string;
  };
  title: string;
};
```
