Add an even gap around a shape with CSS shape-margin

shape-margin grows the shape that text wraps around by a fixed distance, so the words keep the same gap from a circle, a slope or an image outline. It has one catch: the gap cannot reach past the float's margin box.

shape-margin adds an even gap between a shape and the text that wraps around it. Put it on a float that already has shape-outside, and every line is pushed the same distance away from the curve.

Give the float a margin at least as large, or the gap is cut off at the float's edge.

.pic {
  float: left;
  width: 120px;
  height: 120px;
  shape-outside: circle(50%) border-box;
  shape-margin: 20px;
  margin-right: 20px; /* room for the gap */
}

Drag the slider to change the gap. Then untick the box to see what happens without the matching margin.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>shape-margin basics</title>
<style>
  body { margin: 0; padding: 14px 16px 16px 40px; font: 15px/1.55 system-ui, sans-serif; color: #1d2330; background: #fff; }
  .controls { display: flex; flex-wrap: wrap; gap: 8px 18px; align-items: center; margin: 0 0 10px -24px; font-size: 14px; }
  .controls input[type=range] { width: 150px; }
  pre { margin: 0 0 14px -24px; padding: 8px 10px; background: #f1f4f8; border-radius: 8px; font: 13px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; }

  .pic {
    --m: 20px;       /* shape-margin */
    --mr: 20px;      /* margin-right */
    float: left;
    width: 120px; height: 120px;
    border-radius: 50%;
    background: radial-gradient(circle at 35% 35%, #fdba74, #ea580c);
    shape-outside: circle(50%) border-box; /* the circle on the picture itself */
    shape-margin: var(--m);                /* the even gap around it */
    margin-right: var(--mr);               /* room for that gap */
    box-shadow: 0 0 0 var(--m) rgba(234, 88, 12, .16); /* paints the shape-margin band */
    position: relative;
  }
  /* dashed line = the float's margin box, where the shape is cut off */
  .pic::after {
    content: ""; position: absolute; top: 0; bottom: 0; left: 0;
    right: calc(-1 * var(--mr));
    border: 1.5px dashed #64748b;
  }
  p { margin: 0; }
</style>
</head>
<body>
<div class="controls">
  <label>shape-margin <input id="gap" type="range" min="0" max="32" value="20"></label>
  <label><input id="room" type="checkbox" checked> margin-right the same size</label>
</div>
<pre id="code"></pre>

<div class="pic" id="pic"></div>
<p id="text">The orange disc is a float with shape-outside set to a circle. The pale ring around it is the shape-margin: the lines of text stop at its outer edge, so the gap between the words and the curve stays the same all the way round. Untick the box and look at the widest point of the circle. The ring is still painted, but the text ignores the part that sticks out past the dashed line, because a shape can never reach outside the float's margin box. Drag the slider to 0 and the lines close in until they touch the disc, since shape-margin starts at 0.</p>

<script>
  const gap = document.getElementById('gap');
  const room = document.getElementById('room');
  const pic = document.getElementById('pic');
  const code = document.getElementById('code');

  function update() {
    const m = gap.value + 'px';
    const mr = room.checked ? m : '0px';
    pic.style.setProperty('--m', m);
    pic.style.setProperty('--mr', mr);
    code.textContent = 'shape-margin: ' + m + ';\nmargin-right: ' + mr + ';';
  }
  gap.addEventListener('input', update);
  room.addEventListener('change', update);
  update();
</script>
</body>
</html>
A circle float with shape-margin. The pale ring is the gap; the dashed line is the float's margin box.

If you are new to shapes, CSS shape-outside covers the shape functions and image shapes. This page is about the gap around them.

What shape-margin does to the shape

shape-margin does not move the float or change its size. It builds a new, larger shape from the one shape-outside gave, and the text wraps around that larger shape instead.

shape-margin adds a band of the same width all the way round a circle, a polygon and an image shape.
shape-margin adds a band of the same width all the way round a circle, a polygon and an image shape.

The CSS Shapes spec defines the new shape as every point that lies the shape-margin distance outward from the original edge. In practice:

  • A circle stays a circle. Its radius grows by the shape-margin value.
  • A polygon keeps its straight edges, each moved out in parallel. Sharp corners and points become arcs of that radius.
  • An image shape from url() gets the same treatment around the outline of its solid pixels.

The float itself is painted as before. shape-margin only changes where the lines of text start or stop, so the band is invisible unless you draw it, as the first example does with a spread box-shadow.

Give the gap room: the margin box limit

A shape can only ever make the area around a float smaller, never larger. The spec clips every shape to the float's margin box, and shape-margin is no exception.

Without a margin, the gap stops at the float's edge. With a margin of the same size, the whole band is kept.
Without a margin, the gap stops at the float's edge. With a margin of the same size, the whole band is kept.

With circle(50%) border-box on a square float, the circle touches the box on all four sides. Any shape-margin beyond that is outside the margin box, so at the widest point the text still touches the circle.

The spec itself says extra margin may be needed to avoid this.

The fix is a margin on the side the text is on:

.pic {
  float: left;
  shape-outside: circle(50%) border-box;
  shape-margin: 16px;
  margin-right: 16px;   /* at least the shape-margin */
  margin-bottom: 16px;  /* so the line below the float gets the gap too */
}

For float: right, the text is on the left, so use margin-left.

shape-margin vs margin

It is tempting to skip shape-margin and just add margin. That fails because of the reference box. A shape without a box keyword is measured on the margin box, so a bigger margin makes the whole shape bigger rather than adding a gap around it.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>margin vs shape-margin</title>
<style>
  body { margin: 0; padding: 14px 16px; font: 14px/1.5 system-ui, sans-serif; color: #1d2330; background: #fff; }
  label { font-size: 14px; }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 10px; }
  @media (max-width: 560px) { .row { grid-template-columns: 1fr; } }
  .pn { border: 1px solid #e1e4ea; border-radius: 10px; padding: 10px 12px; }
  h2 { margin: 0 0 4px; font-size: 15px; }
  code { font: 12.5px ui-monospace, Consolas, monospace; background: #f1f4f8; border-radius: 4px; padding: 0 3px; }
  .note { margin: 0 0 8px; font-size: 13px; color: #4b5563; }
  p.txt { margin: 0; }

  .tri {
    --gap: 20px;
    float: left;
    width: 100px; height: 100px;
    background: #2563eb;
    clip-path: polygon(0 0, 100% 100%, 0 100%); /* what you see */
  }
  /* Left: margin only. The shape is measured on the bigger margin box. */
  .by-margin .tri {
    margin: 0 var(--gap) var(--gap) 0;
    shape-outside: polygon(0 0, 100% 100%, 0 100%);
  }
  /* Right: shape-margin. The shape stays on the triangle, the gap is even. */
  .by-shape-margin .tri {
    margin: 0 var(--gap) var(--gap) 0;
    shape-outside: polygon(0 0, 100% 100%, 0 100%) border-box;
    shape-margin: var(--gap);
  }
</style>
</head>
<body>
<label>Gap <input id="gap" type="range" min="0" max="36" value="20"> <b id="val">20px</b></label>

<div class="row">
  <div class="pn by-margin">
    <h2>margin only</h2>
    <p class="note"><code>margin</code> + shape on the margin box</p>
    <div class="tri"></div>
    <p class="txt">Here the gap comes from margin alone. The shape is measured on the margin box, which grew by the same amount in both directions, so the slope lands exactly where it was. The words still touch the triangle. Only the flat bottom gets a gap.</p>
  </div>
  <div class="pn by-shape-margin">
    <h2>shape-margin</h2>
    <p class="note"><code>shape-margin</code> + <code>border-box</code></p>
    <div class="tri"></div>
    <p class="txt">Here the shape is measured on the border box, so it matches the blue triangle, and shape-margin pushes every line the same distance from the slope. The margin only makes room so the gap is not cut off.</p>
  </div>
</div>

<script>
  const gap = document.getElementById('gap');
  const val = document.getElementById('val');
  gap.addEventListener('input', () => {
    // one value drives margin and shape-margin in both panels
    document.querySelectorAll('.tri').forEach(t => t.style.setProperty('--gap', gap.value + 'px'));
    val.textContent = gap.value + 'px';
  });
</script>
</body>
</html>
The same triangle and the same gap value. Left: margin only. Right: shape-margin with border-box.

In the left panel, the triangle's polygon is stretched to fill the larger margin box. Its slope runs from corner to corner of that box, which is the same line as before, so the words still touch the triangle.

Only the flat bottom gets a gap, because the box grew downward too.

margin only makes the shape grow with the box. shape-margin adds the same distance at every point of the slope.
margin only makes the shape grow with the box. shape-margin adds the same distance at every point of the slope.
margin only shape-margin + border-box
What grows The box the shape is measured on The shape itself
Gap along the triangle's slope None Even
Circle with margin on one side Centre moves Centre stays put
Still needed – A margin as large as the gap

The two work as a pair. border-box keeps the shape on the visible element, shape-margin sets the gap, and margin makes room for it.

Values, requirements and browser support

Values

shape-margin takes one length or percentage.

Value Gap
0 None. This is the initial value
20px 20 pixels all round
1em One font size, so it grows with the text
5% 5% of the containing block's width
-10px Invalid, the declaration is ignored

Percentages are the one surprise. They are not measured on the float or on the shape.

MDN and the spec both measure them against the containing block, which for a float is most often the nearest block-level ancestor. In a 400px wide article, 10% is a 40px gap.

The property is not inherited, so setting it on a parent does nothing for the floats inside. It can be animated: a transition between two values moves the text smoothly.

What it needs

shape-margin has two requirements, both inherited from shape-outside:

  1. A float. The element needs float: left or float: right. The spec lists floats and initial letter boxes as the only elements the property applies to.
  2. A shape. With shape-outside: none, the text wraps around the plain margin box and shape-margin changes nothing.

Browser support

MDN lists shape-margin as Baseline Widely available, and says it has worked across browsers since January 2020. MDN's compatibility data lists support in Chrome, Edge, Firefox and Safari, with Safari also accepting a -webkit- prefixed name.

We ran every example on this page in the Chromium, Firefox and WebKit builds that ship with Playwright. The gap measured the same in all three.

A finished example: one variable for every gap

A real layout often has more than one shaped float. Keep the gap in one custom property and feed it to both shape-margin and the margin that makes room for it. Then the two can never drift apart.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Article layout with shape-margin</title>
<style>
  :root {
    --gap: 14px; /* one value for every shape-margin and the margin that makes room for it */
    --star: polygon(50% 4%, 61% 38%, 97% 38%, 68% 59%, 79% 94%, 50% 72%, 21% 94%, 32% 59%, 3% 38%, 39% 38%);
  }
  body { margin: 0; padding: 16px 18px; font-family: Georgia, serif; background: #faf7f2; color: #2b2622; }
  .bar { font: 14px system-ui, sans-serif; margin: 0 0 12px; }
  h1 { margin: 0 0 12px; font-size: 24px; }
  p { margin: 0 0 12px; font-size: 15px; line-height: 1.6; }

  /* round picture: clip-path cuts it, shape-outside + shape-margin wrap it */
  .portrait {
    float: left;
    width: 130px; height: 130px;
    margin: 0 var(--gap) var(--gap) 0;
    background: linear-gradient(#0e7490 55%, #164e63 55%), #0e7490;
    clip-path: circle(50%);
    shape-outside: circle(50%) border-box;
    shape-margin: var(--gap);
  }

  /* star: one polygon cuts the picture and shapes the wrap */
  .star {
    float: right;
    width: 110px; height: 110px;
    margin: 0 0 var(--gap) var(--gap);
    background: #f59e0b;
    clip-path: var(--star);
    shape-outside: var(--star) border-box;
    shape-margin: var(--gap);
  }

  @media (max-width: 480px) {
    .portrait { width: 96px; height: 96px; }
    .star { width: 84px; height: 84px; }
  }
</style>
</head>
<body>
<label class="bar">Gap <input id="gap" type="range" min="0" max="30" value="14"> <b id="val">14px</b></label>
<h1>Notes from the night sky</h1>

<div class="portrait" role="img" aria-label="A lake at night"></div>
<p>We rowed out past the reeds once the wind dropped, and the lake went so still that the sky seemed to continue below the boat. The first stars came through in the east while the west was still pale. Nobody wanted to be the first to speak.</p>

<div class="star" role="img" aria-label="A star"></div>
<p>The brightest point over the far shore was not a star at all but a planet, steady where the others flickered. We tried to name the rest from memory and got most of them wrong. It did not matter much. The point was to sit there long enough for the eye to adjust, until the faint ones filled in the gaps between the bright ones.</p>

<p>By midnight the dew had soaked everything we had not put away, and the oars were cold enough to sting. We rowed back slowly, following the light we had left on the porch.</p>

<script>
  const gap = document.getElementById('gap');
  const val = document.getElementById('val');
  gap.addEventListener('input', () => {
    // changing one custom property updates both floats
    document.documentElement.style.setProperty('--gap', gap.value + 'px');
    val.textContent = gap.value + 'px';
  });
</script>
</body>
</html>
A round picture and a star, both cut with clip-path and wrapped with shape-outside. The slider changes one custom property.
:root { --gap: 14px; }

.portrait {
  float: left;
  margin: 0 var(--gap) var(--gap) 0;
  clip-path: circle(50%);
  shape-outside: circle(50%) border-box;
  shape-margin: var(--gap);
}

clip-path cuts the picture to the same circle, so the text follows what people see. It measures from the border box by default, which is one more reason to write border-box after the shape. CSS clip-path and CSS variables cover both tools in more depth.

When it does not work

What you see Cause Fix
No change at all The element is not floated Add float: left or right
No change, and the text wraps a rectangle shape-outside is missing or none Give the float a shape
The gap vanishes at the widest point The shape is clipped to the margin box Add a margin on the text side, at least as large
The gap is fine beside the float, missing below it No bottom margin Add margin-bottom of the same size
A percentage gap is far too big It is measured on the parent's width Use px or em
A negative value is ignored Only 0 or more is allowed Shrink the shape instead
The gap is uneven along a slope Margin used instead of shape-margin Use shape-margin with border-box

Text wrapping changes with the width of the screen, so a screenshot only shows one case. A live page lets people drag the window, or open it on a phone, and watch the gap hold.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the sliders in these examples work for whoever opens the link. If you change the code later, the same link shows the new version.

Questions people ask

Why does shape-margin do nothing?

It only changes a shape made by shape-outside, and shape-outside only works on floats. On an element that is not floated, or with shape-outside: none, the text wraps around the plain margin box and shape-margin has no effect.

Why is the gap missing at the widest part of my shape?

A shape is clipped to the float's margin box, and shape-margin cannot extend it past that box. If the shape already touches the edge of the float, the extra band is cut off there. Give the float a margin on the text side at least as large as the shape-margin.

What is a percentage shape-margin measured against?

The width of the containing block, the same as a percentage margin or padding. The spec words it as the inline size of the containing block, which is the width in a normal horizontal page. In a 400px wide container, 10% is a 40px gap.

Can shape-margin be negative?

No. The spec only allows values of 0 or more, so a negative value is invalid and the declaration is ignored. To bring text closer, make the shape itself smaller, for example circle(45%) instead of circle(50%).

Can I animate shape-margin?

Yes. It is a length or percentage, so a transition or a keyframe animation moves smoothly between two values.

Keep reading