zudo-css-wisdom
GitHub repository

Type to search...

to open search from anywhere

Float and Flow Spacing

Why flow/owl prose spacing silently misaligns floated figures, the backwards "zero the follower" fix, and how to equalise the leads instead.

The Problem

The flow utility from Prose Heading Spacing puts every gap on the following sibling's margin-block-start:

.prose > * + * { margin-block-start: var(--flow-space, 1rem); }
.prose :where(h3) { --flow-space: 2rem; }

This is robust for blocks. It is wrong for floats, and it fails without any diagnostic.

A float is positioned by its own margin-block-start. Its border-box top sits at previous sibling's bottom margin edge + own margin-block-start. The block beside it is positioned by its margin-block-start. The two top-align only when those two leads are equal. Nothing in the flow strategy makes that true — headings deliberately carry a larger lead than body text — so a floated figure beside a heading is misaligned by default. The error is invisible until the heading paints a rule or border.

Flow Spacing Misaligns a Float Beside a Heading

The left column aligns only by coincidence: both leads are the default 1rem. Change the neighbour to a heading, and the heading drops below the image by exactly the difference between the two leads.

The Seductive Wrong Fix

The first instinct is "the float already opened the gap, so zero the neighbour":

/* Backwards — do not ship this */
.prose .figure + * { margin-block-start: 0; }

This drops the neighbour to the previous block's bottom edge while the float stays one lead lower. The text now starts above the image. A production site shipped exactly this rule, with a code comment claiming it aligned things.

Anti-Pattern — Zeroing the Neighbour's Lead Puts Text Above the Image

The Solution

Equalise the two leads instead of zeroing one. Move the float to meet the neighbour — never pull the neighbour up, because its lead is the section rhythm the whole strategy exists to protect.

ShapeLeadsAction
p → float → pBoth defaultNone. Delete any zeroing rule.
p → float → h3Heading lead is largerFloat adopts the heading's lead: .figure:has(+ h3) { --flow-space: 2rem }
Heading paints its rule below its own padding-topHeading lead + insetFloat adopts lead + inset, sharing the inset through one custom property
Previous sibling owns a margin-block-endCollapses with the block, not with the floatZero it: > *:has(+ .figure) { margin-block-end: 0 }
Float only above a breakpointBelow it the element is a blockScope every correction to that media query

Code Examples

Float Adopts the Heading's Lead

The flow utility reads --flow-space from the element it positions. Set it on the float when a heading follows, and the float moves down to meet the heading.

.prose > * + * { margin-block-start: var(--flow-space, 1rem); }
.prose :where(h3) { --flow-space: 2rem; }

/* The float beside an h3 takes the h3's lead */
.prose .figure:has(+ :where(h3)) { --flow-space: 2rem; }

Define leads in rem, not em

em resolves against the element that uses it. A float at 14px and an h3 at 16.8px get different pixel values from the same 2em, so the leads are still unequal after the "fix". Put the lead values in rem or fixed-unit tokens, as the examples on this page do.

Fix — The Float Takes the Heading's Lead

Heading With an Internal Inset

A heading that paints its rule after its own padding-top aligns to a line that is not its box top. The float must adopt lead + inset. Hoist the inset into a shared custom property so the heading and the float rule cannot drift apart.

.prose {
  --heading-lead: 2rem;
  --heading-inset: 0.75rem; /* padding above the heading's rule */
}
.prose :where(h3) {
  --flow-space: var(--heading-lead);
  padding-top: var(--heading-inset);
}
.prose :where(h3)::before {
  content: "";
  display: block;
  border-top: 2px solid currentColor;
}

/* Float top meets the rule, not the heading's box top */
.prose .figure:has(+ :where(h3)) {
  --flow-space: calc(var(--heading-lead) + var(--heading-inset));
}
Fix — Float Adopts Lead Plus the Heading's Inset

Floats Do Not Margin-Collapse

A trailing margin-block-end owned by the previous sibling collapses with the next block's lead — but not with the float between them. The float is pushed down by the full sum; the text beside it is not. Zero the trailing margin on whatever precedes a float.

/* An hr that owns its own bottom margin */
.prose hr { margin-block: 1.5rem; }

/* Trailing margins must not reach a float */
.prose > *:has(+ .figure) { margin-block-end: 0; }

The deeper invariant: inside a flow-spaced container, no child should own a trailing margin at all. Use padding-block-end for internal visual weight instead.

Fix — Zero the Trailing Margin Before a Float

Scope Corrections to the Float's Media Query

A figure that floats only above a breakpoint is a full-width block below it. There, its own padding-block-end makes the gap, and zeroing the follower's lead is correct. Every float correction belongs inside the same media query that turns the float on.

.figure { padding-block-end: 1rem; }

/* Block mode: the figure's padding is the gap */
.figure + * { margin-block-start: 0; }

@media (min-width: 480px) {
  .figure { float: left; width: 40%; margin-inline-end: 1.5rem; }

  /* Float mode: restore the lead and equalise */
  .figure + * { margin-block-start: var(--flow-space, 1rem); }
  .figure:has(+ :where(h3)) { --flow-space: 2rem; }
  .prose > *:has(+ .figure) { margin-block-end: 0; }
}
Float Corrections Scoped to the Breakpoint — Resize to Compare

Diagnostic

Eyeballing this does not work — a 12px offset beside a wrapped paragraph looks like optical alignment. Measure it:

const float = document.querySelector('.figure');
const neighbour = float.nextElementSibling;
neighbour.getBoundingClientRect().top - float.getBoundingClientRect().top; // want 0

Run it across every distinct structural shape the content produces: p → float → h3, h2 → float → p, hr → float → dl, and so on. When the heading paints its rule inside its box, measure the rule element, not the heading. On the production site that motivated this page, 14 shapes across 53 floats were misaligned before the fix, in both directions (+36px to −34px), with zero aligned. After the fix all 14 measured Δ = 0.

Quick Reference

ScenarioTechnique
Float beside a default-lead blockNo rule. Delete any .float + * { margin-block-start: 0 }
Float beside a heading with a larger lead.float:has(+ h3) { --flow-space: <heading lead> }
Heading paints its rule below padding-top--flow-space: calc(var(--heading-lead) + var(--heading-inset)) on the float
Previous sibling owns margin-block-end> *:has(+ .float) { margin-block-end: 0 }
Float only above a breakpointPut every correction inside that @media; keep the zeroed lead below it
Float is the container's first child:first-child { margin-block-start: 0 } defeats the float's lead — avoid the shape or special-case it
Verifyingneighbour.getBoundingClientRect().top - float.getBoundingClientRect().top === 0 per shape

Common AI Mistakes

  • Zeroing the neighbour's lead after a float. This reads as "the float opened the gap" but moves the text above the image. Leads must be equal, not zero.

  • Pulling the heading up to meet the float. The heading's larger lead is the section rhythm. Move the float down instead.

  • Treating the heading's box top as the alignment target. When the rule sits below padding-top, align to the rule: add the inset to the float's lead.

  • Hard-coding the inset in two places. The heading's padding-top and the float's calc() drift apart on the next redesign. Share one custom property.

  • Writing the shared lead in em. The float and the heading have different font sizes, so 2em is a different number of pixels on each. Use rem or fixed tokens for --flow-space values.

  • Assuming margin collapse applies to floats. A previous sibling's margin-block-end offsets the float but not the block beside it. Zero trailing margins before floats — or never let a flow child own one.

  • Applying float corrections at every viewport. Below the float breakpoint the element is a block and the corrections are wrong. Scope them to the media query.

  • Judging alignment by eye. Measure getBoundingClientRect().top differences across every shape the content produces.

When to Use

Float Adopts the Neighbour's Lead

Use whenever a flow-spaced container allows floated children — markdown articles with floated figures, pull quotes, or sidebars. This is the only correction that preserves the heading rhythm.

Zero Trailing Margins Before Floats

Use whenever a flow container contains elements that still own margin-block-end (third-party components, hr, grids). Better: remove the trailing margins entirely and make the invariant hold for every child.

Zeroing the Follower's Lead

Use only in the non-floating (block) mode of a responsive figure, where the figure's own padding-block-end is the gap. Never in float mode.

References

Revision History

CreatedUpdated