Build a flowchart in HTML, CSS and SVG

Let CSS place the boxes and let a short script draw the arrows between them. The chart then reflows like any page and the lines follow.

A flowchart in HTML needs no library. Put the steps in a container and lay them out with CSS grid or flex. Lay an SVG over the same container, measure where each box landed, and draw one <path> per arrow. Redraw when anything changes size.

Try it first, then make the example narrower or wider. The arrows stay attached.

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>Flowchart in HTML</title>
<style>
  body { margin: 0; padding: 20px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  .chart {
    position: relative;             /* the SVG is placed inside this box */
    display: flex; flex-direction: column; align-items: center;
    gap: 36px;                      /* the gap is where the arrows go */
  }
  .chart svg {
    position: absolute; inset: 0; width: 100%; height: 100%;
    overflow: visible; pointer-events: none;
  }
  .node {
    position: relative;             /* paint the boxes above the SVG */
    padding: 12px 18px; border-radius: 8px; max-width: 70%; text-align: center;
    background: #fff; border: 2px solid #2563eb;
  }
  .start, .end { border-radius: 999px; background: #dbeafe; }
</style>
</head>
<body>
<div class="chart" id="chart">
  <svg id="lines">
    <defs>
      <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5"
              markerWidth="7" markerHeight="7" orient="auto">
        <path d="M0 0 L10 5 L0 10 z" fill="#2563eb"/>
      </marker>
    </defs>
  </svg>
  <div class="node start">Start</div>
  <div class="node">Enter your email</div>
  <div class="node">Send a sign-in code</div>
  <div class="node end">Done</div>
</div>

<script>
  const chart = document.getElementById('chart');
  const svg = document.getElementById('lines');
  const nodes = [...chart.querySelectorAll('.node')];

  function draw() {
    svg.querySelectorAll('.edge').forEach((p) => p.remove());
    const c = chart.getBoundingClientRect();
    for (let i = 0; i < nodes.length - 1; i++) {
      const a = nodes[i].getBoundingClientRect();
      const b = nodes[i + 1].getBoundingClientRect();
      // bottom-centre of one box to top-centre of the next, relative to the chart
      const x1 = a.left + a.width / 2 - c.left, y1 = a.bottom - c.top;
      const x2 = b.left + b.width / 2 - c.left, y2 = b.top - c.top;
      const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute('class', 'edge');
      path.setAttribute('d', `M${x1} ${y1} L${x2} ${y2}`);
      path.setAttribute('stroke', '#2563eb');
      path.setAttribute('stroke-width', '2');
      path.setAttribute('marker-end', 'url(#arrow)');
      svg.appendChild(path);
    }
  }

  // runs once when observing starts, then whenever the chart changes size
  new ResizeObserver(draw).observe(chart);
</script>
</body>
</html>
Four boxes in a flex column and one SVG layer. The script measures the boxes and draws the arrows.

The layout is pure CSS. The script only reads positions the browser already worked out, so the chart reflows like the rest of the page.

Lay out the boxes with grid or flex

Treat each step as an ordinary <div>. A straight sequence is a flex column with a gap. Anything with branches is easier on a grid, because a "No" branch needs a second column.

.chart {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  row-gap: 34px;              /* room for the arrows */
  justify-items: center;
}

Named areas make the plan readable in the CSS itself. The finished example below uses grid-template-areas, with a dot for each empty cell:

grid-template-areas:
  "start   .      "
  "check   .      "
  "stock   reorder"
  "pack    .      ";

If your diagram is a strict tree, such as a reporting line, nested lists and CSS borders may be enough. The org chart guide shows that approach. A flowchart has branches that rejoin and loops that point back up. Those need real lines.

Draw arrows from measured positions

The SVG layer sits inside the chart, stretched to its size with inset: 0. The chart needs position: relative so the SVG is placed against it. Add pointer-events: none so the layer never blocks clicks on the boxes.

getBoundingClientRect measures from the window. Subtract the chart's corner to get SVG coordinates.
getBoundingClientRect measures from the window. Subtract the chart's corner to get SVG coordinates.

getBoundingClientRect() returns positions measured from the window, while the SVG draws from the chart's top-left corner. Subtract the chart's rectangle and you have the numbers the SVG needs. This helper returns the middle of any side:

function anchor(id, side) {
  const r = document.getElementById(id).getBoundingClientRect();
  const c = chart.getBoundingClientRect();
  const x = r.left - c.left, y = r.top - c.top;
  return {
    top:    [x + r.width / 2, y],
    bottom: [x + r.width / 2, y + r.height],
    left:   [x, y + r.height / 2],
    right:  [x + r.width, y + r.height / 2],
  }[side];
}

Scrolling moves both rectangles by the same amount, so the difference stays correct on a long page.

Each connection is one <path>. Its d attribute is a small drawing language: M moves to a point, L draws a straight line, H and V draw horizontal and vertical lines. The arrowhead is a <marker> defined once and reused:

<marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5"
        markerWidth="7" markerHeight="7" orient="auto">
  <path d="M0 0 L10 5 L0 10 z" fill="#2563eb"/>
</marker>

refX="10" puts the tip of the triangle exactly on the end point, and orient="auto" turns it to follow the line. Every edge then takes marker-end="url(#arrow)".

Create the paths with createElementNS and the SVG namespace. A path made with plain createElement is an unknown HTML element and draws nothing. If you are new to inline SVG, SVG in HTML covers the basics.

Decision diamonds and elbow connectors

The decision step is a diamond. Rotating a square with transform looks like the obvious route, but it tilts the text too, and a transform does not change layout. The rotated corners spill into the gaps where the arrows run.

rotate(45deg) turns the text and spills past the layout box. clip-path keeps both upright and in place.
rotate(45deg) turns the text and spills past the layout box. clip-path keeps both upright and in place.

clip-path cuts the painted shape and leaves the layout box alone. The text stays upright, and the four tips land on the middle of each side, which is where anchor() already points.

.diamond {
  padding: 2px; background: #d97706;  /* the outline colour */
  clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}
.diamond span {
  display: block; padding: 26px 34px; background: #fef3c7;
  clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}

A border is clipped away along with everything else. The outline comes from two layers: an outer diamond in the outline colour, and an inner one in the fill colour.

Give the inner layer generous padding so the corners do not cut through the words.

A diagonal line from the diamond to a box in the next column works, but flowcharts read more easily with right angles. Tick and untick the box to compare:

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>Flowchart decision diamond</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  label { display: block; margin-bottom: 14px; font-size: 15px; }
  .chart {
    position: relative;
    display: grid; grid-template-columns: 1fr 1fr;
    row-gap: 44px; column-gap: 16px; justify-items: center; align-items: center;
  }
  .chart svg {
    position: absolute; inset: 0; width: 100%; height: 100%;
    overflow: visible; pointer-events: none;
  }
  .node {
    position: relative; padding: 12px 16px; border-radius: 8px; text-align: center;
    background: #fff; border: 2px solid #2563eb;
  }
  .start { border-radius: 999px; background: #dbeafe; }
  /* diamond: the outer layer is the outline, the inner layer the fill */
  .diamond {
    position: relative; padding: 2px; background: #d97706;
    clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
  }
  .diamond span {
    display: block; padding: 26px 34px; background: #fef3c7; text-align: center;
    clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
  }
  .edge { fill: none; stroke: #2563eb; stroke-width: 2; }
  .tag { font-size: 13px; font-weight: 700; fill: #374151; }
</style>
</head>
<body>
<label><input type="checkbox" id="elbow" checked> Elbow lines (untick for straight lines)</label>
<div class="chart" id="chart">
  <svg id="lines">
    <defs>
      <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5"
              markerWidth="7" markerHeight="7" orient="auto">
        <path d="M0 0 L10 5 L0 10 z" fill="#2563eb"/>
      </marker>
    </defs>
  </svg>
  <div class="node start" id="start">Start</div>
  <div></div>
  <div class="diamond" id="check"><span>Code<br>correct?</span></div>
  <div></div>
  <div class="node" id="ok">Sign in</div>
  <div class="node" id="err">Show an error</div>
</div>

<script>
  const chart = document.getElementById('chart');
  const svg = document.getElementById('lines');
  const elbow = document.getElementById('elbow');

  // [from id, from side, to id, to side, label]
  const edges = [
    ['start', 'bottom', 'check', 'top', ''],
    ['check', 'bottom', 'ok', 'top', 'Yes'],
    ['check', 'right', 'err', 'top', 'No'],
  ];

  // the point on one side of an element, in the chart's coordinates
  function anchor(id, side) {
    const r = document.getElementById(id).getBoundingClientRect();
    const c = chart.getBoundingClientRect();
    const x = r.left - c.left, y = r.top - c.top;
    return {
      top: [x + r.width / 2, y],
      bottom: [x + r.width / 2, y + r.height],
      left: [x, y + r.height / 2],
      right: [x + r.width, y + r.height / 2],
    }[side];
  }

  function svgEl(tag, attrs) {
    const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
    for (const k in attrs) el.setAttribute(k, attrs[k]);
    return svg.appendChild(el);
  }

  // right-angle path: leave along the start side, arrive along the end side
  function route(x1, y1, outH, x2, y2, inH) {
    const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
    if (outH && inH) return `M${x1} ${y1} H${mx} V${y2} H${x2}`;
    if (!outH && !inH) return `M${x1} ${y1} V${my} H${x2} V${y2}`;
    if (outH) return `M${x1} ${y1} H${x2} V${y2}`;   // sideways, then down
    return `M${x1} ${y1} V${y2} H${x2}`;              // down, then sideways
  }

  function draw() {
    svg.querySelectorAll('.edge, .tag').forEach((el) => el.remove());
    for (const [from, fs, to, ts, label] of edges) {
      const [x1, y1] = anchor(from, fs);
      const [x2, y2] = anchor(to, ts);
      const across = fs === 'left' || fs === 'right';
      const d = elbow.checked ? route(x1, y1, across, x2, y2, ts === 'left' || ts === 'right')
                              : `M${x1} ${y1} L${x2} ${y2}`;
      svgEl('path', { class: 'edge', d, 'marker-end': 'url(#arrow)' });
      if (label) {
        const t = svgEl('text', across ? { x: x1 + 8, y: y1 - 8 } : { x: x1 + 8, y: y1 + 18 });
        t.setAttribute('class', 'tag');
        t.textContent = label;
      }
    }
  }

  elbow.addEventListener('change', draw);
  new ResizeObserver(draw).observe(chart);
</script>
</body>
</html>
A grid with two columns. Yes goes down, No goes sideways and then down.

Which right-angled path to draw depends on the two sides the line leaves and enters:

Leaves from Enters at Path after M x1 y1
bottom or top top or bottom V midY H x2 V y2
left or right left or right H midX V y2 H x2
left or right top or bottom H x2 V y2
bottom or top left or right V y2 H x2

The rule is to leave along the start side and arrive along the end side, so the arrowhead always points into the box. In code, that table is one function:

function route(x1, y1, outH, x2, y2, inH) {
  const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
  if (outH && inH) return `M${x1} ${y1} H${mx} V${y2} H${x2}`;
  if (!outH && !inH) return `M${x1} ${y1} V${my} H${x2} V${y2}`;
  if (outH) return `M${x1} ${y1} H${x2} V${y2}`;
  return `M${x1} ${y1} V${y2} H${x2}`;
}

Keep the connections as data, one entry per arrow:

const edges = [
  ['stock', 'bottom', 'pack', 'top', 'Yes'],
  ['stock', 'right', 'reorder', 'left', 'No'],
];

Adding an arrow is then one more line in a list, not new drawing code.

Redraw when anything resizes

The numbers in a d attribute are fixed when you write them. When the layout changes, the boxes move and the paths do not.

Lines drawn once keep their old positions. A ResizeObserver redraws them after every size change.
Lines drawn once keep their old positions. A ResizeObserver redraws them after every size change.

A ResizeObserver calls your function when an element changes size. It also calls it once when you start observing, so it doubles as the first draw:

const ro = new ResizeObserver(draw);
ro.observe(chart);
chart.querySelectorAll('.node, .diamond').forEach((n) => ro.observe(n));

Watch the boxes as well as the chart. A label that wraps onto a second line makes one box taller and pushes the boxes below it down.

That can happen without the window changing size, so a listener on the window resize event would miss it.

The draw function should first remove the old paths, then add new ones. Keep the <defs> with the marker, and remove only elements with your edge class.

A finished flowchart

This one puts it all together: a grid with named areas, a decision diamond, labelled Yes and No branches, and a dashed arrow that loops back up. Press the button to make one box taller and watch the arrows below it follow.

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>Order flowchart</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; }
  button { margin-bottom: 16px; padding: 8px 14px; font: inherit; border-radius: 8px;
           border: 1px solid #9ca3af; background: #fff; cursor: pointer; }
  .chart {
    position: relative; max-width: 560px;
    display: grid; grid-template-columns: 1fr 1fr;
    grid-template-areas:
      "start   .      "
      "check   .      "
      "stock   reorder"
      "pack    .      "
      "ship    .      "
      "end     .      ";
    row-gap: 34px; column-gap: 16px; justify-items: center; align-items: center;
  }
  .chart svg { position: absolute; inset: 0; width: 100%; height: 100%;
               overflow: visible; pointer-events: none; }
  .node { position: relative; padding: 10px 14px; border-radius: 8px; text-align: center;
          background: #fff; border: 2px solid #2563eb; max-width: 100%; box-sizing: border-box; }
  .pill { border-radius: 999px; background: #dbeafe; }
  .diamond { position: relative; padding: 2px; background: #d97706;
             clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
  .diamond span { display: block; padding: 24px 32px; background: #fef3c7; text-align: center;
                  clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); }
  .edge { fill: none; stroke: #2563eb; stroke-width: 2; }
  .edge.back { stroke: #d97706; stroke-dasharray: 6 4; }
  .tag { font-size: 13px; font-weight: 700; fill: #374151; }
</style>
</head>
<body>
<button id="longer" type="button">Make one box taller</button>
<div class="chart" id="chart">
  <svg id="lines">
    <defs>
      <marker id="arrow" viewBox="0 0 10 10" refX="10" refY="5"
              markerWidth="7" markerHeight="7" orient="auto">
        <path d="M0 0 L10 5 L0 10 z" fill="#2563eb"/>
      </marker>
      <marker id="arrow-back" viewBox="0 0 10 10" refX="10" refY="5"
              markerWidth="7" markerHeight="7" orient="auto">
        <path d="M0 0 L10 5 L0 10 z" fill="#d97706"/>
      </marker>
    </defs>
  </svg>
  <div class="node pill" id="start" style="grid-area:start">Order received</div>
  <div class="node" id="check" style="grid-area:check">Check the stock</div>
  <div class="diamond" id="stock" style="grid-area:stock"><span>In<br>stock?</span></div>
  <div class="node" id="reorder" style="grid-area:reorder">Order from supplier</div>
  <div class="node" id="pack" style="grid-area:pack">Pack the order</div>
  <div class="node" id="ship" style="grid-area:ship">Ship it</div>
  <div class="node pill" id="end" style="grid-area:end">Done</div>
</div>

<script>
  const chart = document.getElementById('chart');
  const svg = document.getElementById('lines');

  // [from id, from side, to id, to side, label, extra class]
  const edges = [
    ['start', 'bottom', 'check', 'top'],
    ['check', 'bottom', 'stock', 'top'],
    ['stock', 'bottom', 'pack', 'top', 'Yes'],
    ['stock', 'right', 'reorder', 'left', 'No'],
    ['reorder', 'top', 'check', 'right', '', 'back'],   // loop back up
    ['pack', 'bottom', 'ship', 'top'],
    ['ship', 'bottom', 'end', 'top'],
  ];

  function anchor(id, side) {
    const r = document.getElementById(id).getBoundingClientRect();
    const c = chart.getBoundingClientRect();
    const x = r.left - c.left, y = r.top - c.top;
    return {
      top: [x + r.width / 2, y],
      bottom: [x + r.width / 2, y + r.height],
      left: [x, y + r.height / 2],
      right: [x + r.width, y + r.height / 2],
    }[side];
  }

  // right-angle path: leave along the start side, arrive along the end side
  function route(x1, y1, outH, x2, y2, inH) {
    const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
    if (outH && inH) return `M${x1} ${y1} H${mx} V${y2} H${x2}`;
    if (!outH && !inH) return `M${x1} ${y1} V${my} H${x2} V${y2}`;
    if (outH) return `M${x1} ${y1} H${x2} V${y2}`;
    return `M${x1} ${y1} V${y2} H${x2}`;
  }

  function svgEl(tag, attrs) {
    const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
    for (const k in attrs) el.setAttribute(k, attrs[k]);
    return svg.appendChild(el);
  }

  function draw() {
    svg.querySelectorAll('.edge, .tag').forEach((el) => el.remove());
    for (const [from, fs, to, ts, label = '', extra = ''] of edges) {
      const [x1, y1] = anchor(from, fs);
      const [x2, y2] = anchor(to, ts);
      const outH = fs === 'left' || fs === 'right';
      const d = route(x1, y1, outH, x2, y2, ts === 'left' || ts === 'right');
      const marker = extra === 'back' ? 'url(#arrow-back)' : 'url(#arrow)';
      svgEl('path', { class: ('edge ' + extra).trim(), d, 'marker-end': marker });
      if (label) {
        const t = svgEl('text', outH ? { x: x1 + 6, y: y1 - 8 } : { x: x1 + 8, y: y1 + 18 });
        t.setAttribute('class', 'tag');
        t.textContent = label;
      }
    }
  }

  // watch the chart and every box: a box can grow without the chart changing width
  const ro = new ResizeObserver(draw);
  ro.observe(chart);
  chart.querySelectorAll('.node, .diamond').forEach((n) => ro.observe(n));

  document.getElementById('longer').addEventListener('click', () => {
    const pack = document.getElementById('pack');
    pack.textContent = pack.textContent === 'Pack the order'
      ? 'Pack the order, print the label and weigh the box'
      : 'Pack the order';
  });
</script>
</body>
</html>
Seven arrows from a list of edges. The chart and every box are observed, so any size change redraws.
  • Loop back: the arrow from "Order from supplier" leaves from its top and enters "Check the stock" from the right. That is the last row of the table, with the line going up instead of down.
  • Labels: each Yes or No is an SVG <text> placed a few pixels from the start of its edge.
  • A second colour: each marker has its own fill, so the dashed loop uses a second marker in orange.

When it does not work

What you see Cause Fix
Arrows are shifted away from the boxes Positions taken from the window, not the chart Subtract the chart's left and top
The SVG covers the page, not the chart The chart is not positioned position: relative on the chart
Nothing is drawn Paths made with createElement Use createElementNS with the SVG namespace
Boxes cannot be clicked The SVG layer takes the clicks pointer-events: none on the SVG
Arrowhead sits past or short of the box refX does not match the tip Set refX to the x of the triangle's tip
Lines stay put after resizing Drawn once on load Redraw from a ResizeObserver
Lines lag behind when one box grows Only the window is watched Observe every box too
The diamond's corners cover the arrows rotate(45deg) does not change layout Use clip-path: polygon()
Arrows are off inside a scaled chart transform: scale() on the chart scales the measured sizes Divide by the scale, or do not scale the chart

A flowchart is often made to be read by someone else: a team, a client, a new hire. A screenshot cannot be corrected once sent, and an .html attachment may not open at all on a phone.

To send the working version, 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 arrows are drawn and redrawn on the reader's own screen. If you change a step later, the same link shows the new version.

Questions people ask

Can I make a flowchart with only HTML and CSS?

The boxes, yes: grid or flex lays them out and clip-path makes the decision diamond. Arrows that bend around to another column or loop back up need to know where the boxes ended up, which takes a few lines of JavaScript that measure them and write SVG paths.

Why not position every box with absolute coordinates?

Then you have to recalculate every coordinate yourself when a label gets longer or the screen gets narrower. With grid or flex, the browser does the layout and the script only reads the result.

How do I draw the arrowhead?

Define an SVG marker once inside defs, with a small triangle path and orient="auto", and add marker-end="url(#arrow)" to each path. The triangle turns to match the direction of the last segment of the line.

Why do my arrows end up in the wrong place after the window is resized?

The path coordinates are plain numbers written at draw time. When the layout changes, the boxes move and the numbers do not. Observe the chart and the boxes with a ResizeObserver and redraw in its callback.

When is a diagram library worth it?

When the layout itself has to be computed, for example placing dozens of nodes automatically so lines do not cross, or when users should drag nodes and draw new connections. For a flowchart you design by hand, the approach here is enough.

Keep reading