Kosmesis
Components

Progress Circle

A circular progress ring with an indeterminate mode. Purely presentational — Morphos's Progress renders no children to nest an SVG ring inside.

Installation

npx kosmesis add progress-circle
pnpm dlx kosmesis add progress-circle
yarn dlx kosmesis add progress-circle
bunx kosmesis add progress-circle

Copy and paste the following code into your project.

progress-circle.tsx
import { StatefulComponent } from "@praxisjs/core";import { Component, Computed, Prop } from "@praxisjs/decorators";import { cn } from "@/lib/utils";export interface ProgressCircleProps {  value?: number;  max?: number;  min?: number;  size?: number;  strokeWidth?: number;  showLabel?: boolean;  class?: string;  id?: string;  "aria-label"?: string;}// Morphos's Progress renders no children, so an SVG ring can't nest inside it; percentage math is duplicated here instead of wrapping it.@Component()export class ProgressCircle extends StatefulComponent {  @Prop() value?: number;  @Prop() max = 100;  @Prop() min = 0;  @Prop() size = 80;  @Prop() strokeWidth = 8;  @Prop() showLabel = false;  @Prop() class?: string;  @Prop() id?: string;  @Prop() "aria-label"?: string;  get isIndeterminate(): boolean {    return this.value === undefined;  }  @Computed()  get percentage(): number | undefined {    if (this.value === undefined) return undefined;    return Math.round(((this.value - this.min) / (this.max - this.min)) * 100);  }  get radius(): number {    return (this.size - this.strokeWidth) / 2;  }  get circumference(): number {    return 2 * Math.PI * this.radius;  }  get dashOffset(): number {    if (this.isIndeterminate) return this.circumference * 0.75;    return this.circumference * (1 - (this.percentage ?? 0) / 100);  }  render() {    return (      <div        id={this.id}        role="progressbar"        aria-valuemin={() => this.min}        aria-valuemax={() => this.max}        aria-valuenow={() => (this.isIndeterminate ? undefined : this.value)}        aria-valuetext={() => (this.isIndeterminate ? undefined : `${String(this.percentage)}%`)}        aria-label={this["aria-label"]}        data-indeterminate={() => (this.isIndeterminate ? "" : undefined)}        class={cn("relative inline-flex items-center justify-center text-primary", this.class)}        style={() => ({ width: `${String(this.size)}px`, height: `${String(this.size)}px` })}      >        <svg          viewBox={() => `0 0 ${String(this.size)} ${String(this.size)}`}          class="-rotate-90 data-[indeterminate]:animate-spin"          data-indeterminate={() => (this.isIndeterminate ? "" : undefined)}        >          <circle            cx={() => this.size / 2}            cy={() => this.size / 2}            r={() => this.radius}            fill="none"            stroke="currentColor"            stroke-width={() => this.strokeWidth}            class="text-primary/20"          />          <circle            cx={() => this.size / 2}            cy={() => this.size / 2}            r={() => this.radius}            fill="none"            stroke="currentColor"            stroke-width={() => this.strokeWidth}            stroke-linecap="round"            stroke-dasharray={() => this.circumference}            stroke-dashoffset={() => this.dashOffset}            class="text-primary transition-[stroke-dashoffset] duration-300 ease-out"          />        </svg>        {this.showLabel && (          <span class="absolute text-sm font-medium tabular-nums">            {() => (this.isIndeterminate ? "" : `${String(this.percentage)}%`)}          </span>        )}      </div>    );  }}

Install the following dependencies:

npm install @praxisjs/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

Copy and paste the following code into your project.

progress-circle.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, keyframes, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Computed, Prop } from "@praxisjs/decorators";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);const spin = keyframes("kosmesis-progress-circle-spin", { from: { transform: "rotate(0deg)" }, to: { transform: "rotate(360deg)" } });class ProgressCircleStyles extends Stylesheet {  $root = this.css({ position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", color: t.primary });  $svg = this.css({ transform: "rotate(-90deg)" }).on("&[data-indeterminate]", { animation: `${spin} 1s linear infinite` });  $track = this.css({ color: `color-mix(in oklab, ${t.primary} 20%, transparent)` });  $fill = this.css({ color: t.primary, transition: "stroke-dashoffset 300ms ease-out" });  $label = this.css({    position: "absolute",    fontSize: "0.875rem",    fontWeight: 500,    fontVariantNumeric: "tabular-nums",    color: t.foreground,  });}export interface ProgressCircleProps {  value?: number;  max?: number;  min?: number;  size?: number;  strokeWidth?: number;  showLabel?: boolean;  class?: string;  id?: string;  "aria-label"?: string;}// Morphos's Progress renders no children, so an SVG ring can't nest inside it; percentage math is duplicated here instead of wrapping it.@Component()export class ProgressCircle extends StatefulComponent {  @Styled(ProgressCircleStyles) $s!: ProgressCircleStyles;  @Prop() value?: number;  @Prop() max = 100;  @Prop() min = 0;  @Prop() size = 80;  @Prop() strokeWidth = 8;  @Prop() showLabel = false;  @Prop() class?: string;  @Prop() id?: string;  @Prop() "aria-label"?: string;  get isIndeterminate(): boolean {    return this.value === undefined;  }  @Computed()  get percentage(): number | undefined {    if (this.value === undefined) return undefined;    return Math.round(((this.value - this.min) / (this.max - this.min)) * 100);  }  get radius(): number {    return (this.size - this.strokeWidth) / 2;  }  get circumference(): number {    return 2 * Math.PI * this.radius;  }  get dashOffset(): number {    if (this.isIndeterminate) return this.circumference * 0.75;    return this.circumference * (1 - (this.percentage ?? 0) / 100);  }  render() {    return (      <div        id={this.id}        role="progressbar"        aria-valuemin={() => this.min}        aria-valuemax={() => this.max}        aria-valuenow={() => (this.isIndeterminate ? undefined : this.value)}        aria-valuetext={() => (this.isIndeterminate ? undefined : `${String(this.percentage)}%`)}        aria-label={this["aria-label"]}        data-indeterminate={() => (this.isIndeterminate ? "" : undefined)}        class={cx(this.$s.$root, this.class)}        style={() => ({ width: `${String(this.size)}px`, height: `${String(this.size)}px` })}      >        <svg          viewBox={() => `0 0 ${String(this.size)} ${String(this.size)}`}          class={this.$s.$svg}          data-indeterminate={() => (this.isIndeterminate ? "" : undefined)}        >          <circle            cx={() => this.size / 2}            cy={() => this.size / 2}            r={() => this.radius}            fill="none"            stroke="currentColor"            stroke-width={() => this.strokeWidth}            class={this.$s.$track}          />          <circle            cx={() => this.size / 2}            cy={() => this.size / 2}            r={() => this.radius}            fill="none"            stroke="currentColor"            stroke-width={() => this.strokeWidth}            stroke-linecap="round"            stroke-dasharray={() => this.circumference}            stroke-dashoffset={() => this.dashOffset}            class={this.$s.$fill}          />        </svg>        {this.showLabel && (          <span class={this.$s.$label}>{() => (this.isIndeterminate ? "" : `${String(this.percentage)}%`)}</span>        )}      </div>    );  }}

Examples

About

Progress wraps Morphos's Progress primitive directly since it only needs a div for its fill. ProgressCircle needs an SVG ring nested inside the progressbar element, and Morphos's Progress renders no children at all — so the same value/min/max percentage math is duplicated here directly against a local role="progressbar" element instead.

Usage

import { ProgressCircle } from "@/components/ui/progress-circle";

<ProgressCircle value={65} showLabel />
<ProgressCircle /> {/* indeterminate — spins */}

Props

PropTypeDefault
valuenumber— (indeterminate when omitted)
maxnumber100
minnumber0
sizenumber80
strokeWidthnumber8
showLabelbooleanfalse
classstring

On this page