Skip to main content
astrocraft-ui/ components · 101

Progress

Free · MIT

Headless, 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

Install command
npx astrocraft-ui add display/progress

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add display/progress --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add display/progress --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/progress --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add display/progress --theme tailwind --bridge lumos

Live 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.

Storage — 7.2 GB of 10 GB7.2 / 10
Score — 8.4 of 108.4 / 10

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.

ComponentSlotsVariantsRuntime stateNative 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
---
// 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
---
// 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
// 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);
}
src/components/ui/display/progress/index.ts
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.