# Page Header 1

> Interior page header: photo, breadcrumbs, title and optional stats (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/page-header-1).

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/cnippet-blocks/page-header-1.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/breadcrumb`, `@cnippet/utils`

## Files

### `registry/default/sections/page-header-1/index.tsx`

```tsx
import { Bath, BedDouble, Maximize } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Fragment } from "react";

import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { cn } from "@/lib/utils";

import type {
  PageHeaderContent,
  PageHeaderStatIcon,
} from "../_types/page-header";
import { defaultPageHeader1Content } from "./data";

const statIcons: Record<PageHeaderStatIcon, typeof Bath> = {
  area: Maximize,
  bath: Bath,
  bed: BedDouble,
};

/**
 * The interior-page counterpart to a photo hero: the same dual scrim and
 * eyebrow, with a title one step down the scale so no interior page ever
 * competes with the homepage h1.
 */
export default function PageHeader1({
  content = defaultPageHeader1Content,
}: {
  content?: PageHeaderContent;
}) {
  return (
    <section className="relative isolate flex min-h-[62vh] flex-col justify-end pt-28 pb-14 sm:pb-16">
      <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/50 to-primary/30" />
        {/* Second pass from the left keeps the title 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 sm:px-8 lg:px-12">
        <Breadcrumb>
          <BreadcrumbList className="text-white/50 text-xs">
            {content.breadcrumbs.map((crumb) => (
              // The separator is a sibling <li>, never nested inside one.
              <Fragment key={crumb.label}>
                <BreadcrumbItem>
                  {crumb.href ? (
                    <BreadcrumbLink
                      className="hover:text-white"
                      render={<Link href={crumb.href} />}
                    >
                      {crumb.label}
                    </BreadcrumbLink>
                  ) : (
                    <BreadcrumbPage className="text-white/80">
                      {crumb.label}
                    </BreadcrumbPage>
                  )}
                </BreadcrumbItem>
                {crumb.href && <BreadcrumbSeparator />}
              </Fragment>
            ))}
          </BreadcrumbList>
        </Breadcrumb>

        <p className="mt-8 inline-flex items-center gap-2 font-medium text-[0.6875rem] text-white/50 uppercase tracking-[0.22em]">
          <span className="size-1 bg-secondary" />
          {content.eyebrow}
        </p>

        <h1 className="mt-5 max-w-3xl font-display text-5xl text-white leading-[1.04] sm:text-6xl">
          {content.title}
        </h1>

        {content.description && (
          <p className="mt-6 max-w-xl text-white/70 leading-relaxed">
            {content.description}
          </p>
        )}

        {content.meta && (
          <p className="mt-6 text-sm text-white/60">{content.meta}</p>
        )}

        {content.stats && content.stats.length > 0 && (
          <div className="mt-9 border-white/20 border-t pt-6">
            <dl className="grid grid-cols-2 gap-8 sm:grid-cols-4">
              {content.stats.map((stat) => {
                const Icon = stat.icon ? statIcons[stat.icon] : null;

                return (
                  <div key={stat.label}>
                    <dd
                      className={cn(
                        "font-display text-2xl text-white sm:text-3xl",
                        Icon && "flex items-center gap-2",
                      )}
                    >
                      {Icon && <Icon className="size-5 text-white/60" />}
                      {stat.value}
                    </dd>
                    <dt className="mt-1 text-white/60 text-xs leading-snug">
                      {stat.label}
                    </dt>
                  </div>
                );
              })}
            </dl>
          </div>
        )}
      </div>
    </section>
  );
}
```

### `registry/default/sections/page-header-1/data.ts`

```ts
import type { PageHeaderContent } from "../_types/page-header";

export const defaultPageHeader1Content: PageHeaderContent = {
  breadcrumbs: [
    {
      href: "/",
      label: "Home",
    },
    {
      label: "About",
    },
  ],
  description:
    "A small practice, deliberately. Twenty-four people, two offices, and a rule about never pricing a house we have not stood inside.",
  eyebrow: "Est. 1994",
  image: {
    alt: "The harbour at first light, looking toward Rowan Street",
    src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786951154/templates/estate/marbrook/cndn_marbrook009.jpg",
  },
  title: "Thirty-one years on the same three streets.",
};
```

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

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

export type PageHeaderStatIcon = "area" | "bath" | "bed";

export type PageHeaderContent = {
  breadcrumbs: { href?: string; label: string }[];
  description?: string;
  eyebrow: string;
  image: ImageAsset;
  /** A single line under the lede, e.g. author · date · reading time. */
  meta?: string;
  /** A row of figures under the lede, on a hairline rule. */
  stats?: { icon?: PageHeaderStatIcon; label: string; value: string }[];
  title: string;
};
```
