Navigation

v0.0.4
Feedback

Busy

A region whose content is being fetched again, under a veil and a spinner.

On this page

Busy marks a region whose content is being fetched again: a card after its filter changed, a table while a page loads, a form while it saves. Put busy on the region and set aria-busy="true" while the work runs. A veil in the color of the region's surface steps the content back and a spinner turns in the middle; what was there keeps its place, so nothing jumps when the new content arrives.

Busy

<article class="card busy w-full max-w(320)" id="revenue" aria-label="Revenue">  <header class="card-header">    <h3 class="card-title">Revenue</h3>    <p class="card-description">$48,200 this month, up 12% on October.</p>  </header>  <footer class="card-footer">    <button class="btn btn-sm" type="button" id="revenue-refresh">Refresh</button>  </footer></article><script>  var card = document.getElementById('revenue')  document.getElementById('revenue-refresh').addEventListener('click', function () {    card.setAttribute('aria-busy', 'true')    card.inert = true    setTimeout(function () {      card.removeAttribute('aria-busy')      card.inert = false    }, 1800)  })</script>
Press Refresh: the card is busy for two seconds.

#One attribute

The class draws nothing by itself. aria-busy="true" on the same element is the state: it is what a screen reader is told, and it is what the stylesheet reads, so the two cannot disagree. Remove the attribute, or set it to false, when the work ends.

The veil waits 160ms before it shows, then fades in. Work that ends at once never flashes, and a reader is not told to wait for something that is already there.

While busy

<article class="card busy w-full max-w(320)" aria-busy="true" aria-label="Revenue" inert>  <header class="card-header">    <h3 class="card-title">Revenue</h3>    <p class="card-description">$48,200 this month, up 12% on October.</p>  </header>  <footer class="card-footer">    <button class="btn btn-sm" type="button">Refresh</button>  </footer></article>

#The veil

The veil is mixed from --surface-color, which a sheet, a card and a dialog publish, so it dims a card with the card's own color and never with a grey. On a region with no surface of its own it uses the page's canvas. It takes the region's corner, and it is whole behind the spinner and thinner around it, so the ring never stands on a word.

The veil keeps the pointer off what is under it. It cannot keep the keyboard out: add the inert attribute while the region is busy, and remove it after, as the first example does.

--busy-size sets the spinner's diameter, 20px by default.

#Busy or skeleton

Busy is for content that is already there and is being replaced. Content that has never loaded has nothing to step back: show its shape with a skeleton. A wait that belongs to one control is a spinner in that control, and a button has its own aria-busy state.

#Accessibility

  • aria-busy="true" tells assistive technology that the region is being updated, so it may wait until the attribute is removed before it reads changes inside it.
  • It does not announce that work has started. Say so in words in a live region outside the busy one, or in the control that started it.
  • Make the region inert while it is busy, so its controls cannot be focused or pressed under the veil.
  • The spinner turns more slowly for a reader who asks for reduced motion, and in forced colors the veil is the canvas color.

#Classes

ClassDescription
busyOn the region itself: while it carries aria-busy="true", a veil in the color of its surface steps the content back and a spinner turns in the middle. The veil takes the region's corner and waits 160ms before it shows

#Custom properties

PropertyDefaultDescription
--busy-size1.25remThe spinner's diameter

#Source

@master/ui/components/busy.css
/* * Busy: a region whose content is being fetched again. * While the region carries aria-busy="true", a veil in the color of the surface it lies on steps its content * back and a spinner turns in the middle, where the veil is whole. What was there stays where it was, so * nothing jumps when the new content arrives; for content that has never loaded, use a skeleton instead. * The veil waits 160ms before it shows: work that ends at once never flashes. It takes the region's own * corner and keeps the pointer off what is under it; add the inert attribute to keep the keyboard out too. */@layer components {  .busy {    --busy-size: 1.25rem;    --busy-width: max(1.5px, calc(var(--busy-size) / 9));    --busy-surface: var(--surface-color, var(--color-surface-base));    position: relative;    &[aria-busy="true"] {      /* The veil. */      &::before {        content: '';        position: absolute;        z-index: 2;        inset: 0;        border-radius: inherit;        corner-shape: inherit;        /* The veil is whole behind the spinner and thins out around it, so the ring never stands on a word. */        background:          radial-gradient(circle at center, var(--busy-surface) calc(var(--busy-size) * .7), transparent calc(var(--busy-size) * 1.7)),          color-mix(in oklab, var(--busy-surface) 62%, transparent);        cursor: progress;        animation: busy-arrive var(--duration-base) var(--easing-glide) 160ms both;      }      /* The spinner: the same fine ring as the spinner component, in the strong ink. */      &::after {        content: '';        position: absolute;        z-index: 3;        inset: 0;        width: var(--busy-size);        height: var(--busy-size);        margin: auto;        border-radius: 999px;        background: conic-gradient(from 90deg, color-mix(in oklab, var(--color-text-strong) 12%, transparent) 0 18%, var(--color-text-strong));        mask: radial-gradient(farthest-side, transparent calc(100% - var(--busy-width) - .5px), black calc(100% - var(--busy-width)), black calc(100% - .5px), transparent);        pointer-events: none;        animation: busy-arrive var(--duration-base) var(--easing-glide) 160ms both, busy-rotate .72s linear infinite;      }      @media (prefers-reduced-motion: reduce) {        &::after {          animation-duration: var(--duration-base), 1.8s;        }      }      @media (forced-colors: active) {        &::before {          background: Canvas;          opacity: .7;          animation: none;        }        &::after {          background: conic-gradient(from 90deg, GrayText 0 18%, CanvasText);          forced-color-adjust: none;        }      }    }  }}@keyframes busy-arrive {  from {    opacity: 0;  }}@keyframes busy-rotate {  to {    rotate: 360deg;  }}