# Contact 5

> Enquiry form beside office cards and a general inbox (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/contact-5).

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/cnippet-blocks/contact-5.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/button`, `@cnippet/field`, `@cnippet/form`, `@cnippet/input`, `@cnippet/select`, `@cnippet/textarea`, `@cnippet/toast`, `@cnippet/utils`

## Files

### `registry/default/sections/contact-5/index.tsx`

```tsx
import { CalendarClock, Mail, MapPin, Phone } from "lucide-react";

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

import type { Surface } from "../_types/common";
import type { ContactContent } from "../_types/contact";
import { defaultContact5Content } from "./data";
import { EnquiryForm } from "./enquiry-form";
import { SectionHeader, surfaceClass, toneFor } from "./section-header";

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

/** An enquiry form beside a column of office cards and a general inbox. */
export default function Contact5({
  content = defaultContact5Content,
  onSubmit,
  surface = "default",
}: {
  content?: ContactContent;
  onSubmit?: (values: Record<string, unknown>) => Promise<void> | void;
  surface?: Surface;
}) {
  const { aside } = content;

  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">
        <div className="grid gap-14 lg:grid-cols-12 lg:gap-16">
          <div className="lg:col-span-7">
            <EnquiryForm
              className="rounded-lg border border-border sm:p-10"
              content={content.form}
              onSubmit={onSubmit}
            />
          </div>

          <div className="lg:col-span-5">
            <SectionHeader
              description={aside.description}
              eyebrow={aside.eyebrow}
              size="sm"
              title={aside.title}
              tone={toneFor(surface)}
            />

            <div className="mt-8 grid gap-px overflow-hidden rounded-lg bg-border">
              {aside.locations.map((location) => (
                <div className="bg-card p-6" key={location.label}>
                  <h3 className="font-display text-2xl text-primary">
                    {location.label}
                  </h3>
                  <p className="mt-3 flex items-start gap-3 text-muted-foreground text-sm">
                    <MapPin className="mt-0.5 size-4 shrink-0 text-muted-foreground/70" />
                    {location.address}
                  </p>
                  {location.phone && (
                    <a
                      className="mt-2 flex items-center gap-3 text-muted-foreground text-sm transition-colors hover:text-primary"
                      href={telHref(location.phone)}
                    >
                      <Phone className="size-4 shrink-0 text-muted-foreground/70" />
                      {location.phone}
                    </a>
                  )}
                  {location.hours && (
                    <p className="mt-2 flex items-center gap-3 text-muted-foreground text-sm">
                      <CalendarClock className="size-4 shrink-0 text-muted-foreground/70" />
                      {location.hours}
                    </p>
                  )}
                </div>
              ))}

              {aside.email && (
                <div className="bg-card p-6">
                  <h3 className="font-display text-2xl text-primary">
                    {aside.email.title}
                  </h3>
                  <a
                    className="mt-3 flex items-center gap-3 text-muted-foreground text-sm transition-colors hover:text-primary"
                    href={`mailto:${aside.email.address}`}
                  >
                    <Mail className="size-4 shrink-0 text-muted-foreground/70" />
                    {aside.email.address}
                  </a>
                  {aside.email.note && (
                    <p className="mt-3 text-muted-foreground text-sm leading-relaxed">
                      {aside.email.note}
                    </p>
                  )}
                </div>
              )}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}
```

### `registry/default/sections/contact-5/data.ts`

```ts
import type { ContactContent } from "../_types/contact";

export const defaultContact5Content: ContactContent = {
  aside: {
    description:
      "Both answer until 7pm, Monday to Saturday. Viewings outside those hours are arranged all the time.",
    email: {
      address: "hello@marbrook.com",
      note: "Read within the hour during office hours, and answered within one working day either way.",
      title: "General enquiries",
    },
    eyebrow: "Or just call",
    locations: [
      {
        address: "148 Rowan Street, Harbour District",
        hours: "Mon–Sat, 8am – 7pm",
        label: "Harbour District",
        phone: "+1 (800) 555-0199",
      },
      {
        address: "9 Vantage Lane, Northgate Heights",
        hours: "Mon–Sat, 8am – 7pm",
        label: "Northgate Heights",
        phone: "+1 (800) 555-0142",
      },
    ],
    title: "Two offices, one switchboard",
  },
  form: {
    description:
      "Everything marked optional really is. A sentence about what you are after is the useful part.",
    fields: [
      {
        label: "Full name",
        name: "name",
        placeholder: "Alex Marsden",
        required: true,
      },
      {
        label: "Email",
        name: "email",
        placeholder: "you@email.com",
        required: true,
        type: "email",
      },
      {
        label: "Phone (optional)",
        name: "phone",
        placeholder: "+1 (800) 555-0199",
        type: "tel",
      },
      {
        label: "What is it about?",
        name: "subject",
        options: [
          "Buying a home",
          "Selling a home",
          "A free valuation",
          "Letting or management",
          "Relocating from abroad",
          "Something else",
        ],
        type: "select",
      },
      {
        label: "Someone in particular? (optional)",
        name: "advisor",
        options: [
          "Whoever covers my district",
          "Nadia Ellery — Harbour District",
          "Theo Marchetti — Northgate Heights",
          "Imogen Bell — Marina Bay & Riverside",
          "Rafael Okonjo — Old Town & Cedar Park",
          "Elin Sandoval — Relocation & overseas buyers",
          "Bram Halvorsen — Valuations, all districts",
        ],
        span: "full",
        type: "select",
      },
      {
        label: "Your message",
        name: "message",
        placeholder:
          "We are looking for a four-bedroom house near Cedar Park, ideally before the school year starts. Budget is around $1.5M.",
        required: true,
        span: "full",
        type: "textarea",
      },
    ],
    note: "We use your details only to answer this enquiry. We never sell or share them.",
    submitLabel: "Send message",
    success: {
      description:
        "Somebody will read this within the hour and reply within one working day. If it is urgent, both offices answer the phone until 7pm, Monday to Saturday.",
      resetLabel: "Send another message",
      title: "Thank you — that is with us.",
    },
    title: "Send us a message",
  },
};
```

### `registry/default/sections/contact-5/enquiry-form.tsx`

```tsx
"use client";

import { ArrowRight, Check } from "lucide-react";
import type React from "react";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldLabel } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectItem,
  SelectPopup,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { toastManager } from "@/components/ui/toast";
import { cn } from "@/lib/utils";

import type { FormContent, FormField } from "../_types/common";

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const PHONE = /^\+?[\d\s().-]{8,}$/;

function validate(field: FormField) {
  return (raw: unknown): string | null => {
    const value = String(raw ?? "").trim();
    if (value === "") {
      return field.required ? "Please fill this in." : null;
    }
    if (field.type === "email" && !EMAIL.test(value)) {
      return "That does not look like an email address.";
    }
    if (field.type === "tel" && !PHONE.test(value)) {
      return "Please check the number — digits, spaces and brackets only.";
    }
    return null;
  };
}

/** Stands in for a real POST so the pending and success states are visible. */
async function simulateSubmit(): Promise<void> {
  await new Promise((resolve) => setTimeout(resolve, 900));
}

const controlClasses = "h-11 items-center border-input";
const labelClasses = "text-muted-foreground text-sm";

export function EnquiryForm({
  className,
  content,
  onSubmit = simulateSubmit,
  size = "default",
}: {
  className?: string;
  content: FormContent;
  onSubmit?: (values: Record<string, unknown>) => Promise<void> | void;
  /** `sm` for a sidebar card, `default` for a page-level form. */
  size?: "default" | "sm";
}): React.ReactElement {
  const [pending, setPending] = useState(false);
  const [sent, setSent] = useState(false);
  const [selects, setSelects] = useState<Record<string, string>>(() =>
    Object.fromEntries(
      content.fields
        .filter((field) => field.type === "select")
        .map((field) => [field.name, field.options?.[0] ?? ""]),
    ),
  );

  const Heading = size === "sm" ? "h3" : "h2";
  const headingClasses =
    size === "sm"
      ? "font-display text-2xl text-primary"
      : "font-display text-3xl text-primary";

  async function handleSubmit(values: Record<string, unknown>): Promise<void> {
    setPending(true);
    await onSubmit({ ...values, ...selects });
    setPending(false);
    setSent(true);

    toastManager.add({
      description: content.success.description,
      title: content.success.title,
      type: "success",
    });
  }

  if (sent) {
    return (
      <div className={cn("bg-card p-7 sm:p-9", className)}>
        <span className="flex size-11 items-center justify-center rounded-md border border-input text-primary">
          <Check className="size-5" />
        </span>
        <Heading className={cn("mt-6", headingClasses)}>
          {content.success.title}
        </Heading>
        <p className="mt-2 max-w-md text-muted-foreground text-sm leading-relaxed">
          {content.success.description}
        </p>
        <Button
          className="mt-7 border-input bg-background px-7 text-primary shadow-none before:shadow-none hover:bg-muted"
          onClick={() => setSent(false)}
          size="xl"
          variant="outline"
        >
          {content.success.resetLabel}
        </Button>
      </div>
    );
  }

  return (
    <Form
      className={cn("gap-0 bg-card p-7 sm:p-9", className)}
      onFormSubmit={handleSubmit}
      validationMode="onBlur"
    >
      <Heading className={headingClasses}>{content.title}</Heading>
      {content.description && (
        <p className="mt-1 text-muted-foreground text-sm">
          {content.description}
        </p>
      )}

      <div className="mt-7 grid gap-5 sm:grid-cols-2">
        {content.fields.map((field) => {
          const span = field.span === "full" && "sm:col-span-2";

          if (field.type === "select") {
            return (
              <div className={cn("flex flex-col gap-2", span)} key={field.name}>
                <span className={cn("font-medium", labelClasses)}>
                  {field.label}
                </span>
                <Select
                  onValueChange={(value) =>
                    setSelects((current) => ({
                      ...current,
                      [field.name]: String(value),
                    }))
                  }
                  value={selects[field.name]}
                >
                  <SelectTrigger
                    aria-label={field.label}
                    className="h-11 min-h-0 w-full border-input bg-background text-primary text-sm shadow-none before:shadow-none sm:h-11 sm:text-sm"
                  >
                    <SelectValue />
                  </SelectTrigger>
                  <SelectPopup>
                    {field.options?.map((option) => (
                      <SelectItem key={option} value={option}>
                        {option}
                      </SelectItem>
                    ))}
                  </SelectPopup>
                </Select>
              </div>
            );
          }

          return (
            <Field
              className={cn(span)}
              key={field.name}
              name={field.name}
              validate={validate(field)}
            >
              <FieldLabel className={labelClasses}>{field.label}</FieldLabel>
              {field.type === "textarea" ? (
                <Textarea
                  className="min-h-36 border-input py-3"
                  name={field.name}
                  placeholder={field.placeholder}
                />
              ) : (
                <Input
                  className={controlClasses}
                  name={field.name}
                  placeholder={field.placeholder}
                  type={field.type ?? "text"}
                />
              )}
              <FieldError />
            </Field>
          );
        })}
      </div>

      <Button
        className={cn(
          "group mt-7 w-full gap-2 border-none bg-primary text-primary-foreground shadow-none hover:bg-primary/90",
          size === "default" && "sm:w-fit sm:px-9",
        )}
        loading={pending}
        size="xl"
        type="submit"
      >
        {content.submitLabel}
        <ArrowRight className="size-4 transition-transform duration-300 group-hover:translate-x-0.5" />
      </Button>

      {content.note && (
        <p className="mt-4 text-muted-foreground/70 text-xs leading-relaxed">
          {content.note}
        </p>
      )}
    </Form>
  );
}
```

### `registry/default/sections/contact-5/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/contact.ts`

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

export type ContactContent = {
  aside: SectionIntro & {
    email?: {
      address: string;
      note?: string;
      title: string;
    };
    locations: {
      address: string;
      hours?: string;
      label: string;
      phone?: string;
    }[];
  };
  form: FormContent;
};
```
