# Hero 9

> Full-bleed photo hero with highlights and a property search bar (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/hero-9).

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/cnippet-blocks/hero-9.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/select`, `@cnippet/tabs`, `@cnippet/utils`

## Files

### `registry/default/sections/hero-9/index.tsx`

```tsx
"use client";

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

import { Button } from "@/components/ui/button";
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 { HeroContent } from "../_types/hero";
import { defaultHero9Content } from "./data";

const eyebrowClass =
  "inline-flex items-center gap-2 font-medium text-[0.6875rem] uppercase tracking-[0.22em]";

const bareSelectTrigger =
  "min-h-0 w-full border-none bg-transparent px-0 text-base text-primary shadow-none before:shadow-none focus-visible:ring-0 sm:min-h-0 sm:text-base dark:bg-transparent";

function SearchField({
  children,
  className,
  label,
}: {
  children: React.ReactNode;
  className?: string;
  label: string;
}) {
  return (
    <div
      className={cn(
        "flex flex-col justify-center gap-1.5 border-border px-5 py-4 lg:px-6",
        className,
      )}
    >
      <span className={cn(eyebrowClass, "text-muted-foreground/70")}>
        {label}
      </span>
      {children}
    </div>
  );
}

export default function Hero9({
  content = defaultHero9Content,
  onSearch,
}: {
  content?: HeroContent;
  /** Receives the active tab and every field's chosen option, by label. */
  onSearch?: (query: { tab?: string; values: Record<string, string> }) => void;
}) {
  const { search } = content;
  const [tab, setTab] = useState(search?.tabs?.[0]?.value);
  const [values, setValues] = useState<Record<string, string>>(() =>
    Object.fromEntries(
      (search?.fields ?? []).map((field) => [field.label, field.options[0]]),
    ),
  );

  const [primaryAction, secondaryAction] = content.actions;

  return (
    <section
      className={cn(
        "relative isolate flex min-h-[94vh] flex-col justify-end pt-28",
        search ? "mb-12 lg:mb-14" : "pb-10",
      )}
    >
      <div className="absolute inset-0 -z-10 overflow-hidden">
        <Image
          alt={content.image.alt}
          className="object-cover object-center"
          fill
          priority
          sizes="100vw"
          src={content.image.src}
        />
        <div className="absolute inset-0 bg-linear-to-t from-primary/90 via-primary/45 to-primary/25" />
        {/* Second pass from the left keeps the headline legible over bright façades. */}
        <div className="absolute inset-0 bg-linear-to-r from-primary/70 via-primary/20 to-transparent" />
      </div>

      <div className="mx-auto w-full max-w-7xl px-5 pb-10 sm:px-8 lg:px-12 lg:pb-14">
        <div className="flex flex-col gap-10 lg:flex-row lg:items-end lg:justify-between">
          <div className="max-w-2xl">
            <p className={cn(eyebrowClass, "text-white/70")}>
              <span className="size-1 bg-secondary" />
              {content.eyebrow}
            </p>

            <h1 className="mt-6 font-display text-5xl text-white leading-[1.02] sm:text-6xl lg:text-7xl">
              {content.title}
              {content.titleAccent && (
                <span className="block italic">{content.titleAccent}</span>
              )}
            </h1>

            <p className="mt-6 max-w-lg text-base text-white/75 leading-relaxed">
              {content.description}
            </p>

            <div className="mt-9 flex flex-wrap items-center gap-3">
              {primaryAction && (
                <Button
                  className="border-none bg-white px-7 text-primary shadow-none hover:bg-white/90"
                  render={<Link href={primaryAction.href} />}
                  size="xl"
                >
                  {primaryAction.label}
                </Button>
              )}
              {secondaryAction && (
                <Button
                  className="border-white/40 bg-transparent px-7 text-white shadow-none before:shadow-none hover:bg-white/10 dark:bg-transparent"
                  render={<Link href={secondaryAction.href} />}
                  size="xl"
                  variant="outline"
                >
                  {secondaryAction.label}
                </Button>
              )}
            </div>
          </div>

          {content.highlights && content.highlights.length > 0 && (
            <dl className="grid grid-cols-3 gap-8 border-white/20 border-t pt-6 lg:shrink-0 lg:gap-x-10 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-8">
              {content.highlights.map((highlight) => (
                <div key={highlight.label}>
                  <dd className="font-display text-3xl text-white lg:text-4xl">
                    {highlight.value}
                  </dd>
                  <dt className="mt-1 text-white/60 text-xs leading-snug">
                    {highlight.label}
                  </dt>
                </div>
              ))}
            </dl>
          )}
        </div>
      </div>

      {search && (
        <div className="relative z-10 mx-auto -mb-12 w-full max-w-7xl px-5 sm:px-8 lg:-mb-14 lg:px-12">
          {search.tabs && search.tabs.length > 0 && (
            <Tabs onValueChange={(value) => setTab(String(value))} value={tab}>
              <TabsList
                className="w-fit gap-0 rounded-b-none bg-white/10 p-0 backdrop-blur-sm"
                variant="default"
              >
                {search.tabs.map((item) => (
                  <TabsTab
                    className="h-11 rounded-b-none px-5 text-sm text-white/70 data-active:text-primary sm:h-11 sm:text-sm"
                    key={item.value}
                    value={item.value}
                  >
                    {item.label}
                  </TabsTab>
                ))}
              </TabsList>
            </Tabs>
          )}

          <form
            className="grid grid-cols-1 overflow-hidden rounded-lg rounded-tl-none bg-card shadow-2xl shadow-primary/30 sm:grid-cols-2 sm:divide-x sm:divide-border lg:grid-cols-[1.2fr_1fr_1fr_auto]"
            onSubmit={(event) => {
              event.preventDefault();
              onSearch?.({ tab, values });
            }}
          >
            {search.fields.map((field, index) => (
              <SearchField
                className={cn(
                  "border-b sm:border-b-0",
                  index >= 2 && "sm:border-t lg:border-t-0",
                )}
                key={field.label}
                label={field.label}
              >
                <div className="flex items-center gap-2">
                  {field.icon === "pin" && (
                    <MapPin className="size-4 shrink-0 text-muted-foreground" />
                  )}
                  <Select
                    onValueChange={(value) =>
                      setValues((current) => ({
                        ...current,
                        [field.label]: String(value ?? field.options[0]),
                      }))
                    }
                    value={values[field.label]}
                  >
                    <SelectTrigger
                      aria-label={field.label}
                      className={bareSelectTrigger}
                    >
                      <SelectValue />
                    </SelectTrigger>
                    <SelectPopup>
                      {field.options.map((option) => (
                        <SelectItem key={option} value={option}>
                          {option}
                        </SelectItem>
                      ))}
                    </SelectPopup>
                  </Select>
                </div>
              </SearchField>
            ))}

            <div className="flex items-stretch p-3 sm:col-span-2 lg:col-span-1 lg:p-4">
              <Button
                className="h-full w-full gap-2 border-none bg-primary px-8 text-primary-foreground shadow-none hover:bg-primary/90 max-lg:h-12"
                size="xl"
                type="submit"
              >
                <Search className="size-4" />
                {search.submitLabel}
              </Button>
            </div>
          </form>
        </div>
      )}
    </section>
  );
}
```

### `registry/default/sections/hero-9/data.ts`

```ts
import type { HeroContent } from "../_types/hero";

export const defaultHero9Content: HeroContent = {
  actions: [
    {
      href: "/properties",
      label: "Browse listings",
    },
    {
      href: "/contact",
      label: "Request a valuation",
    },
  ],
  description:
    "A curated portfolio of architect-designed homes, waterfront apartments, and private estates — represented by advisors who know every street on the list.",
  eyebrow: "Harbour District & the Hills",
  highlights: [
    {
      label: "Homes placed",
      value: "1,240+",
    },
    {
      label: "Closed volume",
      value: "$4.8B",
    },
    {
      label: "Client rating",
      value: "4.9/5",
    },
  ],
  image: {
    alt: "Contemporary hillside residence at dusk",
    src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook001.jpg",
  },
  search: {
    fields: [
      {
        icon: "pin",
        label: "Location",
        options: [
          "Harbour District",
          "Northgate Heights",
          "Marina Bay",
          "Old Town",
          "Cedar Park",
          "Riverside Quarter",
        ],
      },
      {
        label: "Property type",
        options: [
          "Any type",
          "House",
          "Apartment",
          "Penthouse",
          "Townhouse",
          "Estate",
        ],
      },
      {
        label: "Price range",
        options: [
          "Any price",
          "Up to $750k",
          "$750k – $1.5M",
          "$1.5M – $3M",
          "$3M – $6M",
          "$6M+",
        ],
      },
    ],
    submitLabel: "Search homes",
    tabs: [
      {
        label: "Buy",
        value: "buy",
      },
      {
        label: "Rent",
        value: "rent",
      },
      {
        label: "New developments",
        value: "new",
      },
    ],
  },
  title: "Find the address that",
  titleAccent: "feels like yours.",
};
```

### `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/hero.ts`

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

export type HeroContent = {
  actions: LinkAction[];
  description: string;
  eyebrow: string;
  highlights?: Highlight[];
  image: ImageAsset;
  /** Search bar under the hero (an industry widget, see playbook ruling 8). */
  search?: {
    fields: {
      icon?: "pin";
      label: string;
      options: string[];
    }[];
    submitLabel: string;
    tabs?: { label: string; value: string }[];
  };
  title: string;
  /** Second line of the title, set in italics. */
  titleAccent?: string;
};
```
