Progress
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add display/progressPlain-CSS theme — no build step
npx astrocraft-ui add display/progress --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add display/progress --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add display/progress --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add display/progress --theme tailwind --bridge lumosLive demo
Progress
CircularProgress & Meter
The ring is one dash the length of its own circumference, slid by stroke-dashoffset — no path maths, no clipping element. The meter is the native <meter>: a MEASUREMENT, not a task, and the only element that can say which end of its range is the good one. It ships with no appearance: none, for the same reason the Slider does — zeroing it erases the control in WebKit.
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `CircularProgress.astro` | `circular-progress` `circular-progress-indicator` `circular-progress-track` | `data-size`: `sm` · `md` · `lg` | — | — |
| `Progress.astro` | `progress` `progress-indicator` | `data-size`: `sm` · `md` · `lg` | — | — |
Source
What the command copies — 4 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/display/progress/CircularProgress.astro — headless primitive (see ../../README.md).
// The ring: a determinate progress indicator drawn as an arc instead of a bar. Same ARIA contract as
// Progress (`role="progressbar"` + `aria-value*`), same rule about what belongs to whom — the arc
// maths is the component, the colour and the size are the theme's.
//
// THE ARC IS ONE DASH. The ring is a circle whose `stroke-dasharray` is its own circumference, so it
// holds exactly one dash and one gap; sliding `stroke-dashoffset` from the full circumference to 0
// draws it from nothing to whole. No path maths, no arc flags, no second element to clip. The two
// numbers come from `arc.ts`, which is where the clamping lives and where it is checked.
//
// `rotate(-90)` puts 0% at twelve o'clock. Without it a ring starts at three o'clock, which reads as
// broken to everyone and is the single most common bug in a hand-rolled version.
//
// The stroke colours are PRESENTATION ATTRIBUTES (`stroke="currentColor"`), which is deliberate:
// they lose to any CSS rule a theme writes, so they are a fallback rather than an opinion — and
// without them an SVG with no theme draws nothing at all, which is worse than unstyled. The track's
// `stroke-opacity` is there for the same reason: two arcs in one colour are one arc.
//
// ponytail: determinate only, like Progress. An indeterminate ring needs a @keyframes, which is the
// theme's job; use Spinner for busy-without-progress.
import type { HTMLAttributes } from "astro/types";
import { circumference, dashOffset, toPercent } from "./arc";
type Props = HTMLAttributes<"svg"> & {
value?: number;
max?: number;
size?: "sm" | "md" | "lg";
/** Stroke width in viewBox units (the box is 100×100), so it scales with whatever size you give. */
thickness?: number;
/** The ring's accessible name. A progressbar with no name is announced as an unlabelled one. */
label?: string;
};
const {
value = 0,
max = 100,
size = "md",
thickness = 10,
label,
class: className,
...rest
} = Astro.props;
const pct = toPercent(value, max);
// Half the stroke sits outside the path, so the radius has to be inset by half the thickness or the
// ring is clipped by its own viewBox.
const radius = Math.max(0, 50 - thickness / 2);
const round = (n: number) => Number(n.toFixed(3));
---
<svg
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
aria-label={label}
viewBox="0 0 100 100"
class={className}
data-slot="circular-progress"
data-size={size}
data-value={round(pct)}
{...rest}
>
<circle
data-slot="circular-progress-track"
cx="50"
cy="50"
r={round(radius)}
fill="none"
stroke="currentColor"
stroke-opacity="0.2"
stroke-width={thickness}></circle>
<circle
data-slot="circular-progress-indicator"
cx="50"
cy="50"
r={round(radius)}
fill="none"
stroke="currentColor"
stroke-width={thickness}
stroke-linecap="round"
stroke-dasharray={round(circumference(radius))}
stroke-dashoffset={round(dashOffset(pct, radius))}
transform="rotate(-90 50 50)"></circle>
</svg>
---
// src/components/ui/display/progress/Progress.astro — headless primitive (see ../../README.md).
// Determinate progress bar (track + indicator), role="progressbar" with aria-value*. For an
// unknown/busy state use the Spinner primitive instead. The indicator's inline `width` is the
// value made visible — it is computed data, not styling, so it stays on the element.
// ponytail: determinate-only — an indeterminate state needs a @keyframes, which is the theme's job;
// use Spinner for busy-without-progress.
import type { HTMLAttributes } from "astro/types";
import { toPercent } from "./arc";
type Props = HTMLAttributes<"div"> & {
value?: number;
max?: number;
size?: "sm" | "md" | "lg";
/**
* The bar's accessible name. A progressbar with no name is announced as an unlabelled one — the
* same note CircularProgress carries, which had this prop while this one did not. Pass
* `aria-labelledby` instead when a visible label already says it; `pnpm acceptance` fails on a
* progressbar with neither.
*/
label?: string;
};
const { label, value = 0, max = 100, size = "md", class: className, ...rest } = Astro.props;
const pct = toPercent(value, max);
---
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
class={className}
data-slot="progress"
data-size={size}
{...rest}
>
<div style={`width: ${pct}%`} data-slot="progress-indicator"></div>
</div>
// src/components/ui/display/progress/arc.ts — the value→geometry maths behind Progress and CircularProgress,
// in a plain module so it is unit-checkable (see arc.test.ts) without a DOM or a browser.
//
// Both primitives turn a `value` / `max` pair into a number that is DATA, not styling: a bar's width
// and an arc's dash offset are the value made visible, which is why they stay inline on the element
// rather than moving to a stylesheet.
/**
* Clamp a value to a percentage of `max`, for a progress indicator.
*
* Everything hostile is folded in here rather than at each call site: a `max` of 0 (a list that has
* not loaded yet) would divide by zero, a value past `max` would over-fill the bar, and a NaN from
* an unparsed attribute would put `width: NaN%` in the markup — which the browser drops silently,
* leaving a bar that is simply always empty.
*
* @param value - the current amount
* @param max - the amount that counts as complete
* @returns a number in 0…100
* @example toPercent(3, 4) // => 75
*/
export function toPercent(value: number, max: number): number {
if (!Number.isFinite(value) || !Number.isFinite(max) || max <= 0) return 0;
return Math.max(0, Math.min(100, (value / max) * 100));
}
/**
* The circumference of the indicator circle — the value an SVG ring uses as its `stroke-dasharray`,
* so that exactly one dash wraps the whole ring.
*
* @param radius - the circle's radius in user units
*/
export function circumference(radius: number): number {
return 2 * Math.PI * Math.max(0, radius);
}
/**
* How much of that single dash to hold back — `stroke-dashoffset`. A full offset hides the ring
* entirely (0%), a zero offset draws all of it (100%).
*
* @param percent - filled percentage, 0…100 (pass the output of {@link toPercent})
* @param radius - the circle's radius in user units
* @example dashOffset(100, 10) // => 0, the whole ring drawn
*/
export function dashOffset(percent: number, radius: number): number {
return circumference(radius) * (1 - Math.max(0, Math.min(100, percent)) / 100);
}
import CircularProgress from "./CircularProgress.astro";
import Progress from "./Progress.astro";
export { circumference, dashOffset, toPercent } from "./arc";
export { CircularProgress, Progress };
export default Progress;
What you get
The component source, copied into your project by npx astrocraft-ui add display/progress — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.