# Legal 1

> Stacked legal documents with anchor ids (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/legal-1).

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/cnippet-blocks/legal-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

- Registry items: `@cnippet/utils`

## Files

### `registry/default/sections/legal-1/index.tsx`

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

import type { Surface } from "../_types/common";
import type { LegalContent } from "../_types/legal";
import { defaultLegal1Content } from "./data";
import { SectionHeader, surfaceClass, toneFor } from "./section-header";

/** Legal documents stacked one after another: title in a narrow column,
 *  plain paragraphs beside it. Each document carries its `id` as an anchor. */
export default function Legal1({
  content = defaultLegal1Content,
  surface = "default",
}: {
  content?: LegalContent;
  surface?: Surface;
}) {
  return (
    <section className={cn("py-20 sm:py-28", surfaceClass[surface])}>
      <div className="mx-auto grid max-w-7xl gap-16 px-5 sm:px-8 lg:px-12">
        {content.documents.map((document) => (
          <div
            // scroll-mt clears a fixed navbar when an anchor link lands here.
            className="grid scroll-mt-24 gap-8 lg:grid-cols-12 lg:gap-16"
            id={document.id}
            key={document.id}
          >
            <div className="lg:col-span-4">
              <SectionHeader
                eyebrow={content.eyebrow}
                size="sm"
                title={document.title}
                tone={toneFor(surface)}
              />
            </div>

            <div className="space-y-5 text-muted-foreground leading-relaxed lg:col-span-8">
              {document.paragraphs.map((paragraph) => (
                <p key={paragraph.slice(0, 40)}>{paragraph}</p>
              ))}
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}
```

### `registry/default/sections/legal-1/data.ts`

```ts
import type { LegalContent } from "../_types/legal";

export const defaultLegal1Content: LegalContent = {
  documents: [
    {
      id: "privacy",
      paragraphs: [
        "We collect only what we need to answer your enquiry and act for you: your name, contact details, and whatever you choose to tell us about what you are looking for. If you instruct us, we also hold the documents a sale or tenancy legally requires.",
        "We never sell your details and we never share them with third-party marketing. We do share them with the parties a transaction requires — your solicitor, a surveyor you have asked us to appoint, or the referencing agency on a tenancy — and only with your knowledge.",
        "The market letter is a separate opt-in. Unsubscribing takes one click and does not affect anything else we hold for you.",
        "You can ask us at any time what we hold, ask for a copy, or ask us to delete it where no legal obligation requires us to keep it. Write to hello@marbrook.com and we will reply within five working days.",
      ],
      title: "Privacy notice",
    },
    {
      id: "terms",
      paragraphs: [
        "Sales commission is 1.4% of the achieved price plus VAT, payable on completion. It covers staging, photography, floor plans, film, and the full marketing campaign. There are no listing fees, no withdrawal fees, and no marketing add-ons.",
        "Sole agency instructions run for twelve weeks, after which either side may end the agreement with two weeks' written notice. We do not use tie-in periods longer than that.",
        "Lettings commission is one month's rent plus VAT for a let-only instruction, or 9% of rent plus VAT for full management, billed monthly in arrears.",
        "Guide prices and valuations are our professional opinion based on comparable evidence available at the time. They are not a guarantee of the price achieved, and they are not a formal valuation for lending or tax purposes unless we have said so in writing.",
        "Any property particulars on this site are provided in good faith. Measurements are taken by our own team and are approximate. Nothing on this site forms part of any contract.",
      ],
      title: "Terms of business",
    },
    {
      id: "fair-housing",
      paragraphs: [
        "Marbrook does not discriminate, and will not act for a client who asks us to, on the basis of race, colour, religion, sex, sexual orientation, gender identity, disability, familial status, national origin, or age.",
        "This applies to every part of what we do: which viewings we offer, how we describe a district, which offers we present to a seller, and which applications we forward to a landlord. Every offer received on a property we market is presented to our client.",
        "We will end an instruction rather than follow a discriminatory one, and we have done so.",
        "If you believe you have been treated unfairly by anyone at this practice, write to Nadia Ellery at nadia@marbrook.com. Every complaint is investigated by a principal and answered in writing within ten working days.",
      ],
      title: "Fair housing commitment",
    },
  ],
  eyebrow: "Marbrook Estates",
};
```

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

```ts
export type LegalContent = {
  documents: {
    id: string;
    paragraphs: string[];
    title: string;
  }[];
  /** Small label above each document title, e.g. the company name. */
  eyebrow: string;
};
```
