Bars, spinners and skeletons that follow your page's theme on their own. Click anything below — every card is live.
A fake upload drives set(); done() flips to
the success color.
var bar = Progress.bar('#bar-slot', {
label: 'Uploading photo…',
showValue: true, autoRemove: true })
bar.set(42) // …later:
bar.done()
No known total — a segment sweeps until set() or
done() promotes it.
Progress.bar('#ind-slot', {
indeterminate: true,
label: 'Contacting server…' })
role="status" with visually-hidden text — screen
readers hear "Loading…".
Progress.spinner('#spinner-slot',
{ size: 28, label: 'Loading results…' })
Original content is hidden, not removed — Reveal calls
release() and restores it exactly.
var sk = Progress.skeleton('#sk-target',
{ avatar: true, lines: 3 })
sk.release() // or Progress.skeleton.release(el)
These three were built from markup alone on page load.
<div data-vpg="bar" data-vpg-value="65"
data-vpg-label="Profile complete"
data-vpg-show-value></div>
Small + success variants. With prefers-reduced-motion, shimmer, sweep and spin all become a calm opacity pulse — try it in your OS settings.
Progress.bar(el, { size: 'sm',
color: 'success', value: 80 })
Progress is a namespace of three static primitives —
Progress.bar(), Progress.spinner() and
Progress.skeleton(). Each takes a target (element or selector,
the widget is appended into it), merges its options over
Progress.defaults, and returns a small plain-object handle.
SSR-safe: without a DOM (or with a missing target) every call returns an
inert handle whose methods are all safe to call.
var bar = Progress.bar('#upload', { label: 'Uploading…', showValue: true })
bar.set(42)
bar.done() // fills to 100% in the success color
var sp = Progress.spinner('#slot', { size: 28 })
sp.remove()
var sk = Progress.skeleton('#card', { avatar: true, lines: 3 })
sk.release() // or: Progress.skeleton.release('#card')
Progress.bar(target, opts)| Name | Type | Default | Description |
|---|---|---|---|
value | number | 0 |
Starting value, clamped to [0, max]. |
max | number | 100 |
The 100% mark (values ≤ 0 fall back to 100). |
indeterminate | boolean | false |
Sweeping animation for an unknown total; aria-valuenow is omitted while indeterminate. |
label | string | '' |
Visible text above the bar — always plain text (textContent, never HTML). Also becomes the track's aria-label. |
showValue | boolean | false |
Rounded percentage ('42%') at the right of the label row. |
size | string | 'md' |
'sm' | 'md' — track height 5px vs 8px. |
color | string | 'accent' |
'accent' | 'success' fill color. |
autoRemove | boolean | number | false |
After done(): true → remove the bar after 800 ms; a number → custom delay in ms. |
styles | boolean | true |
Shared: false = headless, no CSS injected. |
theme | string | 'auto' |
Shared: 'auto' | 'light' | 'dark' (pin globally via Progress.defaults.theme). |
| Member | Description |
|---|---|
el | The root .vpg.vpg-bar element (or null for the inert SSR handle). |
set(value) | Clamps to [0, max], updates the fill, aria-valuenow and the percentage readout. On an indeterminate bar the first set() promotes it to determinate. Chainable. |
done() | Fills to 100% in the success color (and promotes an indeterminate bar), then removes the bar if autoRemove is set. Chainable. |
setLabel(text) | Replaces the label text (creating the label row if needed) and keeps aria-label in sync. Chainable. |
remove() | Removes the bar from the DOM immediately. Idempotent. |
Progress.spinner(target, opts)An SVG arc — the same arc as Toast's loading icon — spinning in the accent color, with visually-hidden accessible text.
| Name | Type | Default | Description |
|---|---|---|---|
size | number | 20 |
Diameter in px. Because size doubles as the bar's 'sm'|'md' in the shared defaults, only a numeric value applies here — spinnerSize (the actual defaults key, 20) is an equivalent, unambiguous spelling. |
spinnerSize | number | 20 |
Alias of size in px; used when size is not a positive number. |
label | string | '' |
Visually-hidden accessible text; falls back to Progress.defaults.labels.loading ('Loading…'). |
inline | boolean | true |
false renders as a block (.vpg-block) instead of inline-flex. |
styles / theme | — | — | Shared options, as for bars. |
| Member | Description |
|---|---|
el | The root .vpg.vpg-spinner element. |
remove() | Removes the spinner. Idempotent. |
Progress.skeleton(target, opts)Shimmering placeholder blocks rendered inside
target. The original element children are hidden (inline
display:none), never removed — release() restores
each one's exact prior inline display. Calling skeleton() twice on
the same target is idempotent: the existing handle is returned.
| Name | Type | Default | Description |
|---|---|---|---|
lines | number | 3 |
Number of placeholder lines. |
avatar | boolean | false |
Leading 40px circle. |
header | boolean | false |
Taller first line (1.5 × height, class .vpg-head). |
widths | array | null | null |
Per-line CSS widths, e.g. ['100%','85%','60%']. Default is a staggered pattern: 100%/85% alternating, with a short 60% last line. |
height | number | 12 |
Line height in px. |
styles / theme | — | — | Shared options, as for bars. |
| Member | Description |
|---|---|
el | The root .vpg.vpg-skeleton element. |
release() | Removes the skeleton, restores every hidden child's prior inline display, and restores the target's previous aria-busy state. Safe to call twice. |
Static form: Progress.skeleton.release(target)
releases the skeleton on a target without needing the handle — handy when the
cover and the reveal happen in different places.
Progress.defaults is one live, shared object holding every
option above — bar keys (value, max,
indeterminate, label, showValue,
size, color, autoRemove), spinner keys
(spinnerSize, inline), skeleton keys
(lines, avatar, header,
widths, height) and the shared keys below. Mutate it
before rendering to change the global behavior.
| Name | Type | Default | Description |
|---|---|---|---|
styles | boolean | true |
false = headless: no CSS is ever injected — style the .vpg-* markup yourself. |
theme | string | 'auto' |
'auto' | 'light' | 'dark'. Pin globally with Progress.defaults.theme = 'dark'. |
labels | object | { loading: 'Loading…' } |
Fallback accessible text for bars without a label and for spinners (i18n). |
| Static | Description |
|---|---|
Progress.bar(target, opts) | Render a bar → { el, set, done, setLabel, remove }. |
Progress.spinner(target, opts) | Render a spinner → { el, remove }. |
Progress.skeleton(target, opts) | Render a skeleton → { el, release } (idempotent per target). |
Progress.skeleton.release(target) | Release a target's skeleton without the handle. |
Progress.autoInit(root?) | Initialize every [data-vpg] under root (default: document); returns the created handles. Runs automatically on DOMContentLoaded; already-initialized elements (stamped data-vpg-ready) are skipped. |
Progress.defaults | The live shared defaults object (see above). |
Progress.css | The full stylesheet as a string (rendered with the current salt) — a starting point for headless styling. |
Progress.salt | CSS isolation token, default 'vc1'. Set your own token or false before the first render. |
Progress.version | Version string, '1.0.0'. |
data-vpg="bar" | "spinner" | "skeleton" on any element makes it
the target container, initialized on DOMContentLoaded (or call
Progress.autoInit(root) / VC.autoInit() after
inserting markup).
<div data-vpg="bar" data-vpg-value="30" data-vpg-label="Importing" data-vpg-show-value></div>
<span data-vpg="spinner" data-vpg-size="16"></span>
<div data-vpg="skeleton" data-vpg-lines="4" data-vpg-avatar></div>
| Attribute | Applies to | Maps to |
|---|---|---|
data-vpg-label | all | label |
data-vpg-theme | all | theme |
data-vpg-value / data-vpg-max | bar | value / max |
data-vpg-size | bar, spinner | size ('sm'|'md' for bars; px → spinnerSize for spinners) |
data-vpg-color | bar | color |
data-vpg-indeterminate / data-vpg-show-value | bar | indeterminate / showValue (boolean; present = true, "false" = false) |
data-vpg-inline | spinner | inline (boolean) |
data-vpg-lines / data-vpg-height | skeleton | lines / height |
data-vpg-avatar / data-vpg-header | skeleton | avatar / header (boolean) |
data-vpg-widths | skeleton | widths (comma-separated CSS widths) |
Auto light/dark with the family's resolution order:
<html data-theme> / data-bs-theme /
.dark class → prefers-color-scheme, re-resolved live.
Pin it with Progress.defaults.theme = 'dark'. All colors are CSS
custom properties on .vpg:
.vpg {
--vpg-accent: #b45309; /* bar fill, spinner */
--vpg-success: …; /* done() fill */
--vpg-bg: …; --vpg-text: …; --vpg-muted: …;
--vpg-faint: …; /* track + skeleton base */
--vpg-shimmer: …; /* skeleton highlight */
--vpg-radius: 12px; --vpg-font: …;
}
With the VC core loaded, VC.config({ accent: '#b45309' }) themes
progress and every other family component in one call. Roots render as
class="vpg vpg-bar vc1"; structural rules ship salted so host
design systems can't override the widgets, while the unsalted
--vpg-* variable definitions keep page overrides working. Change
the token with Progress.salt = 'acme' (or false)
before the first render.
Headless: Progress.defaults.styles = false
injects no CSS — you keep values, ARIA and hide/restore behavior, and style
this markup contract yourself (Progress.css is the reference
stylesheet as a string):
.vpg.vpg-bar[.vpg-sm][.vpg-success][.vpg-ind][data-theme=dark]
.vpg-top ← only when label/showValue
.vpg-labeltext
.vpg-value ← only when showValue
.vpg-track[role=progressbar]
.vpg-fill
.vpg.vpg-spinner[.vpg-block][role=status]
svg.vpg-spin
.vpg-sr ← visually-hidden label
.vpg.vpg-skeleton[aria-hidden]
.vpg-avatar ← only when avatar
.vpg-lines
.vpg-line[.vpg-head] ← .vpg-head on header first line
role="progressbar" with
aria-valuemin/max/now and an aria-label (the label
text, or 'Loading…'). aria-valuenow is
omitted while indeterminate, per the ARIA spec, and set as
soon as set() or done() promotes the bar to
determinate.role="status" (a polite live
region) with visually-hidden text (.vpg-sr) — screen readers
hear the label, sighted users see the arc.aria-hidden="true"; the target gets
aria-busy="true" instead, restored to its previous value on
release().With styles: false Progress injects no CSS at all — the full
behavior (value updates, promotion to determinate, hide/restore), the ARIA
contract and the stable .vpg-* class hooks all remain; you bring
the stylesheet. The demos below each run in their own
<iframe>: the styled examples above have already injected
the component stylesheet into this page, so only a separate, clean document
can show what headless truly looks like.
styles: false)Bar, spinner and skeleton with zero injected CSS — label text, a live
role="progressbar", a static SVG arc and bare skeleton boxes.
The frame adds three lines of its own CSS purely so the empty
<div>s are visible at all.
Progress.bar('#bar', { label: 'Uploading…', showValue: true,
value: 42, styles: false })
Progress.spinner('#spin', { label: 'Loading…', styles: false })
Progress.skeleton('#skel', { lines: 3, avatar: true, styles: false })
/* the component injected nothing — this baseline is the page's,
so the bare boxes show up: */
.vpg-fill { height: 8px; background: #888; }
.vpg-line { background: #ddd; }
.vpg-avatar { width: 40px; height: 40px; background: #ddd; }
The same class hooks mapped to a deliberately different design with Tailwind utilities — gradient fill, pulsing skeleton, spinning arc. Nothing loads from the CDN until you click.
<style type="text/tailwindcss">
.vpg-bar { @apply w-full; }
.vpg-top { @apply mb-2 flex items-baseline justify-between; }
.vpg-labeltext { @apply text-sm font-semibold text-slate-700; }
.vpg-value { @apply text-xs font-bold tabular-nums text-purple-600; }
.vpg-track { @apply h-3 overflow-hidden rounded-full bg-slate-100
shadow-inner; }
.vpg-fill { @apply h-full rounded-full bg-gradient-to-r
from-fuchsia-500 via-purple-500 to-indigo-500
transition-all duration-300; }
.vpg-spinner { @apply inline-flex text-purple-600; }
.vpg-spin { @apply h-6 w-6 animate-spin; }
.vpg-sr { @apply sr-only; }
.vpg-skeleton { @apply flex items-start gap-4; }
.vpg-avatar { @apply h-12 w-12 flex-none animate-pulse rounded-2xl
bg-slate-200; }
.vpg-lines { @apply flex-1 space-y-2.5; }
.vpg-line { @apply animate-pulse rounded-full bg-slate-200; }
</style>