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.
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.
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.
| Shape | Leads | Action |
|---|---|---|
p → float → p | Both default | None. Delete any zeroing rule. |
p → float → h3 | Heading lead is larger | Float adopts the heading's lead: .figure:has(+ h3) { --flow-space: 2rem } |
Heading paints its rule below its own padding-top | Heading lead + inset | Float adopts lead + inset, sharing the inset through one custom property |
Previous sibling owns a margin-block-end | Collapses with the block, not with the float | Zero it: > *:has(+ .figure) { margin-block-end: 0 } |
| Float only above a breakpoint | Below it the element is a block | Scope 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.
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));
}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.
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; }
}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 0Run 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
| Scenario | Technique |
|---|---|
| Float beside a default-lead block | No 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 breakpoint | Put 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 |
| Verifying | neighbour.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-topand the float'scalc()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, so2emis a different number of pixels on each. Useremor fixed tokens for--flow-spacevalues.Assuming margin collapse applies to floats. A previous sibling's
margin-block-endoffsets 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().topdifferences 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
Prose Heading Spacing — the flow utility this page corrects for floats
Axiomatic CSS and Lobotomized Owls — Heydon Pickering, A List Apart