# Seam: Halftone Section Divider for React

> The edge between two sections, printed as a halftone. Drop it between a light section and a dark one and it reads both backgrounds; as you scroll past, the color below climbs into the band cell by cell along a ragged front, so the next section reaches up to meet you instead of starting at a hard line.

Halftone Section Divider for React: the next section's color climbs in dots as you scroll. Built with Tailwind CSS; install it with the shadcn CLI. It's a KeyframeUI component: the file lands in your project as source, yours to edit.

- Docs and live preview: https://keyframeui.com/components/seam
- Tags: Background, Scroll, Canvas
- npm dependencies: clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/seam.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/seam
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/seam.json
```

## Usage

The main demo shows typical usage.

### Studio Site

A design studio's page, where the hero gives way to a blue stats band and back, in dots, squares or dither.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/seam-demo
```

```tsx title="components/seam-demo.tsx"
"use client";

import * as React from "react";

import { Seam, type SeamPattern } from "@/components/ui/seam";

const patterns: { value: SeamPattern; label: string }[] = [
  { value: "dots", label: "Dots" },
  { value: "squares", label: "Squares" },
  { value: "dither", label: "Dither" },
];

const stats = [
  { value: "42", label: "launches shipped since 2019" },
  { value: "9 days", label: "median from brief to a live first page" },
  { value: "31", label: "teams back for a second release" },
];

const work = [
  { client: "Tessel", job: "Launch site for an AI upscaler", year: "2026" },
  { client: "Okto", job: "Identity and app UI for payroll", year: "2025" },
  { client: "Marrow & Vine", job: "Site and menus for a restaurant group", year: "2025" },
];

export function SeamDemo() {
  const [pattern, setPattern] = React.useState<SeamPattern>("dots");

  return (
    <div className="relative h-[var(--demo-height,36rem)] w-full overflow-hidden rounded-2xl border border-zinc-950/10 bg-white text-zinc-950 dark:border-white/10 dark:bg-[#07080c] dark:text-zinc-100">
      <div className="h-full overflow-y-auto overscroll-contain">
        <section className="bg-white px-6 pt-5 pb-14 sm:px-10 dark:bg-[#07080c]">
          <nav className="flex items-center justify-between gap-4">
            <a href="#" className="flex items-center gap-2 text-sm font-semibold tracking-[-0.02em]">
              <span aria-hidden className="grid size-5 grid-cols-2 gap-px">
                <span className="bg-current" />
                <span className="bg-[#2747ff]" />
                <span className="bg-[#2747ff]" />
                <span className="bg-current" />
              </span>
              Halden
            </a>
            <div className="hidden items-center gap-6 text-sm text-zinc-500 sm:flex dark:text-zinc-400">
              <a href="#" className="hover:text-zinc-950 dark:hover:text-white">Work</a>
              <a href="#" className="hover:text-zinc-950 dark:hover:text-white">Studio</a>
              <a href="#" className="hover:text-zinc-950 dark:hover:text-white">Journal</a>
            </div>
          </nav>
          <p className="mt-14 text-xs font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
            Brand and product studio · Oslo
          </p>
          <h1 className="mt-3 max-w-xl text-4xl font-medium tracking-[-0.035em] text-balance sm:text-5xl">
            We design the first five seconds.
          </h1>
          <p className="mt-4 max-w-md text-[15px] leading-relaxed text-zinc-500 dark:text-zinc-400">
            Identities, launch sites and product UI for teams heading into their first big release.
          </p>
          <div className="mt-7 flex flex-wrap items-center gap-x-5 gap-y-3">
            <a
              href="#"
              className="rounded-full bg-zinc-950 px-5 py-2.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200"
            >
              See the work
            </a>
            <a href="mailto:hello@halden.example" className="text-sm text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white">
              hello@halden.example
            </a>
          </div>
        </section>

        <Seam pattern={pattern} className="h-36" />

        <section className="bg-[#2747ff] px-6 py-14 text-white sm:px-10">
          <p className="text-xs font-medium tracking-wide text-white/70 uppercase">Since 2019</p>
          <dl className="mt-6 grid gap-8 sm:grid-cols-3">
            {stats.map((stat) => (
              <div key={stat.label}>
                <dt className="sr-only">{stat.label}</dt>
                <dd className="text-4xl font-medium tracking-[-0.03em] tabular-nums">{stat.value}</dd>
                <dd className="mt-2 max-w-48 text-sm leading-snug text-white/75">{stat.label}</dd>
              </div>
            ))}
          </dl>
        </section>

        <Seam pattern={pattern} seed={5} className="h-36" />

        <section className="bg-white px-6 pt-10 pb-12 sm:px-10 dark:bg-[#07080c]">
          <h2 className="text-xs font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">Selected work</h2>
          <ul className="mt-4 divide-y divide-zinc-950/10 border-y border-zinc-950/10 dark:divide-white/10 dark:border-white/10">
            {work.map((item) => (
              <li key={item.client} className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-4">
                <span className="text-[15px] font-medium tracking-[-0.01em] sm:w-32 sm:shrink-0">{item.client}</span>
                <span className="order-last basis-full text-sm text-zinc-500 sm:order-none sm:flex-1 sm:basis-auto dark:text-zinc-400">
                  {item.job}
                </span>
                <span className="ml-auto text-sm text-zinc-400 tabular-nums dark:text-zinc-500">{item.year}</span>
              </li>
            ))}
          </ul>
          <div className="mt-12 flex flex-wrap items-end justify-between gap-6">
            <p className="max-w-sm text-2xl font-medium tracking-[-0.03em] text-balance">Have a launch on the calendar?</p>
            <a
              href="#"
              className="rounded-full bg-[#2747ff] px-5 py-2.5 text-sm font-medium text-white hover:bg-[#1f3be0]"
            >
              Book a call
            </a>
          </div>
          <p className="mt-12 text-xs text-zinc-400 dark:text-zinc-500">© 2026 Halden Studio · halden.example</p>
        </section>
      </div>

      <div
        role="group"
        aria-label="Seam pattern"
        className="absolute right-3 bottom-3 flex rounded-full border border-zinc-950/10 bg-white/90 p-0.5 text-xs shadow-sm backdrop-blur dark:border-white/10 dark:bg-zinc-900/90"
      >
        {patterns.map((option) => (
          <button
            key={option.value}
            type="button"
            aria-pressed={pattern === option.value}
            onClick={() => setPattern(option.value)}
            className={
              pattern === option.value
                ? "rounded-full bg-zinc-950 px-3 py-1 font-medium text-white dark:bg-white dark:text-zinc-950"
                : "rounded-full px-3 py-1 text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white"
            }
          >
            {option.label}
          </button>
        ))}
      </div>
    </div>
  );
}
```

## Examples

Finished sections built with Seam. Each one installs whole, with the component included.

### Night Footer

A closing call to action that dithers down into a night footer with a giant wordmark.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/seam-footer-demo
```

```tsx title="components/seam-footer-demo.tsx"
"use client";

import { Seam } from "@/components/ui/seam";

const columns = [
  { title: "Product", links: ["Evals", "Traces", "Datasets", "Pricing"] },
  { title: "Developers", links: ["Docs", "API reference", "Changelog", "Status"] },
  { title: "Company", links: ["About", "Careers", "Security", "Contact"] },
];

export function SeamFooterDemo() {
  return (
    <div className="h-[var(--demo-height,36rem)] w-full overflow-hidden rounded-2xl border border-zinc-950/10 dark:border-white/10">
      <div className="h-full overflow-y-auto overscroll-contain">
        <section className="bg-white px-6 pt-16 pb-20 text-center text-zinc-950 sm:px-10 dark:bg-zinc-900 dark:text-zinc-100">
          <h2 className="mx-auto max-w-lg text-3xl font-medium tracking-[-0.035em] text-balance sm:text-4xl">
            Know your agent works before your users do.
          </h2>
          <p className="mx-auto mt-4 max-w-md text-[15px] leading-relaxed text-zinc-500 dark:text-zinc-400">
            Gauge replays two thousand scripted conversations against every prompt change and shows you which ones broke.
          </p>
          <form className="mx-auto mt-8 flex max-w-sm gap-2" onSubmit={(e) => e.preventDefault()}>
            <label htmlFor="seam-footer-email" className="sr-only">
              Work email
            </label>
            <input
              id="seam-footer-email"
              type="email"
              placeholder="you@company.example"
              className="h-11 min-w-0 flex-1 rounded-full border border-zinc-950/10 bg-white px-4 text-sm placeholder:text-zinc-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-950 dark:border-white/10 dark:bg-zinc-950 dark:focus-visible:outline-white"
            />
            <button
              type="submit"
              className="h-11 shrink-0 rounded-full bg-zinc-950 px-5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-950 dark:hover:bg-zinc-200"
            >
              Start free
            </button>
          </form>
          <p className="mt-3 text-xs text-zinc-400 dark:text-zinc-500">Free for the first 10,000 runs a month.</p>
        </section>

        <Seam pattern="dither" cell={10} grain={0.75} className="h-44" />

        <footer className="@container bg-zinc-950 px-6 pt-10 text-zinc-400 sm:px-10 dark:bg-black">
          <div className="grid grid-cols-2 gap-8 sm:grid-cols-[1.4fr_repeat(3,1fr)]">
            <div className="col-span-2 sm:col-span-1">
              <p className="text-sm font-semibold tracking-[-0.02em] text-white">Gauge</p>
              <p className="mt-2 max-w-56 text-sm leading-relaxed">Evals and traces for teams shipping AI agents.</p>
            </div>
            {columns.map((column) => (
              <nav key={column.title} aria-label={column.title}>
                <p className="text-xs font-medium tracking-wide text-zinc-500 uppercase">{column.title}</p>
                <ul className="mt-3 space-y-2 text-sm">
                  {column.links.map((link) => (
                    <li key={link}>
                      <a href="#" className="hover:text-white">
                        {link}
                      </a>
                    </li>
                  ))}
                </ul>
              </nav>
            ))}
          </div>
          <div className="mt-12 flex flex-wrap justify-between gap-2 border-t border-white/10 pt-5 text-xs text-zinc-500">
            <span>© 2026 Gauge Labs, Inc. · gauge.example</span>
            <span>SOC 2 Type II · Made in Lisbon</span>
          </div>
          <p
            aria-hidden
            className="mt-6 -mb-[0.2em] text-[27cqw] leading-none font-semibold tracking-[-0.06em] text-white/[0.07] select-none"
          >
            gauge
          </p>
        </footer>
      </div>
    </div>
  );
}
```

### Changelog

A product changelog where the tinted latest release fades into the list of earlier ones through fine dots.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/seam-changelog-demo
```

```tsx title="components/seam-changelog-demo.tsx"
"use client";

import { Seam } from "@/components/ui/seam";

const latest = {
  version: "4.2",
  date: "Nov 4, 2026",
  title: "Live cursors in comment threads",
  notes: [
    "See who is reading a thread and where, with their cursor on the card they're looking at.",
    "Replies arrive in place instead of reloading the thread.",
    "Mentions now notify on mobile within two seconds.",
  ],
};

const earlier = [
  {
    version: "4.1",
    date: "Oct 21, 2026",
    title: "Branching boards",
    notes: "Fork any board to try a layout, then merge it back with a diff of every card that moved.",
  },
  {
    version: "4.0",
    date: "Oct 2, 2026",
    title: "Offline mode",
    notes: "Boards open and edit without a connection, and sync the moment you're back.",
  },
  {
    version: "3.9",
    date: "Sep 12, 2026",
    title: "Export to SVG and PDF",
    notes: "Export a frame or a whole board, with fonts embedded and links kept.",
  },
  {
    version: "3.8",
    date: "Aug 28, 2026",
    title: "Faster search",
    notes: "Search now covers card text, comments and file names, in under 100ms on boards of 10,000 cards.",
  },
  {
    version: "3.7",
    date: "Aug 7, 2026",
    title: "Guest seats",
    notes: "Invite clients to a single board with comment-only access. Guests don't count toward your plan.",
  },
  {
    version: "3.6",
    date: "Jul 17, 2026",
    title: "Card templates",
    notes: "Save any card as a template with its fields, checklist and cover, and reuse it across boards.",
  },
  {
    version: "3.5",
    date: "Jun 30, 2026",
    title: "Keyboard-first editing",
    notes: "Move, link and resize cards without the mouse. Press ? on any board for the full map.",
  },
];

export function SeamChangelogDemo() {
  return (
    <div className="h-[var(--demo-height,36rem)] w-full overflow-hidden rounded-2xl border border-zinc-950/10 text-zinc-950 dark:border-white/10 dark:text-zinc-100">
      <div className="h-full overflow-y-auto overscroll-contain">
        <section className="bg-[#e6ebff] px-6 pt-8 pb-12 sm:px-10 dark:bg-[#111a3f]">
          <div className="flex items-baseline justify-between gap-4">
            <p className="text-sm font-semibold tracking-[-0.02em]">Brindle</p>
            <a href="#" className="text-sm text-[#2747ff] hover:underline dark:text-[#8ea2ff]">
              Subscribe to updates
            </a>
          </div>
          <h1 className="mt-10 text-3xl font-medium tracking-[-0.035em] sm:text-4xl">Changelog</h1>
          <article className="mt-8 max-w-2xl">
            <p className="text-xs font-medium tracking-wide text-[#2747ff] uppercase tabular-nums dark:text-[#8ea2ff]">
              Latest · v{latest.version} · {latest.date}
            </p>
            <h2 className="mt-2 text-xl font-medium tracking-[-0.02em]">{latest.title}</h2>
            <ul className="mt-4 space-y-2 text-[15px] leading-relaxed text-zinc-600 dark:text-zinc-300">
              {latest.notes.map((note) => (
                <li key={note} className="flex gap-3">
                  <span aria-hidden className="mt-[0.6em] size-1.5 shrink-0 rounded-full bg-[#2747ff] dark:bg-[#8ea2ff]" />
                  {note}
                </li>
              ))}
            </ul>
          </article>
        </section>

        <Seam cell={8} grain={0.5} className="h-32" />

        <section className="bg-white px-6 pt-6 pb-12 sm:px-10 dark:bg-[#07080c]">
          <h2 className="text-xs font-medium tracking-wide text-zinc-500 uppercase dark:text-zinc-400">Earlier releases</h2>
          <ol className="mt-4 divide-y divide-zinc-950/10 dark:divide-white/10">
            {earlier.map((release) => (
              <li key={release.version} className="grid gap-1 py-5 sm:grid-cols-[9rem_1fr] sm:gap-6">
                <p className="text-sm text-zinc-500 tabular-nums dark:text-zinc-400">
                  v{release.version} · {release.date}
                </p>
                <div>
                  <h3 className="text-[15px] font-medium tracking-[-0.01em]">{release.title}</h3>
                  <p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">{release.notes}</p>
                </div>
              </li>
            ))}
          </ol>
        </section>
      </div>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `pattern` | `"dots" \| "squares" \| "dither"` | `"dots"` | What the band breaks up into: halftone `dots` whose gaps close like a print's, halftone `squares`, or a pixel `dither` that switches whole cells through a 4 × 4 ordered matrix. |
| `cell` | `number` | `12` | Size of one cell, in px. Rounded to whole pixels so full cells meet without seams. |
| `grain` | `number` | `0.6` | How ragged the front is, from a straight line (`0`) to deep tongues (`1`). |
| `progress` | `number` |  | Hold or drive the front yourself, from `0` (all the section above) to `1` (all the section below). Leave it out to follow scroll in the nearest scrolling box or the window. |
| `seed` | `number` | `1` | Changes the shape of the front's tongues. Give two seams on one page different seeds. |
| `stiffness` | `number` | `120` | Stiffness of the spring the front rides as it follows scroll. Higher keeps it closer to the scroll; lower lets it trail behind and glide in. |
| `damping` | `number` | `30` | Damping of that spring. Lower lets the front overshoot and settle back after a fast scroll. |
| `fromClassName` | `string` |  | A `text-*` color for the section above. Without it, the band reads the background of the element before it, and follows it into dark mode. |
| `toClassName` | `string` |  | A `text-*` color for the section below. Without it, the band reads the background of the element after it. |
| `children` | `React.ReactNode` |  | Optional content drawn over the band, such as a label. |
| `className` | `string` |  | Extra classes for the root. Set the band's height here; it defaults to `h-40`. |

## What to keep

To adapt it, change its props first. When you do edit `components/ui/seam.tsx`, keep these, which make it feel and behave right:

- The front is painted on one canvas by one rAF loop that stops once the spring settles; scroll never re-renders React.
- Cells are whole pixels and the rows are anchored to the band's bottom, so full cells meet edge to edge and the band meets the next section exactly.
- The top and bottom `edgeRows` are pinned to their section's color, so neither edge of the band shows a line.
- Mounting, resizing and coming back on screen snap the front to the scroll position instead of springing to it.
- Under reduced motion the front skips the spring and sits exactly where the scroll puts it.

Its timing (durations, delays, springs and easings) is all in the `TIMING` block at the top of the file; the timing props default to it.

## Source

```tsx title="components/ui/seam.tsx"
"use client";

import * as React from "react";

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

/**
 * Keep:
 * - The front is painted on one canvas by one rAF loop that stops once the spring settles; scroll
 *   never re-renders React.
 * - Cells are whole pixels and the rows are anchored to the band's bottom, so full cells meet edge
 *   to edge and the band meets the next section exactly.
 * - The top and bottom `edgeRows` are pinned to their section's color, so neither edge of the band
 *   shows a line.
 * - Mounting, resizing and coming back on screen snap the front to the scroll position instead of
 *   springing to it.
 * - Under reduced motion the front skips the spring and sits exactly where the scroll puts it.
 */
const TIMING = {
  /** The spring the front rides as it follows scroll (the `stiffness` and `damping` props). */
  front: { stiffness: 120, damping: 30, mass: 0.4 },
} as const;

/* ─────────────────────────────────────────────────────────
 * SEAM STORYBOARD
 *
 *  rest      a band between two sections, printed as a grid of
 *            cells. The top row is all the section above, the
 *            bottom row all the section below, and a ragged front
 *            of half-filled cells runs between them, so neither
 *            edge of the band shows a line
 *  scroll    the front follows the band through its scrollport.
 *            When the band comes up from the bottom of the screen
 *            the section above still holds most of it; as it rises
 *            the color below climbs into it cell by cell, and by
 *            the time it leaves at the top the lower section has
 *            taken it over. The next section reaches up to meet
 *            the reader instead of starting at a hard edge
 *  smooth    the front rides a scroll-smoothing spring (stiffness
 *            120, damping 30, mass 0.4), so a wheel tick glides
 *            instead of stepping
 *  grain     the front is ragged, not a ruled line: each cell's
 *            threshold is shifted by low-frequency noise plus a
 *            little jitter of its own, so the lower color creeps
 *            up in tongues, like ink wicking into paper
 *  patterns  `dots` and `squares` grow by area like a halftone;
 *            `dither` switches whole cells on and off through a
 *            4 × 4 ordered matrix, like an old pixel dissolve
 *  colors    with no classes it reads the background of the
 *            element above and the element below, so it matches
 *            the two sections it sits between and follows them
 *            into dark mode
 *  progress  pass `progress` (0–1) to hold or drive the front
 *            yourself; the scrollport is ignored
 *  reduced   the same mapping to scroll, without the spring: the
 *            front sits exactly where the scroll puts it
 * ───────────────────────────────────────────────────────── */

const SEAM = {
  ramp: 1, // share of the band height the front's half-filled cells span
  noise: 0.5, // share of the band height the front wanders at `grain` 1
  jitter: 0.3, // share of `noise` that is each cell's own, the rest is shared with its neighbours
  noiseScale: [7, 2.5], // cells per noise period, across and down
  edgeRows: 2.5, // rows over which the top and bottom edges are pinned to their section
  minFill: 0.02, // cells under this fill draw nothing
  grout: 3, // px, the narrowest gap a halftone square leaves before it fills its cell
  maxDpr: 2,
} as const;

// 4 × 4 Bayer matrix, as thresholds in (0, 1).
const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map((b) => (b + 0.5) / 16);

const clamp01 = (t: number) => Math.min(1, Math.max(0, t));
const smoothstep = (a: number, b: number, x: number) => {
  const t = clamp01((x - a) / (b - a));
  return t * t * (3 - 2 * t);
};

/** A stable 0–1 value per integer lattice point. */
function hash(x: number, y: number, seed: number) {
  let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(seed, 1442695041)) | 0;
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  h ^= h >>> 16;
  return (h >>> 0) / 4294967295;
}

/** Smooth value noise in (-1, 1). */
function noise(x: number, y: number, seed: number) {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const fx = x - xi;
  const fy = y - yi;
  const u = fx * fx * (3 - 2 * fx);
  const v = fy * fy * (3 - 2 * fy);
  const a = hash(xi, yi, seed);
  const b = hash(xi + 1, yi, seed);
  const c = hash(xi, yi + 1, seed);
  const d = hash(xi + 1, yi + 1, seed);
  return (a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v) * 2 - 1;
}

function isTransparent(color: string) {
  return color === "transparent" || /rgba?\([^)]*,\s*0\)$/.test(color) || /\/\s*0\)$/.test(color);
}

/** The first opaque background at or above `el`. */
function backgroundOf(el: Element | null) {
  for (let node = el; node; node = node.parentElement) {
    const color = getComputedStyle(node).backgroundColor;
    if (!isTransparent(color)) return color;
  }
  return null;
}

function scrollParent(el: HTMLElement) {
  for (let node = el.parentElement; node; node = node.parentElement) {
    const { overflowY } = getComputedStyle(node);
    if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") return node;
  }
  return null;
}

export type SeamPattern = "dots" | "squares" | "dither";

type Options = {
  pattern: SeamPattern;
  cell: number;
  grain: number;
  progress: number | undefined;
  seed: number;
  stiffness: number;
  damping: number;
  reduced: boolean;
};

function createSeam() {
  let root: HTMLElement | null = null;
  let canvas: HTMLCanvasElement | null = null;
  let ctx: CanvasRenderingContext2D | null = null;
  let probes: { from: HTMLElement | null; to: HTMLElement | null } = { from: null, to: null };
  let scroller: HTMLElement | null = null;
  let options: Options = {
    pattern: "dots",
    cell: 12,
    grain: 0.6,
    progress: undefined,
    seed: 1,
    stiffness: TIMING.front.stiffness,
    damping: TIMING.front.damping,
    reduced: false,
  };

  let width = 0;
  let height = 0;
  let dpr = 1;
  let cols = 0;
  let rows = 0;
  let x0 = 0;
  let y0 = 0;
  let offsets = new Float32Array(0);
  let colors = { from: "#ffffff", to: "#09090b" };

  let target = 0;
  let front = 0;
  let velocity = 0;
  let raf = 0;
  let last = 0;
  let visible = true;
  const cleanups: (() => void)[] = [];

  function readColors() {
    if (!root) return;
    const above = probes.from ? getComputedStyle(probes.from).color : backgroundOf(root.previousElementSibling);
    const below = probes.to ? getComputedStyle(probes.to).color : backgroundOf(root.nextElementSibling);
    const page = backgroundOf(root.parentElement) ?? "#ffffff";
    colors = { from: above ?? page, to: below ?? getComputedStyle(root).color };
  }

  function layout() {
    if (!root || !canvas || !ctx) return;
    const rect = root.getBoundingClientRect();
    const nextDpr = Math.min(window.devicePixelRatio || 1, SEAM.maxDpr);
    width = rect.width;
    height = rect.height;
    dpr = nextDpr;
    canvas.width = Math.max(1, Math.round(width * dpr));
    canvas.height = Math.max(1, Math.round(height * dpr));

    const cell = Math.max(4, Math.round(options.cell));
    cols = Math.ceil(width / cell) + 1;
    rows = Math.ceil(height / cell);
    // Centre the columns; anchor the rows to the bottom, where the band must meet the next section exactly.
    x0 = Math.round((width - cols * cell) / 2);
    y0 = Math.round(height - rows * cell);

    offsets = new Float32Array(cols * rows);
    const [sx, sy] = SEAM.noiseScale;
    for (let j = 0; j < rows; j++) {
      for (let i = 0; i < cols; i++) {
        const shared = noise(i / sx, j / sy, options.seed);
        const own = hash(i, j, options.seed + 7) * 2 - 1;
        offsets[j * cols + i] = shared * (1 - SEAM.jitter) + own * SEAM.jitter;
      }
    }
    scroller = scrollParent(root);
  }

  /** Where the band sits in its scrollport: 0 entering at the bottom, 1 leaving at the top. */
  function measure() {
    if (options.progress !== undefined) return clamp01(options.progress);
    if (!root) return 0;
    const rect = root.getBoundingClientRect();
    let top = 0;
    let bottom = window.innerHeight;
    if (scroller) {
      const port = scroller.getBoundingClientRect();
      top = port.top;
      bottom = port.bottom;
    }
    return clamp01((bottom - rect.top) / (bottom - top + rect.height));
  }

  function draw() {
    if (!ctx || !canvas || width === 0) return;
    // Whole pixels, so full cells meet edge to edge with no antialiased seam between them.
    const cell = Math.max(4, Math.round(options.cell));
    const amp = SEAM.noise * options.grain;
    const ramp = SEAM.ramp;
    // The front's centre, from below the band (nothing climbed yet) to above it (all climbed).
    const reach = ramp / 2 + amp;
    const at = 1 + reach - front * (1 + 2 * reach);
    const edge = SEAM.edgeRows / rows;
    const dither = options.pattern === "dither";
    const dots = options.pattern === "dots";

    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = colors.from;
    ctx.fillRect(0, 0, width, height);
    ctx.fillStyle = colors.to;
    ctx.beginPath();
    for (let j = 0; j < rows; j++) {
      const cy = y0 + (j + 0.5) * cell;
      const v = cy / height;
      const pinTop = smoothstep(0, edge, v - 0.5 / rows);
      const pinBottom = smoothstep(0, edge, 1 - v - 0.5 / rows);
      for (let i = 0; i < cols; i++) {
        const k = j * cols + i;
        let fill = smoothstep(0, 1, (v - at + offsets[k] * amp) / ramp + 0.5);
        fill *= pinTop;
        fill = 1 - (1 - fill) * pinBottom;
        if (fill < SEAM.minFill) continue;
        const x = x0 + i * cell;
        const y = y0 + j * cell;
        if (dither) {
          if (fill > BAYER[(j % 4) * 4 + (i % 4)]) ctx.rect(x, y, cell, cell);
        } else if (fill > 0.985) {
          ctx.rect(x, y, cell, cell);
        } else if (dots) {
          // Circles overlap their neighbours past half, so the gaps close the way a print halftone's do.
          const r = cell * 0.7072 * Math.sqrt(fill);
          ctx.moveTo(x + cell / 2 + r, y + cell / 2);
          ctx.arc(x + cell / 2, y + cell / 2, r, 0, Math.PI * 2);
        } else {
          // Even pixel sizes keep a square centred on the pixel grid, and a square within
          // `grout` of its cell fills it, so the field never shows hairline outlines.
          const side = Math.floor((cell * Math.sqrt(fill)) / 2) * 2;
          if (cell - side < SEAM.grout) ctx.rect(x, y, cell, cell);
          else if (side >= 2) ctx.rect(x + (cell - side) / 2, y + (cell - side) / 2, side, side);
        }
      }
    }
    ctx.fill();
  }

  function frame(now: number) {
    raf = 0;
    const dt = Math.min(1 / 30, (now - last) / 1000 || 1 / 60);
    last = now;
    if (options.reduced) {
      front = target;
      velocity = 0;
    } else {
      const { stiffness, damping } = options;
      const { mass } = TIMING.front;
      const accel = (stiffness * (target - front) - damping * velocity) / mass;
      velocity += accel * dt;
      front += velocity * dt;
    }
    draw();
    if (Math.abs(target - front) < 1e-4 && Math.abs(velocity) < 1e-3) {
      front = target;
      velocity = 0;
      draw();
      return;
    }
    raf = requestAnimationFrame(frame);
  }

  function wake() {
    target = measure();
    if (!visible) return;
    if (!raf) {
      last = performance.now();
      raf = requestAnimationFrame(frame);
    }
  }

  /** Jump straight to the scroll position: on mount, resize and coming back on screen. */
  function snap() {
    target = front = measure();
    velocity = 0;
    draw();
  }

  return {
    connect(el: { root: HTMLElement; canvas: HTMLCanvasElement; from: HTMLElement | null; to: HTMLElement | null }) {
      root = el.root;
      canvas = el.canvas;
      ctx = canvas.getContext("2d");
      probes = { from: el.from, to: el.to };
      readColors();
      layout();
      snap();

      const resize = new ResizeObserver(() => {
        layout();
        snap();
      });
      resize.observe(root);
      const io = new IntersectionObserver(([entry]) => {
        visible = entry.isIntersecting;
        if (visible) snap();
      });
      io.observe(root);

      // Scroll events don't bubble, but they do pass through the capture phase, so one listener
      // hears the window and whatever box the band scrolls inside.
      const onScroll = () => wake();
      window.addEventListener("scroll", onScroll, { capture: true, passive: true });
      window.addEventListener("resize", onScroll, { passive: true });

      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      const recolor = () => {
        // Let the new scheme's styles land before reading the neighbours' backgrounds.
        requestAnimationFrame(() => {
          readColors();
          draw();
        });
      };
      scheme.addEventListener("change", recolor);
      const themeAttrs = new MutationObserver(recolor);
      themeAttrs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });

      cleanups.push(
        () => resize.disconnect(),
        () => io.disconnect(),
        () => window.removeEventListener("scroll", onScroll, { capture: true }),
        () => window.removeEventListener("resize", onScroll),
        () => scheme.removeEventListener("change", recolor),
        () => themeAttrs.disconnect(),
      );
      return () => {
        cancelAnimationFrame(raf);
        raf = 0;
        cleanups.splice(0).forEach((fn) => fn());
        root = canvas = scroller = null;
        ctx = null;
      };
    },

    setOptions(next: Options) {
      const relayout = next.cell !== options.cell || next.seed !== options.seed;
      const driven = next.progress !== options.progress;
      options = next;
      if (relayout) layout();
      readColors();
      if (driven && next.progress !== undefined) wake();
      else snap();
    },
  };
}

const REDUCED = "(prefers-reduced-motion: reduce)";
function subscribeReduced(onChange: () => void) {
  const query = window.matchMedia(REDUCED);
  query.addEventListener("change", onChange);
  return () => query.removeEventListener("change", onChange);
}
function useReduced() {
  return React.useSyncExternalStore(
    subscribeReduced,
    () => window.matchMedia(REDUCED).matches,
    () => false,
  );
}

export interface SeamProps extends React.HTMLAttributes<HTMLDivElement> {
  /** What the band breaks up into: halftone `dots`, halftone `squares`, or a pixel `dither`. */
  pattern?: SeamPattern;
  /** Size of one cell, in px. */
  cell?: number;
  /** How ragged the front is, from a straight line (`0`) to deep tongues (`1`). */
  grain?: number;
  /** Hold or drive the front yourself, from `0` (all the section above) to `1` (all the section below). Leave it out to follow scroll. */
  progress?: number;
  /** Changes the shape of the front's tongues. */
  seed?: number;
  /** Stiffness of the spring the front rides as it follows scroll. Higher keeps it closer to the scroll. */
  stiffness?: number;
  /** Damping of that spring. Lower lets the front overshoot and settle back. */
  damping?: number;
  /** A `text-*` color for the section above. Without it, the band reads the background of the element before it. */
  fromClassName?: string;
  /** A `text-*` color for the section below. Without it, the band reads the background of the element after it. */
  toClassName?: string;
}

export function Seam({
  pattern = "dots",
  cell = 12,
  grain = 0.6,
  progress,
  seed = 1,
  stiffness = TIMING.front.stiffness,
  damping = TIMING.front.damping,
  fromClassName,
  toClassName,
  className,
  children,
  ...props
}: SeamProps) {
  const rootRef = React.useRef<HTMLDivElement>(null);
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const fromRef = React.useRef<HTMLSpanElement>(null);
  const toRef = React.useRef<HTMLSpanElement>(null);
  const [seam] = React.useState(createSeam);
  const reduced = useReduced();

  React.useLayoutEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    return seam.connect({ root, canvas, from: fromRef.current, to: toRef.current });
  }, [seam, fromClassName, toClassName]);

  React.useEffect(() => {
    seam.setOptions({
      pattern,
      cell,
      grain,
      progress,
      seed,
      stiffness: Math.max(1, stiffness),
      damping: Math.max(0, damping),
      reduced,
    });
  }, [seam, pattern, cell, grain, progress, seed, stiffness, damping, reduced]);

  return (
    <div ref={rootRef} className={cn("relative isolate h-40 w-full overflow-hidden", className)} {...props}>
      <canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 -z-10 size-full" />
      {fromClassName ? <span ref={fromRef} aria-hidden className={cn("hidden", fromClassName)} /> : null}
      {toClassName ? <span ref={toRef} aria-hidden className={cn("hidden", toClassName)} /> : null}
      {children}
    </div>
  );
}
```
