# Property 3

> Property description and features beside facts, agent and district cards (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-3).

- Page, with a live preview: https://shadcn.exchange/r/cnippet-blocks/property-3
- Screenshot: https://shadcn.exchange/shot/cnippet-blocks/property-3/light.png
- Registry item: https://blocks.cnippet.dev/r/property-3.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-3.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/utils`

## Files

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

```tsx
import { Check, Mail, Phone } from "lucide-react";
import Image from "next/image";
import Link from "next/link";

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

import type { Surface } from "../_types/common";
import type { PropertyDetailContent } from "../_types/property";
import { defaultProperty3Content } from "./data";
import {
  eyebrowClass,
  SectionHeader,
  surfaceClass,
  toneFor,
} from "./section-header";

function telHref(phone: string): string {
  return `tel:${phone.replace(/[^\d+]/g, "")}`;
}

/** Description and features beside a facts table, the agent and the district. */
export default function Property3({
  className,
  content = defaultProperty3Content,
  surface = "default",
}: {
  /** Use `pt-0` when a gallery sits directly above in the same band. */
  className?: string;
  content?: PropertyDetailContent;
  surface?: Surface;
}) {
  return (
    <section className={cn("py-20 sm:py-28", surfaceClass[surface], className)}>
      <div className="mx-auto max-w-7xl px-5 sm:px-8 lg:px-12">
        <div className="grid gap-14 lg:grid-cols-12 lg:gap-16">
          <div className="lg:col-span-7">
            <SectionHeader
              eyebrow={content.eyebrow}
              size="sm"
              title={content.title}
              tone={toneFor(surface)}
            />

            <div className="mt-8 space-y-5 text-muted-foreground leading-relaxed">
              {content.description.map((paragraph) => (
                <p key={paragraph.slice(0, 40)}>{paragraph}</p>
              ))}
            </div>

            <h3 className="mt-12 font-display text-2xl text-primary">
              {content.featuresTitle}
            </h3>
            <ul className="mt-6 grid gap-4 sm:grid-cols-2">
              {content.features.map((feature) => (
                <li
                  className="flex items-start gap-3 text-muted-foreground text-sm leading-relaxed"
                  key={feature}
                >
                  <span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-sm border border-input text-primary">
                    <Check className="size-3" />
                  </span>
                  {feature}
                </li>
              ))}
            </ul>
          </div>

          <div className="lg:col-span-5">
            <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-lg bg-border">
              {content.facts.map((row) => (
                <div className="bg-card px-6 py-5" key={row.label}>
                  <dt className={cn(eyebrowClass, "text-muted-foreground/70")}>
                    {row.label}
                  </dt>
                  <dd className="mt-2 text-primary text-sm">{row.value}</dd>
                </div>
              ))}
            </dl>

            {content.agent && (
              <div className="mt-6 rounded-lg border border-border bg-card p-6">
                <p className={cn(eyebrowClass, "text-muted-foreground/70")}>
                  {content.agent.label}
                </p>
                <div className="mt-5 flex items-center gap-4">
                  <div className="relative size-16 shrink-0 overflow-hidden rounded-md bg-muted">
                    <Image
                      alt={content.agent.image.alt}
                      className="object-cover"
                      fill
                      sizes="64px"
                      src={content.agent.image.src}
                    />
                  </div>
                  <div>
                    <p className="font-display text-primary text-xl">
                      {content.agent.name}
                    </p>
                    <p className="mt-0.5 text-muted-foreground text-sm">
                      {content.agent.role}
                    </p>
                  </div>
                </div>

                <p className="mt-5 text-muted-foreground text-sm leading-relaxed">
                  {content.agent.bio}
                </p>

                <div className="mt-6 flex flex-col gap-3 border-border border-t pt-5">
                  <a
                    className="flex items-center gap-3 text-primary text-sm transition-colors hover:text-primary/70"
                    href={telHref(content.agent.phone)}
                  >
                    <Phone className="size-4 text-muted-foreground/70" />
                    {content.agent.phone}
                  </a>
                  <a
                    className="flex items-center gap-3 text-primary text-sm transition-colors hover:text-primary/70"
                    href={`mailto:${content.agent.email}`}
                  >
                    <Mail className="size-4 text-muted-foreground/70" />
                    {content.agent.email}
                  </a>
                </div>
              </div>
            )}

            {content.location && (
              <Link
                className="group mt-6 block rounded-lg border border-border bg-card p-6 transition-colors hover:border-input"
                href={content.location.href}
              >
                <p className={cn(eyebrowClass, "text-muted-foreground/70")}>
                  {content.location.label}
                </p>
                <p className="mt-4 font-display text-2xl text-primary">
                  {content.location.name}
                </p>
                <p className="mt-2 text-muted-foreground text-sm leading-relaxed">
                  {content.location.description}
                </p>
                <p className="mt-4 text-muted-foreground text-xs">
                  {content.location.meta}
                </p>
              </Link>
            )}
          </div>
        </div>
      </div>
    </section>
  );
}
```

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

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

export const defaultProperty3Content: PropertyDetailContent = {
  agent: {
    bio: "Nadia joined Marbrook from a listed-buildings practice and still reads a survey before she reads a brochure. She handles the majority of our private sales.",
    email: "nadia@marbrook.com",
    image: {
      alt: "Nadia Ellery",
      src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770279333/a1.jpg",
    },
    label: "Your advisor",
    name: "Nadia Ellery",
    phone: "+1 (800) 555-0111",
    role: "Principal, Private Sales",
  },
  description: [
    "Blackwood House occupies the corner of Rowan Street and the quay, three storeys of 1890s bond store that a previous owner opened up in 2016 without losing a single original column.",
    "The ground floor is one room — kitchen, dining, and living across forty-one feet, with the original loading doors reglazed as a south wall. Bedrooms sit above, and the top floor is a single studio with harbour light on three sides.",
    "The building is not listed, which is unusual for the street and makes the roof terrace consent already granted rather than hypothetical.",
  ],
  eyebrow: "Architectural home",
  facts: [
    {
      label: "Tenure",
      value: "Freehold",
    },
    {
      label: "Built",
      value: "1893",
    },
    {
      label: "Type",
      value: "Architectural home",
    },
    {
      label: "District",
      value: "Harbour District",
    },
  ],
  features: [
    "Reglazed original loading doors",
    "Roof terrace with consent granted",
    "Off-street parking for two",
    "Underfloor heating throughout",
    "Cellar with tanked storage",
    "Freehold, no service charge",
  ],
  featuresTitle: "What comes with it",
  location: {
    description:
      "Warehouse conversions, moored yachts, and the best morning light in the city.",
    href: "/neighbourhoods/harbour-district",
    label: "The district",
    meta: "48 listings · median $2.4M · 12 min to the centre",
    name: "Harbour District",
  },
  title: "About this home",
};
```

### `registry/default/sections/property-3/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;
};
```
