Overview
ln-progress is applied to a bar element inside a track via <div class="progress"><div data-ln-progress="42"></div></div>. A MutationObserver observes the attribute, calculates the percentage against max, assigns style.width, and synchronizes ARIA progressbar attributes (aria-valuenow, aria-valuemin, aria-valuemax). The attribute IS the API — there is no imperative setValue() method.
The design choice this contract reflects: state lives in the DOM, not in JS. A server-rendered template can stamp data-ln-progress="75" directly into markup; consumer JS that updates the value writes the same attribute. Both paths flow through the same MutationObserver. There is one source of truth — the attribute — and DOM Inspector shows it. This is the same pattern ln-circular-progress (circular sibling), ln-stepper, and ln-table's display layer all use: passive renderers driven by external state.
Native <progress> would have given screen-readers automatic role="progressbar" and aria-valuenow sync. It does not support multiple coloured value bars stacked inside a single track — a constraint that becomes load-bearing for "CPU 40% / RAM 30% / Disk 30%" or "4 done + 2 in progress + 1 pending" patterns. The trade-off accepted by ln-progress: full visual control via <div>s, theme-driven colours via CSS variables, animated width transitions via CSS. ARIA is auto-managed on each bar (role="progressbar", aria-valuemin, aria-valuemax, aria-valuenow clamped to [0, max]) — consumers add an aria-label on the bar for context but do nothing for value sync.
For the full guide see the ln-progress README and the architecture reference.
Anatomy
The minimum invocation is one track wrapping at least one bar:
<div class="progress">
<div data-ln-progress="75" class="success"></div>
</div>
Two roles, two distinct markers:
- Track:
class="progress"on the wrapper. Plain styled element with no per-instance JS state — purely a CSS hook for the track chrome. - Bar:
data-ln-progress="N"on the inner element. Gets a per-instance constructor that writesstyle.widthand registers attribute observers.
Reactive state — the attribute IS the API
Two observed attributes on the bar: data-ln-progress (value) and data-ln-progress-max (default 100, or the parent's shared max if present). The MutationObserver in _listenValues is filtered to exactly these two — nothing else triggers a re-render.
An additional per-bar observer (_listenParent) watches the parent track's data-ln-progress-max, but ONLY if the parent had that attribute at construction. Parents that gain the attribute later are not watched — see "Common mistakes" item 4.
What state goes where
"Should this element be a track?" lives on class="progress". "Should this element be a bar?" lives on the presence of data-ln-progress. "Current value" lives in that same attribute on the bar. "Maximum" can live on the bar (per-bar denominator) or on the parent track (shared denominator for stacked bars). "Computed bar width" lives in the bar's style.width, written by JS but never read by anything except the browser's renderer. There is no JS-side cached "value" property — every _render call reads the attributes fresh.
Asymmetry with circular sibling — shared parent max
ln-progress supports a feature ln-circular-progress does NOT: a single data-ln-progress-max on the parent track that all children read as the denominator. This is the difference between "three bars stacking inside a 100% track" and "three bars stacking inside a 7-unit track." The shared-max mode exists because stacked bars with mismatched denominators look broken — three bars filling 4%, 2%, 1% of a 100-unit track make a track that's mostly empty, which reads as "an unfinished operation," not "a complete tally of seven items."
For the circular sibling, the equivalent visual would be three concentric rings sharing one denominator — a different component, not currently shipped. The asymmetry is deliberate: each component is canonical for a different pattern.
| Component | Shared-max source | Why |
|---|---|---|
ln-progress | parent track (preferred) → bar's own max → default 100 | Stacked bars need to fill one track proportionally |
ln-circular-progress | bar's own max → default 100 | A single ring; multi-ring stacking is a different idiom |
ln-circular-progress is the canonical reference for SVG-based progress geometry and the centre-label override pattern. ln-progress is the canonical reference for shared-max parent-track patterns and multi-segment stacking. Cross-link both READMEs when documenting shared concerns.
Attributes
| Attribute | On | Description |
|---|---|---|
class="progress" | track element | Marks the wrapper. Pure CSS hook — no JS instance is created. The track element styles itself via @include progress. |
data-ln-progress="N" | bar element | Current value. Number — can be negative or above max; rendered percentage clamps to 0–100, but the attribute itself is never rewritten. |
data-ln-progress-max="N" | bar element | Per-bar maximum. Default 100. Used as denominator when the parent does not declare a shared max. max="0" is treated as 0% (avoids divide-by-zero). |
data-ln-progress-max="N" | track element | Shared maximum for all bar children. Wins over each bar's own max (parentMax || ownMax || 100 at line 126). Watched by each child's _parentObserver, but only if the attribute existed at the child's construction. |
Visual variants — class-driven, no JS involvement
| Class | Effect |
|---|---|
.success | Bar background = hsl(var(--color-success)) |
.warning | Bar background = hsl(var(--color-warning)) |
.error | Bar background = hsl(var(--color-error)) |
| (no class) | Bar background inherits — typically transparent against the track's --bg-recessed |
Variants are pure CSS — the component does not read or write className. Toggling classes via el.classList.add('success') applies the new colour instantly without a re-render.
Unlike ln-circular-progress, there is no default theme-aware colour: if you don't add a status class, the bar's background is transparent and the bar reads as "no fill" against the track. Add .success for the typical "in progress" indicator, or override the bar's background in project SCSS.
Events
| Event | On | Bubbles | Cancelable | detail |
|---|---|---|---|---|
ln-progress:change | bar element | yes | no | { target, value, max, percentage } |
detail.target is the bar. detail.value is the raw parseFloat of the value attribute — unclamped. detail.max is the resolved max (parent's first, then bar's, then 100). detail.percentage is the clamped 0–100 percentage. A consumer that wants to detect overshoot (value > max) reads value directly; a consumer that wants the visible percentage reads percentage.
The shape is identical to ln-circular-progress:change — both passive renderers expose the same four-property detail. A document-level listener can treat both event types uniformly.
The event fires on every _render call — including the initial render at construction. A document-level listener attached before the bundle loads will see one event per bar on the page during DOMContentLoaded. Listeners attached after init miss the initial event.
The event is not cancelable. Unlike ln-search, there is no default behaviour for a consumer to opt out of — the rendering IS the contract for a passive renderer.
Example — Colour variants
Colour is class-driven. The shipped .success / .warning / .error classes override background-color directly in scss/components/_progress.scss; the bar without a class has no fill colour and reads as empty. Status colours intentionally bypass the theme accent cascade because status semantics are absolute (a "success" bar should be green even if the theme accent is purple).
-
Success (80%)
-
Warning (50%)
-
Error (25%)
Example — Custom per-bar max
Set data-ln-progress-max on the bar to shift the denominator. The width is computed as (value / max) * 100%. There is no centre-label override (that's an ln-circular-progress feature) — if you need descriptive text, place it as a sibling element.
-
Storage: 7.5 GB of 10 GB (75% of track)
-
Backup: 45 of 50 items (90% of track)
Example — Stacked bars, default max=100
Multiple bars inside a single track. Each bar uses its own max (default 100), so its style.width is its value as a percentage. If the values sum to less than 100, the remainder of the track stays empty (showing through as --bg-recessed); if more than 100, each bar still renders at its clamped 0–100 width and they overflow horizontally — the track's overflow: hidden clips the overflow visually but the maths is wrong.
-
Resources: CPU 40%, RAM 30%, Disk 30% (fills the track exactly)
-
Three small values without parent max — bars fill 4 + 2 + 1 = 7% of the track
The second example renders mostly empty because each bar uses the default max of 100. To make 4 + 2 + 1 fill the entire track, declare the parent's shared max — see the next example.
Example — Stacked bars with shared max on the track
This is the canonical pattern that is asymmetric with ln-circular-progress. The parent's data-ln-progress-max wins over each bar's default 100 (resolution: parentMax || ownMax || 100 at line 126), and the three bars together fill the track to 100%.
-
Tasks — 4 done + 2 in progress + 1 pending = 7 total (parent max=7, fills exactly)
-
Same values, parent max=15 (track partially filled, 7/15)
The ln-circular-progress equivalent has no parent-shared-max mode. Three rings sharing a denominator of 7 must declare data-ln-circular-progress-max="7" on each individually.
Example — Reactive updates (attribute write)
The attribute IS the API. Each button below writes data-ln-progress on the bar to a new value; the per-bar MutationObserver fires, _render recomputes the percentage, and the browser's CSS transition smoothly animates the width from the previous size to the new one. Open DOM Inspector and watch the attribute (and the inline style.width) change with each click.
Try the overshoot and undershoot buttons. The attribute holds the raw value (visible in DOM Inspector), but the rendered bar clamps to 0–100% — the bar fills fully at 100% and stays empty at 0%, no matter how far the value strays.
Example — Listening for ln-progress:change
Every render dispatches the event with both the raw value and the clamped percentage. The log below shows what a document-level listener sees as you click the buttons in the previous example. Notice how an overshoot value (150) appears in value but is clamped to 100 in percentage.
Example — There is no indeterminate mode
Setting data-ln-progress="" would mark the element as a track, not a bar — no instance is constructed and no width is written. Setting data-ln-progress="abc" renders the bar at 0% (parseFloat failure falls through || 0). There is no animated-stripes overlay, no shimmer, no pulse mode. The component IS the determinate bar; for an unbounded operation use @mixin loader (a separate primitive — different visual idiom).
-
Invalid value
"abc"— renders as 0%
Accessibility — auto-managed
The component writes role="progressbar", aria-valuemin="0", aria-valuemax, and aria-valuenow on each bar element on every render. The four attributes are written inside _render after the style.width calculation; aria-valuenow is clamped to [0, max] so screen-reader reports match the rendered bar even on overshoot.
Consumers add aria-label on the bar for context — what the bar represents ("Upload progress", "Tasks done", "CPU usage"). The component does not invent a label.
<div class="progress">
<div data-ln-progress="75" class="success"
aria-label="Upload progress"></div>
</div>
For multi-segment stacked tracks, each bar is its own progressbar — semantically each segment is a distinct value advertised separately. Add a per-bar aria-label ("Done", "In progress", "Pending") and the screen reader announces three progressbars in sequence; trying to compress three values into a single track-level aria-valuenow would lose information.
Composition — siblings and the upload gap
ln-circular-progress (circular sibling) shares the contract shape — same :change event detail ({ target, value, max, percentage }), same attribute pattern. They are independent implementations of the same idea in different geometries; ln-progress does not extend ln-circular-progress (the components do not share code). Asymmetries: ln-progress additionally supports parent-shared max (multiple bars stacking inside one track); ln-circular-progress additionally supports a centre-label override (data-ln-circular-progress-label) that has no linear analogue (the bar IS the visual, no label slot inside it).
ln-upload uses its own raw <div class="ln-upload__progress-bar"> with manual style.width writes (see js/ln-upload/ln-upload.js:149-152). It does NOT compose with ln-progress or ln-circular-progress — the upload component predates the progress components and was not refactored. If your project wants a managed progress bar inside an upload list item, swap the inline progress markup for a <div data-ln-progress> track wrapping a <div data-ln-progress="0"> bar and replace the style.width write with setAttribute('data-ln-progress', percent).
ln-form does not own progress UI; submit-state feedback comes from aria-busy on the form. If a long-running form needs a progress bar, drive it from the consumer's xhr.upload.progress listener directly — see the README's "Upload progress" example.
API
const bar = document.querySelector('[data-ln-progress]');
bar.lnProgress.dom; // back-reference to bar element
bar.lnProgress._attrObserver; // observer for bar's own value/max attrs
bar.lnProgress._parentObserver; // observer for parent track's max — null if parent had no max at construction
bar.lnProgress.destroy(); // disconnect observers, delete bar.lnProgress
window.lnProgress(root); // re-run init scan over `root`
// only needed for Shadow DOM / iframe
There is no setValue(n), setMax(n), or redraw() method. The canonical update path is the attribute:
bar.setAttribute('data-ln-progress', 75);
// → MutationObserver fires → _render runs → style.width updates → :change dispatches
Adding an imperative setter would create a second mutation channel that the MutationObserver would also see, without removing the need to write the attribute for screen-readers / DOM Inspector / serialization. The single source of truth is the attribute.
Common mistakes
1. Setting width manually inside the markup
The constructor's first action is _render, which writes style.width based on the value/max attributes. Whatever inline width you set in markup is overwritten on the first paint. Use data-ln-progress="N" instead of style="width: N%".
2. Mixing percentage values with absolute max
data-ln-progress="50" data-ln-progress-max="500" renders 50/500 = 10% of the track, which is probably not what you meant. Either write the absolute count (data-ln-progress="250") or drop the max (defaults to 100, value is a percentage).
3. Multiple bars sharing a parent and forgetting parent-max
Three bars at 4 / 2 / 1 without a parent data-ln-progress-max use the default max of 100 each — bars fill 4 + 2 + 1 = 7% of the track. To make them fill the track exactly, set the parent's max: <div data-ln-progress data-ln-progress-max="7">.
4. Setting parent max AFTER construction
_listenParent bails immediately if the parent does not declare data-ln-progress-max at construction time. The parent observer is conditional, not lazy. If the parent gains the attribute later, the bars do not re-render in response. Workaround: rewrite each bar's own attribute (bar.setAttribute('data-ln-progress', bar.getAttribute('data-ln-progress'))) to fire _listenValues and force _render to re-resolve max with the now-set parent max in scope.
5. Updating value rapidly from a tight loop
Every attribute write triggers the MutationObserver, runs _render, and dispatches :change. At 60Hz that's 60 events per bar. The CSS transition makes per-frame writes pointless anyway — set the value once and let CSS animate. If you genuinely need stepwise animation, throttle to ~10Hz (every 100ms).
6. Writing values above max and expecting attribute clamping
The component clamps the rendered percentage (0–100), not the attribute. bar.setAttribute('data-ln-progress', 150) leaves the attribute at "150"; e.detail.value is 150, e.detail.percentage is 100. Clamp before writing if you need the attribute itself bounded.
7. Track inside a flex / grid container without sizing
@mixin progress includes width: 100%, but inside a flex container that resolves against the flex basis. Give the track flex: 1 (in project SCSS, not inline) so it grows to fill the available space.
8. Calling destroy() and expecting the bar to stay inert
destroy() disconnects observers but leaves data-ln-progress in place. Any subsequent attribute write triggers the document-level _domObserver, which re-instantiates the bar. To remove the component permanently, also remove the attribute: bar.removeAttribute('data-ln-progress').