BaselineGrid

Vertical rhythm where every line in adjacent columns sits on the same rule, including the lines under a photograph. Baseline, and the page in the frame below is built with it.

Scroll inside the frame. Press Show grid, bottom right, to see the rules. Open it on its own for the full width.

The properties are the interface

Override these in your own stylesheet rather than editing the file, so the next version is a clean replacement.

--base-line 1.5rem
The unit. Every vertical measurement on the page is a whole number of these, so changing it rescales the whole rhythm at once without anything drifting out of step.
--base-size 1rem
Body type size. Its line box is exactly one baseline, which is the fact the rest of the system is built on.
--base-rule rgba(54, 54, 48, 0.16)
The overlay colour. Fainter than a column overlay needs to be, because unlike one this covers every line of text on the page rather than the spaces between columns.
--base-measure 66ch
The widest a line of body copy gets. A baseline grid does nothing for readability if the lines are too long for the eye to track back to the next one.

What it gets right

The stylesheet

This is the whole item. Copy it into your project, link it, and use the class names. Nothing to install and nothing to mount.

Paste into Claude Code, Cursor or any agent. Pinned to a tag, so it will not change under you.

/*
 * BaselineGrid: Beamish
 * https://beamish.ink/grids/baseline-grid
 *
 * Vertical rhythm. Every line of type in two adjacent columns sits on the same
 * invisible rule, the way it does on a printed spread, and the overlay draws
 * those rules so you can see whether it is true.
 *
 * Three things carry it, and the second is where almost every attempt at this
 * falls over.
 *
 * Everything is a multiple of one number. Line heights, margins, the space
 * above a heading and below it: all of them `calc(var(--base-line) * n)`. The
 * moment one value is chosen by eye the rhythm is gone, and it is gone for
 * everything after it on the page rather than just for that element.
 *
 * Pictures are the hard part, and they are why most baseline grids are a lie.
 * An image is whatever height its own proportions make it, which is almost
 * never a whole number of baselines, and one of them puts every line below it
 * off the rule for the rest of the page. There are only two honest answers:
 * give the image a height that is a multiple and crop to it, or let it take its
 * own height and put the rhythm back underneath it. This does the first by
 * default, with `.g-plate--free` for the second, and the second is corrected
 * rather than ignored.
 *
 * And the first line is the one that has to land. A block's rhythm is set by
 * where its first baseline sits, not by its box: a heading with a tall
 * line-height has space above the letters that pushes the text off the rule
 * while the box looks right. The trim is done with padding derived from the
 * line-height, which is the only part of this that has to be arithmetic rather
 * than a round number.
 */

:root {
  /*
   * The unit. Everything on the page is a whole number of these, so changing
   * it rescales the whole rhythm without anything drifting.
   */
  --base-line: 1.5rem;
  /* Body type. Its line-height must be exactly one baseline. */
  --base-size: 1rem;
  --base-rule: rgba(54, 54, 48, 0.16);
  --base-measure: 66ch;
}

/*
 * Every text element gets a line box that is a whole number of baselines. This
 * is the whole system; the rest is applying it.
 */
.g-rhythm {
  font-size: var(--base-size);
  line-height: var(--base-line);
}

.g-rhythm :where(p, ul, ol, blockquote, pre, figure, table) {
  margin-block: 0 var(--base-line);
}

.g-rhythm :where(p, li) {
  max-width: var(--base-measure);
}

/*
 * Headings take whole baselines too, and the space above one is a baseline
 * more than the space below it, because a heading belongs to what follows.
 */
.g-rhythm :where(h1, h2, h3, h4) {
  margin-block: calc(var(--base-line) * 2) var(--base-line);
  font-weight: 700;
}

.g-rhythm h1 {
  font-size: calc(var(--base-line) * 2);
  line-height: calc(var(--base-line) * 2);
}

.g-rhythm h2 {
  font-size: calc(var(--base-line) * 1.15);
  line-height: calc(var(--base-line) * 1.5);
}

.g-rhythm :where(h3, h4) {
  font-size: var(--base-size);
  line-height: var(--base-line);
}

/*
 * A picture cropped to a whole number of baselines. The number is a property
 * rather than a fixed value, so a plate can be four baselines in a sidebar and
 * twelve across a page and both still land on the rule.
 */
.g-plate {
  --plate-lines: 8;
  display: block;
  width: 100%;
  height: calc(var(--base-line) * var(--plate-lines));
  object-fit: cover;
  margin-block: 0 var(--base-line);
}

/*
 * The escape hatch: a picture at its own proportions, with the rhythm put back
 * underneath it.
 *
 * `margin-bottom` cannot fix this, because the error is not known until the
 * image has been laid out at a width nobody chose. The round-up is done in the
 * browser, against the element's real height, which is the only place the
 * number exists.
 */
.g-plate--free {
  height: auto;
  margin-bottom: 0;
}

.g-plate--free + .g-catch {
  display: block;
  /* Set by the script below. Zero until then, so nothing jumps on load. */
  height: var(--catch, 0px);
}

/* Columns that share the rhythm, so two of them line up line for line. */
.g-columns {
  display: grid;
  grid-template-columns: repeat(var(--base-columns, 2), minmax(0, 1fr));
  column-gap: calc(var(--base-line) * 2);
}

/*
 * The overlay. Drawn from `--base-line` itself, so it cannot describe a rhythm
 * other than the one in force.
 *
 * A repeating gradient is right here, unlike the column overlay, because a
 * baseline really is an even repeat with no gutter to get wrong.
 */
.g-baseline-rules {
  position: fixed;
  inset: 0;
  z-index: 60;
  pointer-events: none;
  background-image: repeating-linear-gradient(
    to bottom,
    var(--base-rule) 0,
    var(--base-rule) 1px,
    transparent 1px,
    transparent var(--base-line)
  );
}

/*
 * Said again here, because the attribute's display:none lives in the browser's
 * stylesheet and any author display beats it. Without this the overlay ships
 * switched on with its own button insisting it is off.
 */
.g-baseline-rules[hidden] {
  display: none;
}

@media (max-width: 48rem) {
  .g-columns {
    grid-template-columns: minmax(0, 1fr);
  }
}