The HTML meter element: a gauge for a known range

One tag draws a gauge for disk space, a score or password strength, and turns it green, yellow or red for you. The colour rules are the part people get wrong.

The <meter> element draws a gauge for a value inside a known range: disk space used, a test score, battery level, password strength. You give it value, min and max.

Add low, high and optimum, and the browser colours the bar green, yellow or red depending on where the value sits.

<meter min="0" max="100" low="30" high="70" optimum="90" value="60"></meter>

Move the sliders below and watch the colour. The line underneath shows the attributes the browser is actually using.

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>meter playground</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .panel { background: #fff; border-radius: 12px; padding: 16px; box-shadow: 0 4px 14px rgba(0, 0, 0, .08); }
  meter { width: 100%; height: 24px; }
  code { font: 13px ui-monospace, Consolas, monospace; background: #eef1f5; border-radius: 4px; padding: 2px 5px; word-break: break-all; }
  label { display: grid; grid-template-columns: 76px 1fr 34px; align-items: center; gap: 8px; margin: 8px 0; font-size: 14px; }
  input[type=range] { width: 100%; }
  output { font-weight: 700; text-align: right; }
  #region { margin: 10px 0 0; font-size: 14px; }
  #region b { padding: 2px 8px; border-radius: 6px; }
  .optimum b { background: #dcfce7; color: #166534; }
  .suboptimal b { background: #fef3c7; color: #92400e; }
  .bad b { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<div class="panel">
  <meter id="m" min="0" max="100" value="60" low="30" high="70" optimum="90"></meter>
  <p id="region"></p>
  <label>value <input type="range" id="value" min="0" max="100" value="60"><output></output></label>
  <label>low <input type="range" id="low" min="0" max="100" value="30"><output></output></label>
  <label>high <input type="range" id="high" min="0" max="100" value="70"><output></output></label>
  <label>optimum <input type="range" id="optimum" min="0" max="100" value="90"><output></output></label>
  <p><code id="code"></code></p>
</div>

<script>
  const m = document.getElementById('m');
  const names = ['value', 'low', 'high', 'optimum'];

  // Which region the value is in, the way Chromium decides it.
  function region(v, low, high, opt) {
    if (opt < low) return v <= low ? 'optimum' : v <= high ? 'suboptimal' : 'bad';
    if (opt > high) return v >= high ? 'optimum' : v >= low ? 'suboptimal' : 'bad';
    return v >= low && v <= high ? 'optimum' : 'suboptimal';
  }
  const words = { optimum: 'optimum (green)', suboptimal: 'suboptimal (yellow)', bad: 'even less good (red)' };

  function update() {
    names.forEach((n) => {
      const input = document.getElementById(n);
      m.setAttribute(n, input.value);
      input.nextElementSibling.textContent = input.value;
    });
    // read back from the element: the browser clamps low, high and optimum
    const r = region(m.value, m.low, m.high, m.optimum);
    const box = document.getElementById('region');
    box.className = r;
    box.innerHTML = 'Region: <b>' + words[r] + '</b>';
    document.getElementById('code').textContent =
      '<meter min="0" max="100" value="' + m.value + '" low="' + m.low +
      '" high="' + m.high + '" optimum="' + m.optimum + '">';
  }
  names.forEach((n) => document.getElementById(n).addEventListener('input', update));
  update();
</script>
</body>
</html>
A meter with value, low, high and optimum on sliders. Drag low past high and the browser moves high up to match.

The six attributes

Attribute Default What it does
value 0 The current reading. Clamped to the range
min 0 Bottom of the range
max 1 Top of the range
low same as min Values below this are in the low region
high same as max Values above this are in the high region
optimum halfway between min and max Which region counts as good

Note the default max of 1. <meter value="60"> on its own draws a full bar, because 60 is clamped down to 1. Always set max when your numbers are not fractions of 1.

The browser also clamps the other numbers. low and optimum are kept between min and max, and high is never allowed below low. Reading meter.value or meter.high in JavaScript returns the clamped number.

How the browser picks the colour

low and high cut the range into three regions. optimum does not draw anything. It only says which region is the good one.

The same low and high, three different optimum values. The region holding optimum is green.
The same low and high, three different optimum values. The region holding optimum is green.
  • optimum below low (low is good, like response time): the low region is optimum, the middle is suboptimal, the high region is "even less good".
  • optimum between low and high (the middle is good, like room temperature): the middle is optimum and both ends are suboptimal.
  • optimum above high (high is good, like battery): the high region is optimum and the low region is "even less good".

In Chrome, the three states show as green, yellow and red. Other browsers use their own shades, but the regions are the same.

This is why a meter with only value, min and max stays green. With no low or high, the whole range is one region, and every value lands in it.

meter vs progress

The two bars look alike, but they answer different questions. <progress> says how much of a task is done: a download, a step in a form. <meter> says what level something is at, and that level can go up or down.

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>meter vs progress</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
  .card { background: #fff; border-radius: 12px; padding: 14px; box-shadow: 0 4px 14px rgba(0, 0, 0, .08); }
  h3 { margin: 0 0 4px; font: 700 15px ui-monospace, Consolas, monospace; }
  .card p { margin: 6px 0; font-size: 13px; color: #4b5261; line-height: 1.45; }
  meter, progress { width: 100%; height: 20px; }
  .row { display: flex; gap: 8px; align-items: center; margin-top: 8px; font-size: 13px; }
  button { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid #c9cdd4; background: #fff; cursor: pointer; }
</style>
</head>
<body>
<div class="grid">
  <div class="card">
    <h3>&lt;meter&gt;</h3>
    <p>A level inside a known range. It can go up or down, and it never "finishes".</p>
    <label for="disk">Disk used: <span id="diskText">64 of 128 GB</span></label>
    <meter id="disk" min="0" max="128" low="80" high="110" optimum="20" value="64"></meter>
    <div class="row">
      <button id="less">&minus; 16 GB</button>
      <button id="more">+ 16 GB</button>
    </div>
    <p>Low use is good here (<code>optimum="20"</code>), so the bar turns yellow, then red, as the disk fills.</p>
  </div>

  <div class="card">
    <h3>&lt;progress&gt;</h3>
    <p>How much of a task is done. It moves one way and ends at 100%.</p>
    <label for="up">Upload: <span id="upText">0%</span></label>
    <progress id="up" max="100" value="0"></progress>
    <div class="row"><button id="start">Start upload</button></div>
    <p>No <code>value</code> at all means "working, amount unknown":</p>
    <progress aria-label="Loading"></progress>
  </div>
</div>

<script>
  // meter: set the level directly, up or down
  const disk = document.getElementById('disk');
  function setDisk(gb) {
    disk.value = gb;  // values past min or max are clamped
    document.getElementById('diskText').textContent = disk.value + ' of 128 GB';
  }
  document.getElementById('less').addEventListener('click', () => setDisk(disk.value - 16));
  document.getElementById('more').addEventListener('click', () => setDisk(disk.value + 16));

  // progress: count up to the end of a (pretend) task
  const up = document.getElementById('up');
  let timer;
  document.getElementById('start').addEventListener('click', () => {
    clearInterval(timer);
    up.value = 0;
    timer = setInterval(() => {
      up.value += 5;
      document.getElementById('upText').textContent = up.value === 100 ? 'Done' : up.value + '%';
      if (up.value >= 100) clearInterval(timer);
    }, 120);
  });
</script>
</body>
</html>
Left: disk use goes up and down and changes colour. Right: an upload fills once and stops, and a progress with no value shows "working".
Ask whether the number can be "finished". If yes, it is progress.
Ask whether the number can be "finished". If yes, it is progress.

<progress> has only value and max, no colour regions. Leaving its value off makes it indeterminate, which a meter cannot do.

A loading bar built with <meter> tells assistive technology it is a measurement, not a task. For bars that track a task, see the HTML progress bar guide.

Labels and fallback text

A meter needs a name, the same as a form field. Use a <label for> pointing at the meter's id, or aria-label when there is no visible text. aria-label explains when each fits.

Text between the tags is fallback content:

<meter min="0" max="10" value="3">3 out of 10</meter>

Browsers that support <meter> do not display it. Show the number or a word next to the bar as normal text too. Colour alone does not tell everyone what the level means.

Styling a meter, and where it stops

width and height work as usual. Colour is the hard part. Setting background on the meter in Chrome paints behind the bar and switches it to a plainer built-in look. It does not recolour the bar.

To change the bar colours, style the browser's pseudo-elements:

meter { appearance: none; width: 100%; height: 10px; }

/* Chrome, Edge, Safari */
meter::-webkit-meter-bar { background: #e5e7eb; border: 0; border-radius: 5px; }
meter::-webkit-meter-optimum-value { background: #16a34a; }
meter::-webkit-meter-suboptimum-value { background: #f59e0b; }
meter::-webkit-meter-even-less-good-value { background: #dc2626; }

/* Firefox: the filled part */
meter::-moz-meter-bar { border-radius: 5px; }

These pseudo-elements are not in any standard, so check them in each browser you care about. If you need a gradient, labels inside the bar or a round dial, a <meter> will not get you there.

A finished example: password strength meter

This meter scores a password against five rules, from 0 to 5. With optimum="5", low="2.5" and high="4.5", scores 0 to 2 are red, 3 and 4 are yellow, and 5 is green. Nothing leaves the page.

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>Password strength meter</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .panel { max-width: 420px; background: #fff; border-radius: 12px; padding: 18px; box-shadow: 0 4px 14px rgba(0, 0, 0, .08); }
  label { font-weight: 600; font-size: 14px; }
  input { display: block; box-sizing: border-box; width: 100%; margin: 6px 0 10px; padding: 10px 12px; font: 16px system-ui, sans-serif; border: 1px solid #c9cdd4; border-radius: 8px; }

  /* The meter: drop the native look, then colour each region */
  meter { appearance: none; display: block; width: 100%; height: 10px; }
  meter::-webkit-meter-bar { background: #e5e7eb; border: 0; border-radius: 5px; height: 10px; }
  meter::-webkit-meter-optimum-value { background: #16a34a; border-radius: 5px; }
  meter::-webkit-meter-suboptimum-value { background: #f59e0b; border-radius: 5px; }
  meter::-webkit-meter-even-less-good-value { background: #dc2626; border-radius: 5px; }
  meter::-moz-meter-bar { border-radius: 5px; }  /* Firefox: the filled part */

  #strength { margin: 8px 0 12px; font-size: 14px; font-weight: 600; min-height: 1.2em; }
  ul { margin: 0; padding: 0; list-style: none; font-size: 13px; }
  li { margin: 4px 0; color: #6b7280; }
  li::before { content: "\2717  "; color: #dc2626; }
  li.ok { color: #166534; }
  li.ok::before { content: "\2713  "; color: #16a34a; }
</style>
</head>
<body>
<div class="panel">
  <label for="pw">New password</label>
  <input id="pw" type="password" autocomplete="new-password" aria-describedby="strength">
  <!-- 5 rules = score 0-5. Below 2.5 is red, 2.5-4.5 yellow, 5 green. -->
  <meter id="bar" min="0" max="5" low="2.5" high="4.5" optimum="5" value="0" aria-label="Password strength"></meter>
  <p id="strength" aria-live="polite">Type a password</p>
  <ul id="rules">
    <li data-rule="len8">At least 8 characters</li>
    <li data-rule="len12">At least 12 characters</li>
    <li data-rule="cases">Upper and lower case letters</li>
    <li data-rule="digit">A number</li>
    <li data-rule="symbol">A symbol, such as ! or #</li>
  </ul>
</div>

<script>
  const tests = {
    len8: (p) => p.length >= 8,
    len12: (p) => p.length >= 12,
    cases: (p) => /[a-z]/.test(p) && /[A-Z]/.test(p),
    digit: (p) => /\d/.test(p),
    symbol: (p) => /[^A-Za-z0-9\s]/.test(p),
  };
  const labels = ['Very weak', 'Weak', 'Weak', 'Fair', 'Good', 'Strong'];
  const pw = document.getElementById('pw');
  const bar = document.getElementById('bar');
  const text = document.getElementById('strength');

  pw.addEventListener('input', () => {
    let score = 0;
    document.querySelectorAll('#rules li').forEach((li) => {
      const ok = tests[li.dataset.rule](pw.value);
      li.classList.toggle('ok', ok);
      if (ok) score++;
    });
    bar.value = score;
    // the words carry the meaning, not only the colour
    text.textContent = pw.value ? 'Strength: ' + labels[score] : 'Type a password';
  });
</script>
</body>
</html>
Five rules give a score from 0 to 5. The meter colours it, and a live text line says it in words.
  1. The rules are small tests on the typed text: length, mixed case, a digit, a symbol.
  2. The score is the number of rules passed. It goes straight into bar.value.
  3. The words go in a paragraph with aria-live="polite", so a screen reader announces "Strength: Good" as the user types. The input points at it with aria-describedby.

Rules like these are a rough guide for the person typing, not a security check. The server still decides what passwords it accepts. For the form side, see HTML form validation.

Building a custom gauge with role="meter"

When you want full control of the look, draw the gauge with two divs and give the outer one the meter role and its numbers.

Two divs look like a gauge. role and aria-value attributes make them one for assistive technology.
Two divs look like a gauge. role and aria-value attributes make them one for assistive technology.
<div class="gauge" role="meter" aria-label="Storage"
     aria-valuemin="0" aria-valuemax="100" aria-valuenow="72">
  <div class="fill" style="width: 72%"></div>
</div>

When the value changes, update aria-valuenow and the width together. Add aria-valuetext when a word says it better than the number, such as aria-valuetext="72 of 100 GB". Without these attributes, a screen reader finds only an empty div.

When it does not work

What you see Cause Fix
The colour never changes No low and high, so the range is one region Add low and high, then set optimum in the good region
Green when it should be red optimum is on the wrong side Put optimum below low if low is good, above high if high is good
The bar is always full max left at its default of 1 Set max, for example max="100"
The value stops at the end Values outside min and max are clamped Widen the range, or check the number you pass in
background or color does not recolour the bar The bar is drawn by the browser Use the ::-webkit-meter-* and ::-moz-meter-bar pseudo-elements
A loading bar says "meter" to a screen reader <meter> used for task progress Use <progress>
A div gauge is silent to a screen reader No role or values Add role="meter", aria-valuenow, aria-valuemin, aria-valuemax and a label

A gauge is meant to move. A screenshot shows one colour at one value, and an .html attachment may open as plain code 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 people you send it to can type a password or drag the sliders themselves. If you change the code later, the same link shows the new version.

Questions people ask

What is the difference between meter and progress?

progress shows how much of a task is done, such as an upload, and moves toward an end. meter shows a level inside a known range, such as disk usage or a score, and can go up or down. If the number can be "finished", use progress. Otherwise use meter.

Why does my meter always stay green?

Without low and high, the whole range is one region, so every value counts as optimum. Add low and high to split the range into three regions, and set optimum inside the region that is good.

Why is my meter full when value is 60?

max defaults to 1, and a value above max is clamped to max. Add max="100" (or whatever your range is) and the bar will show 60 percent.

How do I change the colour of a meter?

The bar is drawn by the browser, so background and color on the meter do not recolour it. In Chrome, Edge and Safari, style ::-webkit-meter-optimum-value, ::-webkit-meter-suboptimum-value and ::-webkit-meter-even-less-good-value. In Firefox, style ::-moz-meter-bar. For full control, build the gauge from divs with role="meter".

Is the meter value sent with a form?

No. meter is output, not a form control, so it has no name and is not part of the submitted data. If the server needs the number, put it in an input as well.

Keep reading