# Blog 9

> Article body in a reading column beside an author card (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-9).

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

- Registry items: `@cnippet/utils`

## Files

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

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

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

import type { BlogArticleContent } from "../_types/blog";
import type { Surface } from "../_types/common";
import { defaultBlog9Content } from "./data";

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",
};

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

/** Article body in a reading column beside an author card. */
export default function Blog9({
  content = defaultBlog9Content,
  surface = "default",
}: {
  content?: BlogArticleContent;
  surface?: Surface;
}) {
  const { author } = 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">
          <article className="lg:col-span-7">
            <div className="space-y-6 text-lg text-muted-foreground leading-relaxed">
              {content.paragraphs.map((paragraph) => (
                <p key={paragraph.slice(0, 40)}>{paragraph}</p>
              ))}
            </div>
          </article>

          {author && (
            <aside className="lg:col-span-5">
              <div className="rounded-lg border border-border bg-card p-6">
                <p className="inline-flex items-center gap-2 font-medium text-[0.6875rem] text-muted-foreground/70 uppercase tracking-[0.22em]">
                  {author.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={author.image.alt}
                      className="object-cover"
                      fill
                      sizes="64px"
                      src={author.image.src}
                    />
                  </div>
                  <div>
                    <p className="font-display text-primary text-xl">
                      {author.name}
                    </p>
                    <p className="mt-0.5 text-muted-foreground text-sm">
                      {author.role}
                    </p>
                  </div>
                </div>
                <p className="mt-5 text-muted-foreground text-sm leading-relaxed">
                  {author.bio}
                </p>
                {author.phone && (
                  <a
                    className="mt-5 inline-block border-border border-t pt-5 text-primary text-sm transition-colors hover:text-primary/70"
                    href={telHref(author.phone)}
                  >
                    {author.phone}
                  </a>
                )}
              </div>
            </aside>
          )}
        </div>
      </div>
    </section>
  );
}
```

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

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

export const defaultBlog9Content: BlogArticleContent = {
  author: {
    bio: "Bram writes the quarterly market letter and every formal valuation that leaves the office. He has not missed a guide price by more than four percent since 2019.",
    image: {
      alt: "Bram Halvorsen",
      src: "https://res.cloudinary.com/dvwtcsh5v/image/upload/v1770279333/a6.jpg",
    },
    label: "Written by",
    name: "Bram Halvorsen",
    phone: "+1 (800) 555-0166",
    role: "Head of Valuation",
  },
  paragraphs: [
    "The headline number for the quarter is +4.6% across the six districts we cover, which is the slowest growth we have recorded since 2021 and, we would argue, the healthiest.",
    "Volume is the more interesting figure. Completions were down eleven percent on the same quarter last year, but the average time from accepted offer to exchange fell from sixty-two days to forty-nine. Fewer transactions, moving faster, is what a market looks like when buyers have stopped guessing.",
    "Marina Bay remains the outlier at +8.7%, driven almost entirely by two schemes releasing their final phases at revised prices. Strip those out and the district tracks the average within half a point.",
    "Our expectation for the coming quarter is more of the same: flat to modestly positive, with the sharpest competition in the $1.5M to $3M band where supply is genuinely short. If you are selling in that range, you are in the strongest position of anyone on our books.",
  ],
};
```

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