Circular Progress

Mode

Skin

Theme

Overview

ln-circular-progress is applied to a <div data-ln-circular-progress="42"> container. On initialization, it mounts an SVG circle track, progress ring, and centered label into the host. A MutationObserver on data-ln-circular-progress synchronizes the stroke-dashoffset, updates the text label, and reflects ARIA progressbar attributes (aria-valuenow, aria-valuetext), maintaining zero state in JavaScript memory. The attribute IS the API — there is no imperative setValue().

The design choice this contract reflects: state lives in the DOM, not in JS. A server-rendered template can stamp data-ln-circular-progress="75" directly into markup; a 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-progress (linear 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 shape into a ring — the platform offers no hook to reshape the geometry. To combine premium presentation with full accessibility, ln-circular-progress implements native ARIA reflection automatically, mapping and synchronizing ARIA attributes during render. See "Accessibility" below.

For the full guide see the ln-circular-progress README and the architecture reference.

Anatomy

The minimum invocation is one attribute on a host element:

<div data-ln-circular-progress="75"></div>

The constructor builds the SVG and label and appends them inside the host. After init, the DOM looks like:

<div data-ln-circular-progress="75"
     data-ln-circular-progress-initialized=""
     role="progressbar"
     aria-valuemin="0"
     aria-valuemax="100"
     aria-valuenow="75"
     aria-valuetext="75%"
     class="success">
    <svg viewBox="0 0 36 36" aria-hidden="true">
        <circle cx="18" cy="18" r="16" fill="none" stroke-width="3"
                class="ln-circular-progress__track"></circle>
        <circle cx="18" cy="18" r="16" fill="none" stroke-width="3"
                stroke-linecap="round"
                stroke-dasharray="100.53"
                stroke-dashoffset="25.13"
                transform="rotate(-90 18 18)"
                class="ln-circular-progress__fill"></circle>
    </svg>
    <strong class="ln-circular-progress__label">75%</strong>
</div>

Three numbers worth knowing: viewBox 36×36, radius 16, circumference 2·π·16 ≈ 100.53. These are module-level constants in ln-circular-progress.js:10-12 and are NOT configurable per instance — size variants scale via CSS, the SVG viewBox stays at 36. The transform="rotate(-90 18 18)" on the fill is what makes the arc start at 12 o'clock instead of 3 o'clock.

Reactive state — the attribute IS the API

Three observed attributes: data-ln-circular-progress (the value), data-ln-circular-progress-max (default 100), and data-ln-circular-progress-label. The MutationObserver in _listenValues observes all three of these attributes. Mutating any of these attributes triggers a re-render to update the visual state and ARIA properties automatically.

What state goes where

"Should this element be a circular progress?" lives on data-ln-circular-progress (presence) — owned by the author or consumer markup. "Current value" lives on the same attribute (value) — owned by author at render time, consumer JS at runtime. "Maximum" lives on data-ln-circular-progress-max; "label override" on data-ln-circular-progress-label. "Computed arc offset" lives on stroke-dashoffset of the fill circle, 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 attribute fresh.

Attributes

AttributeOnDescription
data-ln-circular-progress="N"host elementCurrent value. Number — can be negative or above max; rendered percentage clamps to 0–100, but the attribute itself is never rewritten.
data-ln-circular-progress-max="N"host elementMaximum value. Default 100. Used as denominator. max="0" is treated as 0% (avoids divide-by-zero).
data-ln-circular-progress-label="text"host elementCustom centre label. Replaces the auto-computed percentage. Fully observed and reactive — updates the visible text and aria-valuetext immediately.
data-ln-circular-progress-initializedhost element (auto)Set by the constructor; removed by destroy(). Internal marker.

Visual variants — class-driven, no JS involvement

ClassEffect
.successFill stroke = hsl(var(--color-success))
.warningFill stroke = hsl(var(--color-warning))
.errorFill stroke = hsl(var(--color-error))
(no class)Fill stroke = var(--color-accent) — follows theme
.sm2.5rem — small
(no class)4rem — default
.lg6rem — large
.xl8rem — extra large

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.

Events

EventOnBubblesCancelabledetail
ln-circular-progress:changehost elementyesno{ target, value, max, percentage }

detail.value is the raw parseFloat of the value attribute — unclamped. detail.max is the parsed max (or 100 if missing). 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 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 ring 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 stroke directly on the fill circle in scss/components/_circular-progress.scss; the default (no class) reads var(--color-accent) and follows the active theme.

  • Default (75%)
  • .success (90%)
  • .warning (55%)
  • .error (25%)

Example — Size variants

Size variants scale the host element via CSS. The SVG viewBox is unchanged; the SVG fills the host's pixel dimensions. Each size variant also rebinds the label font-size proportionally.

  • .sm (2.5rem)
  • default (4rem)
  • .lg (6rem)
  • .xl (8rem)

Example — Non-percentage scale (max + label)

For "X out of Y" indicators, set data-ln-circular-progress-max="Y" to shift the denominator and data-ln-circular-progress-label="X/Y" to override the auto-computed percentage. Without the label override, the centre would show the ratio (e.g. "70%") instead of the count.

  • 7 out of 10
  • Letter grade
  • Raw count

Example — Reactive updates (attribute write)

The attribute IS the API. Each button below writes data-ln-circular-progress to a new value; the MutationObserver fires, _render recomputes the offset, and the browser's CSS transition smoothly animates the arc from the previous angle to the new one. Open DOM Inspector and watch the attribute change with each click.

  • Click below to change

Try the overshoot and undershoot buttons. The attribute holds the raw value (visible in DOM Inspector), but the rendered arc clamps to 0–100% — the ring fills fully at 100% and stays empty at 0%, no matter how far the value strays.

Example — Listening for ln-circular-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.

No events yet — click a button above.

Example — Theme awareness

The default ring (no colour class) reads var(--color-accent) for the fill and var(--color-border) for the track. Both are vocabulary tokens that themes rebind at theme :root. Switch the theme via the palette icon at the top right of the page — the default ring follows the new accent without any per-component CSS or class swap. The .success / .warning / .error variants use absolute status colours and are not theme-shifted.

  • Default — follows theme accent
  • .success — fixed status colour

Example — There is no indeterminate mode

Setting data-ln-circular-progress="" or "abc" renders an empty ring (0% — parseFloat failure falls through || 0). There is no spinning state, no "loading…" mode. The component IS the determinate ring; for an unbounded operation use @mixin loader (a separate primitive — different visual idiom).

  • Empty value — renders as 0%
  • Invalid value — renders as 0%

Accessibility — native ARIA reflection

The component sets aria-hidden="true" on the constructed `` (so screen readers skip the visual) and automatically writes and synchronizes accessibility-related attributes on the host element: role="progressbar", aria-valuemin="0", aria-valuemax, aria-valuenow (clamped value), and aria-valuetext (the text label). To provide additional screen reader context, developers should add a descriptive label to the host:

<div data-ln-circular-progress="75"
     aria-label="Upload progress"
     class="success">
</div>

Composition — siblings and the upload gap

ln-progress (linear 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-circular-progress does not extend ln-progress. ln-progress additionally supports a parent-track shared max (multiple bars stacking inside one track), which ln-circular-progress does NOT — circular indicators read max only from their own host attribute.

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-circular-progress or ln-progress — the upload component predates the progress components and was not refactored. If your project wants a circular indicator inside an upload list item, swap the inline progress bar for a <div data-ln-circular-progress> in markup and replace the style.width write with setAttribute('data-ln-circular-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 ring, drive it from the consumer's xhr.upload.progress listener directly — see the README's "Upload progress" example.

API

const el = document.querySelector('[data-ln-circular-progress]');

el.lnCircularProgress.dom;             // back-reference to host
el.lnCircularProgress.svg;             // the constructed <svg>
el.lnCircularProgress.progressCircle;  // the fill <circle>
el.lnCircularProgress.labelEl;         // the <strong> label
el.lnCircularProgress.destroy();       // disconnect observer, remove SVG + label

window.lnCircularProgress(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:

el.setAttribute('data-ln-circular-progress', 75);
// → MutationObserver fires → _render runs → SVG redraws → :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. Pre-existing content inside the host

The constructor appendChilds the SVG and label into the host without clearing existing children. <div data-ln-circular-progress="50">Loading...</div> ends up with "Loading..." text overlapping the ring. Keep the host empty; place descriptive text on a sibling (e.g. <figcaption>).

2. Writing values above max and expecting attribute clamping

The component clamps the rendered percentage (0–100), not the attribute. el.setAttribute('data-ln-circular-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.

3. Decimal value, integer label disagreement

data-ln-circular-progress="42.7" draws the arc at exactly 42.7% but shows "43%" in the label (Math.round). The label-only rounding is intentional — visual-noise reduction — but it does mean the rendered shape and the rendered text disagree by up to 0.5%. Round the value before writing if pixel-accurate label-to-arc match matters.

4. Firing 60+ writes per second from a tight loop

Every attribute write triggers the MutationObserver, runs _render, and dispatches :change. At 60Hz that's 60 events per ring. 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).

5. Expecting an indeterminate / spinning state

There is no spinner mode. Empty / invalid values render as 0%. For an unbounded operation use @mixin loader — different visual idiom, separate primitive.

6. Calling destroy() and expecting the host to stay empty

destroy() removes the SVG and label, but leaves data-ln-circular-progress in place. Any subsequent attribute write triggers registerComponent's shared MutationObserver, which re-instantiates the component. To remove the component permanently, also remove the attribute: el.removeAttribute('data-ln-circular-progress').