Christian Katzmann

Marginalia

Component 1

About

A list with a margin that reads whatever you are looking at. The cards scroll, a panel beside them stays put and renames itself to whichever card owns the middle of the screen, and on a narrow screen every card takes its name with it. One TSX file, no dependencies.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Marginalia to your project and fits it to your theme.

Code

How Marginalia is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

/**
 * The cards are only colour.
 *
 * On ktzm.dk a work card whose art has not been drawn yet falls back to a mesh
 * built from the project's own accent, and those placeholders turned out to be
 * the best-looking thing in the list. This demo is that fallback on its own,
 * with the panel doing all of the talking, which is the case Marginalia was
 * built for in the first place.
 *
 * The recipe is the site's, ported from a WebGL mesh gradient to layered
 * radial gradients so the demo stays dependency-free: four stops from one
 * accent (a shadow, the accent, a lift, and a stop that lands almost on the
 * page), grain over the top, and a vignette to seat it in its frame. That last
 * stop is what keeps a card from reading as a solid colour block. It gives the
 * mesh somewhere to fall away to, so the shape shows.
 */
import Marginalia, { type MarginaliaItem } from "@/components/ui/marginalia";

const DEEP = "#10151b";   // shadow end
const PAPER = "#fafcfd";  // the page's own ground

type RGB = [number, number, number];

const parse = (hex: string): RGB => {
  const h = hex.replace("#", "");
  return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
};
const mix = (a: RGB, b: RGB, t: number): RGB => [
  Math.round(a[0] + (b[0] - a[0]) * t),
  Math.round(a[1] + (b[1] - a[1]) * t),
  Math.round(a[2] + (b[2] - a[2]) * t),
];
const css = (c: RGB, alpha = 1) => `rgba(${c[0]},${c[1]},${c[2]},${alpha})`;

/** Deterministic, so the card looks the same on the server and in the browser. */
const rng = (seed: number) => () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;

/**
 * Film grain, as a tiled turbulence tile rather than a canvas.
 *
 * The `feComponentTransfer` is the part that matters. Raw fractal noise sits in
 * a narrow band around mid grey, and mid grey is exactly what `overlay` leaves
 * alone, so the obvious version of this renders perfectly and shows nothing.
 * Stretching the channels to a full black-to-white range is what gives overlay
 * something to work with.
 */
const GRAIN =
  "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='2.6' intercept='-0.8'/%3E%3CfeFuncG type='linear' slope='2.6' intercept='-0.8'/%3E%3CfeFuncB type='linear' slope='2.6' intercept='-0.8'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E\")";

function Mesh({ accent, seed }: { accent: string; seed: number }) {
  const a = parse(accent);
  const deep = parse(DEEP);
  const paper = parse(PAPER);

  // The four stops, exactly as the site builds them.
  const shadow = mix(a, deep, 0.72);
  const lift = mix(a, [255, 255, 255], 0.42);
  const fall = mix(a, paper, 0.82);

  // One mesh, a different corner of it per card, so no two land on the same
  // shape. The numbers are the site's own: a prime step so the sequence does
  // not repeat across a page of them.
  const r = rng(1009 + seed * 617);
  const blob = (colour: string, size: number) =>
    `radial-gradient(${(55 + r() * 45).toFixed(0)}% ${(50 + r() * 40).toFixed(0)}% at ${(r() * 100).toFixed(0)}% ${(r() * 100).toFixed(0)}%, ${colour} 0%, transparent ${size}%)`;

  return (
    <div style={{ position: "absolute", inset: 0, background: css(shadow), overflow: "hidden" }}>
      <div
        style={{
          position: "absolute",
          inset: "-10%",
          backgroundImage: [
            blob(css(fall, 0.95), 62),
            blob(css(lift, 0.9), 58),
            blob(css(a, 1), 66),
            blob(css(lift, 0.55), 70),
            blob(css(a, 0.9), 74),
          ].join(","),
          filter: "blur(16px)",
        }}
      />
      {/* Grain. Over the colour, under the vignette, the way the shader runs it. */}
      <div style={{ position: "absolute", inset: 0, backgroundImage: GRAIN, opacity: 0.13, mixBlendMode: "overlay" }} />
      {/* Vignette, 0.22, to seat the picture in its frame. */}
      <div style={{ position: "absolute", inset: 0, background: "radial-gradient(120% 120% at 50% 50%, transparent 42%, rgba(8,12,16,.22) 100%)" }} />
    </div>
  );
}

/**
 * One key of the mesh per row. Every accent here is a colour the site actually
 * spends somewhere, which is why they sit together: they were picked against
 * each other rather than off a wheel.
 */
const keys: { id: string; name: string; note: string; hex: string; family: string; finish: string; aspect: string }[] = [
  { id: "verdigris", name: "Verdigris", note: "The house green. Everything the site signs its own name with is this.", hex: "#176247", family: "Green", finish: "Deep", aspect: "16 / 9" },
  { id: "emerald", name: "Emerald", note: "One step brighter and it stops being paint and starts being a signal.", hex: "#10b981", family: "Green", finish: "Lit", aspect: "16 / 9" },
  { id: "kiln", name: "Kiln", note: "Rust, a deeper relative of the orange. Warm ones read as a lamp; cool ones read as a screen.", hex: "#9c5033", family: "Red", finish: "Matte", aspect: "8 / 5" },
  { id: "flare", name: "Flare", note: "The one colour nothing else on the page is allowed to spend.", hex: "#e65f2e", family: "Orange", finish: "Lit", aspect: "16 / 9" },
  { id: "ochre", name: "Ochre", note: "Amber with the sugar taken out, so it can sit next to the green.", hex: "#c68a42", family: "Yellow", finish: "Matte", aspect: "10 / 7" },
  { id: "orpiment", name: "Orpiment", note: "Yellow dropped out of neon, which is the only way yellow survives a dark ground.", hex: "#c9b528", family: "Yellow", finish: "Deep", aspect: "16 / 9" },
  { id: "pool", name: "Pool", note: "Teal, picked for what sits on top of it: glass has nothing to refract over a flat field.", hex: "#14756b", family: "Green", finish: "Glazed", aspect: "8 / 5" },
  { id: "slate", name: "Slate", note: "The map colour. Cool enough to hold a white panel without competing with it.", hex: "#4d6b7c", family: "Blue", finish: "Matte", aspect: "16 / 9" },
  { id: "prussian", name: "Prussian", note: "The only stop that reads as a screen rather than as a surface.", hex: "#3178c6", family: "Blue", finish: "Lit", aspect: "10 / 7" },
  { id: "sand", name: "Sand", note: "A near-white plate pushed down until it has a body. A mesh built from white has nothing to shade with.", hex: "#a3937a", family: "Neutral", finish: "Matte", aspect: "16 / 9" },
  { id: "graphite", name: "Graphite", note: "What is left of black once you take the black out of it.", hex: "#3a4046", family: "Neutral", finish: "Deep", aspect: "16 / 9" },
];

const items: MarginaliaItem[] = keys.map((key, i) => ({
  id: key.id,
  title: key.name,
  summary: key.note,
  facts: [
    { label: "Hex", values: [key.hex.toUpperCase()] },
    { label: "Family", values: [key.family] },
    { label: "Finish", values: [key.finish] },
  ],
  href: `#${key.id}`,
  media: <Mesh accent={key.hex} seed={i} />,
  aspect: key.aspect,
}));

/**
 * No rail, no font, and one set of proportions.
 *
 * This is the layout the component was drawn for and the one the original page
 * runs: a 334px margin, a 72px gutter, and cards that take everything left
 * over and then 120px more, off the edge. A second column of small text beside
 * the margin was tried here and it reads as a stray label rather than as part
 * of the list, so the published demo leaves the rail empty.
 *
 * The demo sets no family and no size either, exactly as the component sets
 * none: it wears whatever the page around it is wearing, and it asks the
 * network for nothing at all, which is the difference between a demo that
 * renders anywhere and one that renders where its font is reachable.
 *
 * Below the breakpoint the margin goes away and every card carries its own
 * name underneath. That is the component's real answer to a narrow container,
 * and squeezing the margin into one instead reads exactly as squeezed.
 *
 * The list scrolls with the page rather than inside a box of its own, which is
 * how the original page runs it and what the panel's sticky column is for. It
 * also decides what the listing's own hover video shows: 21st films a demo by
 * scrolling the page, so a list that scrolls inside a box films as a still with
 * a cursor drifting over it, and this one films as a pass down the list with
 * the panel renaming itself. The other two demos keep their own boxes, so the
 * inner-container path stays covered by the tests.
 *
 * The cards would push the page sideways, so the wrapper clips that axis. It
 * uses `overflow-x: clip` and not `hidden`: `hidden` would make the wrapper a
 * scroll container on both axes and the panel would stick to it instead of to
 * the window.
 */
export default function MeshKeysDemo() {
  return (
    <div
      data-demo-root
      style={{
        width: "100%",
        background: PAPER,
        color: "#14171a",
        fontSize: 15,
      }}
    >
      <div style={{ overflowX: "clip", padding: "clamp(22px, 3.6vw, 56px)" }}>
        <Marginalia
          items={items}
          bleed={120}
          stickyTop={100}
          breakpoint={1100}
          style={{
            "--mg-muted": "rgba(20,23,26,.62)",
            "--mg-faint": "rgba(20,23,26,.4)",
            "--mg-surface": "rgba(12,16,24,.05)",
            "--mg-wash": "rgba(255,255,255,.16)",
          }}
        />
      </div>
    </div>
  );
}

More by Christian Katzmann

View all
  • Glyph Portal

    Glyph Portal

    2.3K
  • Earth Blaze

    Earth Blaze

    135
  • Driftwall

    Driftwall

    4
  • Mash Galore

    Mash Galore

    1
  • green mesh

    green mesh