ScreenshotNeo

BlogHow-to

How to Create a Cross-Browser Compatible HTML Progress Bar

Build an accessible progress bar with native HTML, handle determinate and indeterminate states, and check its appearance across your target browsers.

By the ScreenshotNeo team4 October 20268 min read

Use the native HTML <progress> element for a task progress bar. Set max to the total work and value to the completed work, and give the element an accessible name with a <label> or ARIA labeling. For indeterminate work, omit value. Native progress semantics are broadly supported, but the bar’s appearance can differ across browser engines, so test the actual browsers and versions your project supports.

This guide builds a working upload indicator, covers state updates and accessibility, and explains how to validate its rendering across browsers. The native element is the simplest dependable baseline; CSS can customize its appearance, but no single vendor-specific styling recipe should be assumed to render identically everywhere.

1. Start with semantic HTML

<progress> represents completion of a task. It is not a generic gauge for a measurement such as disk usage or temperature; use <meter> for a scalar measurement. The progress element has an implicit progressbar role, so a custom ARIA role is not needed.

<label for="upload-progress">Upload progress</label>
<progress id="upload-progress" max="100" value="45">45%</progress>
<p id="upload-status" aria-live="polite">Uploaded 45%.</p>

Here, max="100" makes the values easy to read as percentages. The default maximum is 1, so <progress value="0.45"> is also valid. A provided max must be greater than zero, and value must be between zero and max.

The label gives the progress bar its accessible name. Text nested inside the element is fallback content; it does not replace a label. Keep any separate visible percentage or status text synchronized with the actual progress value.

2. Represent determinate and indeterminate work

Use determinate progress when the application knows how much work has completed. Update the value as work advances. Use indeterminate progress when the task is underway but its completion amount is unknown: remove the value attribute entirely.

const progress = document.querySelector('#upload-progress');
const status = document.querySelector('#upload-status');

function setProgress(completed, total) {
  if (!Number.isFinite(completed) || !Number.isFinite(total) || total <= 0) {
    throw new TypeError('completed and total must be finite numbers, and total must be greater than zero');
  }

  const value = Math.min(Math.max(completed, 0), total);
  progress.max = total;
  progress.value = value;
  status.textContent = `Uploaded ${Math.round((value / total) * 100)}%.`;
}

function setIndeterminate() {
  progress.removeAttribute('value');
  status.textContent = 'Upload in progress.';
}

function setComplete(total) {
  progress.max = total;
  progress.value = total;
  status.textContent = 'Upload complete.';
}

Do not set value to zero to create an indeterminate bar; zero means determinate progress with no work completed. The HTML Standard specifies removing the attribute for indeterminate state. When the amount becomes known, set a valid value again.

If the progress bar describes a region that is being updated, connect the bar to that region using aria-describedby and mark the region aria-busy="true" while its update is in progress. Clear the busy state when the update finishes.

<section id="upload-panel" aria-busy="true" aria-describedby="upload-progress">
  <label for="upload-progress">Uploading report</label>
  <progress id="upload-progress" max="100" value="45"></progress>
  <p id="upload-status" aria-live="polite">Uploaded 45%.</p>
</section>

<script>
  // After the upload and related content updates are complete:
  document.querySelector('#upload-panel').setAttribute('aria-busy', 'false');
</script>

3. Add visual styling without replacing the semantics

Begin with the native element and add CSS for the dimensions and surrounding layout. A native control may keep browser-specific rendering for its track and fill. That can be a useful choice when a consistent platform-native appearance is acceptable. If you need a custom visual treatment, style and validate it against your supported browsers rather than assuming identical rendering.

.progress-wrap {
  max-width: 32rem;
}

.progress-wrap progress {
  display: block;
  width: 100%;
  height: 1rem;
}

.progress-wrap label,
.progress-wrap .status {
  display: block;
  margin-block: 0.5rem;
}

This CSS sizes the control and spaces its text without depending on browser-specific pseudo-elements. It does not promise pixel-identical track or fill styling. Test zero, partial, complete, and indeterminate states at the sizes and zoom levels your interface supports.

Vendor-specific pseudo-elements and other styling hooks may vary across engines and versions. The available references establish broad support for the element’s basic behavior, not a complete current styling compatibility matrix. If a branded bar must look identical everywhere, compare screenshots from your project’s target browsers and decide whether the visual variation is acceptable.

4. Validate the supported browsers and assistive technology

  1. List the minimum browser versions and assistive technology combinations your project supports.
  2. Check the native element in each target browser before adding custom track or fill styling.
  3. Verify the accessible name, current value, and indeterminate state with the screen readers in your support matrix.
  4. Exercise zero, a middle value, the maximum value, and an omitted value attribute.
  5. Check that status text stays in sync with the numeric value and that the relevant region’s busy state is cleared after completion.
  6. Review the rendered component at the sizes and zoom levels your interface supports.

MDN describes <progress> as widely available and says it has been available across browsers since July 2015. That broad support does not guarantee identical styling or cover every older browser version your project might target. See the MDN progress element reference, the WHATWG HTML Standard, and the WAI-ARIA Authoring Practices when checking the element’s semantics and requirements for custom widgets.

5. When a custom ARIA progress bar is appropriate

Prefer native <progress> when it meets the component’s needs. A custom widget can offer more visual control, but its author must implement and maintain the progressbar semantics and state. A determinate custom progress bar needs an accessible name, the progressbar role, and an updated aria-valuenow; it should also expose the relevant minimum and maximum when they differ from the defaults. For indeterminate state, omit aria-valuenow.

<div
  role="progressbar"
  aria-label="Upload progress"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuenow="45"
>
  <div class="custom-progress-fill" style="width: 45%"></div>
</div>

Update both the visual fill and aria-valuenow from the same application state. For an indeterminate custom bar, remove aria-valuenow; do not leave a stale number exposed to assistive technology. Consult the WAI-ARIA Authoring Practices for the progressbar pattern before building a custom control.

6. Troubleshooting

Symptom Likely cause Fix
The bar looks empty even though progress is intended to be indeterminate. value="0" is set, which means determinate progress at zero. Remove the value attribute.
The bar has no useful accessible name. Only fallback text was added between the progress tags. Add an associated <label>, or use aria-label or aria-labelledby.
The displayed percentage and bar disagree. The text and progress value are updated independently, or the calculation uses the wrong maximum. Derive both from the same completed and total values. Clamp input to the valid range before updating the control.
The component looks different across browsers. Native controls can use engine-specific rendering, and styling hooks can differ. Test the project’s actual target browsers. Keep the native baseline if its variation is acceptable; otherwise validate the custom styling in each target.
A completed operation still appears busy. The related region’s aria-busy state was not cleared. Set aria-busy="false" when the update finishes.
A custom bar’s announced value is stale or missing. The visual fill changed without a matching ARIA state update, or the custom widget has no accessible name. Update aria-valuenow alongside the visual state and provide an accessible name. Omit the value for indeterminate state.

7. Performance, reliability, and cost

A native progress element is a small built-in HTML control; for this component, the practical engineering work is usually keeping its state accurate and validating the supported rendering and assistive technology combinations. Avoid updating the DOM more often than needed for the user to understand progress. For long-running work, keep status text useful and make sure errors and completion update the status rather than leaving a bar stuck in an intermediate state.

Browser testing has a time cost that depends on the project’s support matrix and test workflow; this guide does not claim a benchmark or a universal testing cost. For an automated check, capture the same page state in the browsers you support and compare the results as part of your own review. ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. Its captures can return PNG, JPEG, WebP, or PDF, and its API can be used to capture a page for visual review. See the ScreenshotNeo website and ScreenshotNeo documentation.

Or skip the browser setup

To capture a page for visual review, make one GET request to the ScreenshotNeo API. Replace the example URL with a page you can access and use your API key. See the API documentation for the request options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

ScreenshotNeo removes cookie and consent banners, newsletter popups, and chat widgets before capture; each of those steps can be turned off. Bot checks, blank pages, failed loads, timeouts, and cache hits cost nothing, and response headers report the page verdict and whether the request was billed. Its MCP server gives AI agents tools to take screenshots, get page information, and capture PDFs. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots.

Sign up for 1,000 free screenshots a month, with no card required.

FAQ

Does a progress bar start at zero if I leave out value?

No. An omitted value means progress is indeterminate. Set value="0" when the task has a known total and no work is complete.

Can I use a maximum other than 100?

Yes. Choose a positive max that matches your total work, then keep value between zero and that maximum. A maximum of 100 is just a convenient way to represent percentages.

Should I use <progress> for storage usage?

Usually not if you are showing a measurement such as current disk usage. The HTML reference distinguishes task completion, represented by <progress>, from a scalar measurement, for which <meter> is appropriate.

Will native progress bars look identical in all browsers?

Basic support is broad, but identical rendering is not guaranteed. Validate visual details against the exact browser versions in your project’s support matrix.

References