Loading Bar

This pattern shows how to build a color adaptive and accessible loading bar with the <progress> element.

Full article · Video on YouTube · Source on Github

HTML

<main id="loading-zone" aria-busy="true">
  <p>Loading Level</p>
  <div class="card">
    <label>
      <span class="sr-only">Loading progress</span>
      <progress 
        indeterminate 
        role="progressbar" 
        aria-describedby="loading-zone"
        tabindex="-1"
      >unknown</progress>
    </label>
  </div>
</main>

CSS


        progress {
  --_track: hsl(228 100% 90%);
  --_track-size: min(10px, 1ex);
  --_progress: hsl(228 100% 50%);
  --_radius: 1e3px;
  --_indeterminate-track: linear-gradient(to right,
    var(--_track) 45%,
    var(--_progress) 0%,
    var(--_progress) 55%,
    var(--_track) 0%
  );
  --_indeterminate-track-size: 225% 100%;
  --_indeterminate-track-animation: progress-loading 2s infinite ease;
  
  /*  reset  */
  appearance: none;
  border: none;
  
  /*  custom style  */
  position: relative;
  height: var(--_track-size);
  border-radius: var(--_radius);
  overflow: hidden;

  @media (prefers-color-scheme: dark) {
    --_track: hsl(228 20% 30%);
    --_progress: hsl(228 100% 75%);
  }

  &:focus-visible {
    outline-color: var(--_progress);
  }
  
  /*  Safari/Chromium  */
  &[value]::-webkit-progress-bar {
    background-color: var(--_track);
  }
  
  &[value]::-webkit-progress-value {
    background-color: var(--_progress);
    transition: inline-size .25s ease-out;
  }
  
  /*  Firefox  */
  &[value]::-moz-progress-bar {
    background-color: var(--_progress);
  }
  
  /*  indeterminate  */
  &:indeterminate::after {
    content: "";
    inset: 0;
    position: absolute;
    background: var(--_indeterminate-track);
    background-size: var(--_indeterminate-track-size);
    background-position: right; 
    animation: var(--_indeterminate-track-animation);
  }
  
  /*  indeterminate Safari  */
  &:indeterminate::-webkit-progress-bar {
    background: var(--_indeterminate-track);
    background-size: var(--_indeterminate-track-size);
    background-position: right; 
    animation: var(--_indeterminate-track-animation);
  }
  
  /*  indeterminate Firefox  */
  &:indeterminate::-moz-progress-bar {
    background: var(--_indeterminate-track);
    background-size: var(--_indeterminate-track-size);
    background-position: right; 
    animation: var(--_indeterminate-track-animation);
  }
  
  /*  complete  */
  &:not([max])[value="1"]::before,
  &[max="100"][value="100"]::before {
    content: "✓";
    
    position: absolute;
    inset-block: 0;
    inset-inline: auto 0;
    display: flex;
    align-items: center;
    padding-inline-end: max(calc(var(--_track-size) / 4), 3px);

    color: white;
    font-size: calc(var(--_track-size) / 1.25);
  }
}

@keyframes progress-loading {
  50% {
    background-position: left; 
  }
}
        

JS


        const progress = document.querySelector('progress')
const zone     = document.querySelector('#loading-zone')

const state = {
  val: .1
}

const roundDecimals = (val, places) =>
  +(Math.round(val + "e+" + places)  + "e-" + places)

const setProgress = () => {
  // set loading zone status
  zone.setAttribute('aria-busy', state.val < 1)

  // clear attributes if no value to show
  //  will show indeterminate state
  if (state.val === null) {
    progress.removeAttribute('aria-valuenow')
    progress.removeAttribute('value')
    progress.focus()
    return
  }

  // round bad JS decimal math
  const val = roundDecimals(state.val, 2)
  const valPercent = val * 100 + "%"
  
  // set value for screenreaders and element values
  progress.value = val
  progress.setAttribute('aria-valuenow', valPercent)
  progress.innerText = valPercent

  // focus so screenreaders hear the announced value update
  progress.focus()
}