Vanilla UI Kit Progress v1.0.0 · toast →

Loading, one file.

Bars, spinners and skeletons that follow your page's theme on their own. Click anything below — every card is live.

Determinate bar

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()

Indeterminate

No known total — a segment sweeps until set() or done() promotes it.

Progress.bar('#ind-slot', {
  indeterminate: true,
  label: 'Contacting server…' })

Spinner sizes

role="status" with visually-hidden text — screen readers hear "Loading…".

Progress.spinner('#spinner-slot',
  { size: 28, label: 'Loading results…' })

Skeleton over a real card

Original content is hidden, not removed — Reveal calls release() and restores it exactly.

Ada Lovelace Wrote the first program — for a machine that didn't exist yet. Patience level: legendary.
var sk = Progress.skeleton('#sk-target',
  { avatar: true, lines: 3 })
sk.release()  // or Progress.skeleton.release(el)

Declarative

These three were built from markup alone on page load.

inline spinner
<div data-vpg="bar" data-vpg-value="65"
     data-vpg-label="Profile complete"
     data-vpg-show-value></div>

Sizes, colors & motion

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 })

API reference

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.

Usage

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')

Bars — Progress.bar(target, opts)

Options

NameTypeDefaultDescription
valuenumber0 Starting value, clamped to [0, max].
maxnumber100 The 100% mark (values ≤ 0 fall back to 100).
indeterminatebooleanfalse Sweeping animation for an unknown total; aria-valuenow is omitted while indeterminate.
labelstring'' Visible text above the bar — always plain text (textContent, never HTML). Also becomes the track's aria-label.
showValuebooleanfalse Rounded percentage ('42%') at the right of the label row.
sizestring'md' 'sm' | 'md' — track height 5px vs 8px.
colorstring'accent' 'accent' | 'success' fill color.
autoRemoveboolean | numberfalse After done(): true → remove the bar after 800 ms; a number → custom delay in ms.
stylesbooleantrue Shared: false = headless, no CSS injected.
themestring'auto' Shared: 'auto' | 'light' | 'dark' (pin globally via Progress.defaults.theme).

Handle

MemberDescription
elThe 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.

Spinners — 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.

Options

NameTypeDefaultDescription
sizenumber20 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.
spinnerSizenumber20 Alias of size in px; used when size is not a positive number.
labelstring'' Visually-hidden accessible text; falls back to Progress.defaults.labels.loading ('Loading…').
inlinebooleantrue false renders as a block (.vpg-block) instead of inline-flex.
styles / theme—— Shared options, as for bars.

Handle

MemberDescription
elThe root .vpg.vpg-spinner element.
remove()Removes the spinner. Idempotent.

Skeletons — 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.

Options

NameTypeDefaultDescription
linesnumber3 Number of placeholder lines.
avatarbooleanfalse Leading 40px circle.
headerbooleanfalse Taller first line (1.5 × height, class .vpg-head).
widthsarray | nullnull Per-line CSS widths, e.g. ['100%','85%','60%']. Default is a staggered pattern: 100%/85% alternating, with a short 60% last line.
heightnumber12 Line height in px.
styles / theme—— Shared options, as for bars.

Handle

MemberDescription
elThe 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.

Defaults, statics & helpers

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.

NameTypeDefaultDescription
stylesbooleantrue false = headless: no CSS is ever injected — style the .vpg-* markup yourself.
themestring'auto' 'auto' | 'light' | 'dark'. Pin globally with Progress.defaults.theme = 'dark'.
labelsobject{ loading: 'Loading…' } Fallback accessible text for bars without a label and for spinners (i18n).
StaticDescription
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.defaultsThe live shared defaults object (see above).
Progress.cssThe full stylesheet as a string (rendered with the current salt) — a starting point for headless styling.
Progress.saltCSS isolation token, default 'vc1'. Set your own token or false before the first render.
Progress.versionVersion string, '1.0.0'.

Declarative init

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>
AttributeApplies toMaps to
data-vpg-labelalllabel
data-vpg-themealltheme
data-vpg-value / data-vpg-maxbarvalue / max
data-vpg-sizebar, spinnersize ('sm'|'md' for bars; px → spinnerSize for spinners)
data-vpg-colorbarcolor
data-vpg-indeterminate / data-vpg-show-valuebarindeterminate / showValue (boolean; present = true, "false" = false)
data-vpg-inlinespinnerinline (boolean)
data-vpg-lines / data-vpg-heightskeletonlines / height
data-vpg-avatar / data-vpg-headerskeletonavatar / header (boolean)
data-vpg-widthsskeletonwidths (comma-separated CSS widths)

Theming

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

Accessibility

Headless mode

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.

Unstyled (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; }

Bring your own design (Tailwind)

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>