# Blog 8

> Three-across grid of article 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/blog-8).

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

## Install

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

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

## Dependencies

- Registry items: `@cnippet/utils`

## Files

### `registry/default/sections/blog-8/index.tsx`

```tsx
import Image from "next/image";
import Link from "next/link";

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

import type { BlogGridContent } from "../_types/blog";
import type { Surface } from "../_types/common";
import { defaultBlog8Content } from "./data";
import {
  eyebrowClass,
  SectionHeader,
  surfaceClass,
  toneFor,
} from "./section-header";

/** A three-across grid of article cards with image, standfirst and byline. */
export default function Blog8({
  content = defaultBlog8Content,
  surface = "muted",
}: {
  content?: BlogGridContent;
  surface?: Surface;
}) {
  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
          className="border-border border-b pb-8"
          eyebrow={content.eyebrow}
          title={content.title}
          tone={toneFor(surface)}
        />

        <div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
          {content.posts.map((post) => (
            <article
              className="group flex flex-col overflow-hidden rounded-lg border border-border bg-card transition-colors hover:border-input"
              key={post.href}
            >
              <div className="relative aspect-3/2 overflow-hidden">
                <Image
                  alt={post.image.alt}
                  className="object-cover transition-transform duration-700 ease-out group-hover:scale-105"
                  fill
                  sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
                  src={post.image.src}
                />
              </div>

              <div className="flex flex-1 flex-col p-6">
                <p className={cn(eyebrowClass, "text-muted-foreground/70")}>
                  {post.category}
                </p>
                <h3 className="mt-3 font-display text-2xl text-primary">
                  <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={post.href}
                  >
                    {post.title}
                  </Link>
                </h3>
                <p className="mt-3 flex-1 text-muted-foreground text-sm leading-relaxed">
                  {post.excerpt}
                </p>
                <p className="mt-6 border-border border-t pt-4 text-muted-foreground text-xs">
                  {[post.author, post.date, post.readTime]
                    .filter(Boolean)
                    .join(" · ")}
                </p>
              </div>
            </article>
          ))}
        </div>
      </div>
    </section>
  );
}
```

### `registry/default/sections/blog-8/data.ts`

```ts
import type { BlogGridContent } from "../_types/blog";

export const defaultBlog8Content: BlogGridContent = {
  eyebrow: "The archive",
  posts: [
    {
      author: "Nadia Ellery",
      category: "Selling",
      date: "2 June 2026",
      excerpt:
        "A private sale trades about 1.8% of price for a great deal of control. Here is when that is worth it.",
      href: "/journal/what-off-market-actually-costs",
      image: {
        alt: "What an off-market sale actually costs you",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786951154/templates/estate/marbrook/cndn_marbrook012.jpg",
      },
      readTime: "5 min read",
      title: "What an off-market sale actually costs you",
    },
    {
      author: "Theo Marchetti",
      category: "Buying",
      date: "19 May 2026",
      excerpt:
        "Three findings are worth renegotiating over. Most of the rest is just a two-hundred-year-old building being two hundred years old.",
      href: "/journal/reading-a-survey-on-an-old-house",
      image: {
        alt: "How to read a survey on an old house",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook004.jpg",
      },
      readTime: "8 min read",
      title: "How to read a survey on an old house",
    },
    {
      author: "Imogen Bell",
      category: "New developments",
      date: "28 April 2026",
      excerpt:
        "Every scheme we have handled in fourteen years has slipped. The median is eleven weeks — plan for it.",
      href: "/journal/buying-off-plan-without-regret",
      image: {
        alt: "Buying off plan without regretting it",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786951154/templates/estate/marbrook/cndn_marbrook011.jpg",
      },
      readTime: "5 min read",
      title: "Buying off plan without regretting it",
    },
    {
      author: "Rafael Okonjo",
      category: "Lettings",
      date: "11 March 2026",
      excerpt:
        "We ran 1.4% void across 260 tenancies last year. Three unglamorous habits did most of the work.",
      href: "/journal/the-only-rental-number-that-matters",
      image: {
        alt: "The only rental number that matters",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786950553/templates/estate/marbrook/cndn_marbrook006.jpg",
      },
      readTime: "4 min read",
      title: "The only rental number that matters",
    },
    {
      author: "Elin Sandoval",
      category: "Relocation",
      date: "6 February 2026",
      excerpt:
        "The tasks are not difficult. They are simply in an order nobody tells you until you have got it wrong.",
      href: "/journal/relocating-in-the-right-order",
      image: {
        alt: "Relocating, in the right order",
        src: "https://res.cloudinary.com/qgiongel/image/upload/q_auto/f_auto/v1786951154/templates/estate/marbrook/cndn_marbrook009.jpg",
      },
      readTime: "6 min read",
      title: "Relocating, in the right order",
    },
  ],
  title: "5 earlier pieces",
};
```

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

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

export type BlogPost = {
  author?: string;
  category: string;
  /** Display-ready, e.g. "14 July 2026". */
  date: string;
  excerpt: string;
  href: string;
  image: ImageAsset;
  readTime: string;
  title: string;
};

export type BlogFeaturedContent = {
  eyebrow: string;
  linkLabel: string;
  post: BlogPost;
  title: string;
};

export type BlogGridContent = {
  eyebrow: string;
  posts: BlogPost[];
  title: string;
};

export type BlogArticleContent = {
  author?: {
    bio: string;
    image: ImageAsset;
    label: string;
    name: string;
    phone?: string;
    role: string;
  };
  paragraphs: string[];
};
```

### `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;
};
```
