Press / to search

    No results

    Search failed

    npm GitHub

    <progress-elemental>

    A native <progress> that says where its fill ends, so CSS can draw the bar instead of ::-webkit-progress-value and ::-moz-progress-bar โ€” and a second value beside it, for the part that is loaded but not yet played. Light DOM, no shadow root, nothing moved or wrapped.

    45%
    <progress-elemental buffer="82">
      <progress value="45" max="100">45%</progress>
    </progress-elemental>

    Usage

    Write the <progress> you would have written anyway and wrap it. The <progress> must be a direct child; without one there is nothing to measure, and nothing is enforced beyond that. Edit the sample and the preview above it follows as you type โ€” take the value off and watch it go indeterminate:

    <label for="upload">Uploading</label>
    <progress-elemental buffer="70">
      <progress id="upload" value="35" max="100">35%</progress>
    </progress-elemental>
    import 'book-of-elementals/progress';
    @use "book-of-elementals/progress/style.scss"; // structure
    @use "book-of-elementals/progress/theme.scss"; // the look, optional

    Or the single-element bundle โ€” no build step, no script to write:

    <script src="https://unpkg.com/book-of-elementals/dist/elementals/progress.min.js"></script>
    <link
      rel="stylesheet"
      href="https://unpkg.com/book-of-elementals/dist/elementals/progress.min.css"
    />
    <link
      rel="stylesheet"
      href="https://unpkg.com/book-of-elementals/dist/elementals/progress-theme.min.css"
    />

    It registers itself on include and upgrades on connect. Nothing on window, nothing to instantiate, no init call to forget.

    What the element does, and what the browser does

    Nearly all of it is the browser's, because the child is a real <progress>:

    Behaviour Whose
    role="progressbar", announced as one the browser
    max, and counting from zero the browser
    Indeterminate when there is no value the browser
    Named by a <label>, or by aria-label the browser
    Where the fill ends, as something CSS can read this element
    A second value on the same scale this element

    So no roles are written here, no aria-valuenow, and there is no event of its own. There is nothing to announce that the <progress> is not already announcing.

    API

    Attributes

    Attribute Type Default Description
    buffer number โ€” A second value on the same max, drawn behind the fill. Absent is no buffer bar at all.

    What it writes on itself

    What Value
    --progress-elemental-value How far along, as a percentage. Removed while indeterminate
    --progress-elemental-buffer The buffer over the same max, as a percentage. Removed without one
    data-indeterminate Present while the <progress> has no value. What the sweep hangs off

    Both percentages are already clamped to 0%โ€“100%, so a value past max is a full bar rather than one that has run off the end.

    Properties

    Property Type Description
    progress HTMLProgressElement Read-only. The direct-child <progress>.
    value number | null Get/set. Writes the child's value. null takes it off โ€” see below.
    max number | null Get/set. The child's max, which is 1 when it has none.
    buffer number | null Get/set. Writes the attribute. null removes it.
    apply() โ€” Re-read the <progress>. Call it after swapping the child out.

    Events

    None of its own, and none from <progress> either โ€” the element has never fired one. Move the bar and you already know it moved.

    Styling hooks

    Selector What it is
    progress-elemental The bar. Track, fill and buffer are on it
    progress-elemental[data-indeterminate] While there is no value
    progress-elemental:not(:defined) Before the script has run, or without it
    progress-elemental > progress The native bar, which is now the semantics rather than the drawing

    Moving the bar

    Set the value on the element, or on the <progress> โ€” both land in the same place:

    const bar = document.querySelector('progress-elemental');
    
    bar.value = 60; // through the element
    bar.progress.value = 60; // straight at the child
    bar.progress.setAttribute('value', 60); // or as an attribute

    All three work because <progress>'s value and max are reflecting IDL attributes: setting the property writes the attribute. The element watches the child for exactly those two attributes, so there is one way in and no way to move the bar without the drawing following. <progress> fires no event of its own, which is why watching it is the only way there is.

    A MutationObserver delivers on a microtask, so the custom properties catch up just after the write rather than during it. Nothing rendered is ever behind โ€” the browser paints after the microtask, not between statements โ€” but a test that sets a value and reads element.style on the next line will see the old one. await Promise.resolve() first, or call apply(), which is synchronous.

    Indeterminate

    A <progress> with no value is indeterminate โ€” a task under way whose length nobody knows. That is the platform's rule, not this element's, and the way back is to remove the attribute:

    bar.value = null; // indeterminate: the attribute comes off
    bar.value = 0; // determinate, and empty. Not the same thing

    The element writes data-indeterminate and removes --progress-elemental-value rather than setting it to 0%, because those two are different claims: a bar at zero says nothing has happened yet, and a bar with no value says nobody knows. With the theme on, the second one sweeps.

    <progress-elemental>
      <progress aria-label="Working"></progress>
    </progress-elemental>

    Under prefers-reduced-motion: reduce the sweep is replaced rather than dropped: the whole track fills at the buffer's weight, which still reads as working where an empty bar would read as nothing has started.

    The buffer

    <progress> has one value and <meter>'s low/high do not mean this, so a bar that is loaded to here and played to there has always been two elements stacked by hand. buffer is that second value, on the same max:

    <progress-elemental buffer="82">
      <progress value="45" max="100">45%</progress>
    </progress-elemental>

    It is decoration and it degrades as decoration: it writes no ARIA, so a screen reader hears the <progress>'s own value and nothing about buffering, and without the script or the theme it is not drawn at all. Announce it yourself if it matters โ€” usually it does not.

    The buffer is one span from the start, not the TimeRanges list a <video> actually keeps. Hand it media.buffered.end(0), which is what a scrubber draws anyway:

    video.addEventListener('progress', () => {
      if (!video.duration || !video.buffered.length) return;
      bar.max = video.duration;
      bar.buffer = video.buffered.end(0);
    });

    A scrubber

    The two elements together are the whole of one โ€” a bar underneath with the buffer on it, and a <slider-elemental> over the top for the seeking:

    <div class="scrubber">
      <progress-elemental buffer="82">
        <progress value="45" max="100">45%</progress>
      </progress-elemental>
      <slider-elemental>
        <input type="range" aria-label="Seek" min="0" max="100" value="45" />
      </slider-elemental>
    </div>
    /* One thumb size and one bar thickness for both, under the names the slider already owns.
       They are set on each of them rather than on .scrubber, because both themes declare their
       own on the element itself and a declaration on the element beats anything inherited from
       above it. */
    .scrubber slider-elemental,
    .scrubber progress-elemental {
      --slider-elemental-thumb-size: 1rem;
      --slider-elemental-track-size: 0.5rem;
      --progress-elemental-height: var(--slider-elemental-track-size);
    }
    .scrubber {
      display: grid;
    }
    /* One cell, so the slider sits over the bar โ€” and later in the markup, which is what puts
       it on top without a z-index between them. */
    .scrubber > * {
      grid-area: 1 / 1;
      align-self: center;
    }
    /* The slider's own rail is half a thumb short at each end, because that is where the
       thumb's centre starts and stops. A progress bar has no thumb to stop for, so it is
       inset to match โ€” otherwise it overhangs the seeking at both ends. */
    .scrubber progress-elemental {
      margin-inline: calc(var(--slider-elemental-thumb-size) / 2);
    }
    /* Who draws what. The played part is the slider's fill, because that is the one that
       arrives with the thumb โ€” the bar's own fill eases over --progress-elemental-duration and
       would trail behind a drag. So the bar keeps the two things the slider cannot draw, its
       rail and its buffer, and gives up the fill; the slider gives up its rail so the bar's
       shows through, and keeps the fill and the thumb. */
    .scrubber slider-elemental {
      --slider-elemental-track: transparent;
    }
    .scrubber progress-elemental {
      --progress-elemental-fill: transparent;
    }
    // The bar draws nothing of the position any more, so this is not about the drawing: the
    // <progress> is what a screen reader reads for "45%", and left behind it would announce a
    // position the control it sits under has moved away from.
    // Neither element moves the other on its own, and neither should. In a player the media is
    // what sits between them โ€” a seek sets `video.currentTime`, and it is `timeupdate` coming
    // back that moves the bar. There is no video on this page, so this stands in for it.
    const bar = document.querySelector('.scrubber progress-elemental');
    const seek = document.querySelector('.scrubber input[type=range]');
    
    seek.addEventListener('input', () => {
      bar.value = seek.value;
    });

    Each element draws only what the other cannot, which is the whole of the arrangement:

    Layer Draws Because
    <slider-elemental>, on top the played part and the thumb its fill has no transition, so it arrives with the thumb
    <progress-elemental>, behind the rail and the buffer a slider has no second value, and no rail is wanted over the bar

    The fill has to be the slider's. A progress bar eases over --progress-elemental-duration, which is right for a value arriving on its own and wrong under a thumb the reader is holding โ€” the bar would trail the drag by a quarter of a second, every drag. So the bar gives up --progress-elemental-fill and the slider gives up --slider-elemental-track, and neither draws the same pixel twice.

    One --slider-elemental-thumb-size and one thickness drive both, which is what makes the two layers one control: the slider is sized by the thumb and the bar is inset by half of it, so the rail and the buffer behind cover exactly the stretch the fill in front travels. Left to itself the bar would run the full width while the thumb travelled a half-thumb short of each end โ€” a gap at the start and another at the finish, visible only at the extremes, which is where a scrubber spends its first and last second.

    The <progress> is what a screen reader reads for position, the range input is what takes the seeking, and neither is pretending to be the other. That is why the last fence exists even now that the bar draws no position at all: the two are not wired together by these elements, and a page composing them has to say what connects them โ€” the media in a player, three lines here. Leave it out and the announced position and the visible one part company on the first drag.

    Degrading

    Without the script there are no custom properties, so the theme draws nothing at all โ€” that is what the :defined it hangs off is for. What shows is the browser's own bar, with the real value on it:

    State What you get
    No script, no theme The native <progress>, exactly as it was
    No script, theme imported The native <progress> again โ€” the theme waits for :defined
    Script, no theme The custom properties, and the native bar to draw with them
    Both The bar above

    The one thing this deliberately does not do is draw a themed bar at zero over a <progress> that knows the real answer. A bar frozen empty while a file uploads is worse than an unstyled one.

    The look

    theme.scss is optional and separate. It draws the track, the buffer and the fill on the element itself, and flattens the native bar underneath so nothing is drawn twice โ€” the <progress> stays rendered, stays role="progressbar", and paints nothing.

    Custom property Default What it does
    --progress-elemental-height 0.5rem Bar thickness
    --progress-elemental-radius 999px Corners. A big number is a pill
    --progress-elemental-track color-mix(in srgb, currentcolor 15%, transparent) Behind everything
    --progress-elemental-fill currentcolor The value
    --progress-elemental-buffer-fill color-mix(in srgb, currentcolor 35%, transparent) The buffer, behind the value
    --progress-elemental-duration 250ms How long the fill takes to move
    --progress-elemental-easing ease-out How it moves
    --progress-elemental-indeterminate-duration 1.4s One sweep, while there is no value

    The colours are mixed out of currentcolor, so the bar takes the page's palette with nothing to configure. Give it a colour of its own where it means something:

    progress-elemental.upload {
      --progress-elemental-fill: seagreen;
      --progress-elemental-height: 0.75rem;
    }

    In forced-colors mode the fill is repainted in Highlight, the buffer in GrayText and the track gets a CanvasText rim, because author backgrounds are dropped there and a bar with none is not a bar.

    Naming it

    The <progress> is the labelled thing, not the wrapper โ€” a <label> on the element around it names nothing:

    <label for="upload">Uploading</label>
    <progress-elemental>
      <progress id="upload" value="35" max="100">35%</progress>
    </progress-elemental>

    The text between the tags is a fallback for browsers without <progress>, and not a name.

    Progress, or a meter?

    Different questions, and the wrong one is a bar that lies:

    You have Use Why
    A task getting done โ€” upload, install, step 3 of 7 <progress-elemental> It counts from zero towards finished, and finished means over
    A measurement in a range โ€” disk used, score, temperature <meter> It has a min, a low and a high, and it is never done
    A value the reader sets <slider-elemental> Progress is read, a slider is written

    There is no meter elemental, because <meter> needs nothing this one needed: it takes a min, its own thresholds, and it is not something a page updates sixty times a second.