UIPackage
Menu

Circular Progress

circular-progress ui
Edit on GitHub

Radial/circular progress indicator. Supports value (0-100), size presets or custom pixel size, stroke thickness, custom arc and track colors, indeterminate spinning mode, a label slot for center content, and a show-value prop that renders the percentage in the center.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/circular-progress.json
Named registry: npx shadcn@latest add @uipkge-react/circular-progress Installs to: components/ui/circular-progress/

Examples

Props

Name Type / Values Default Required
value

Progress value 0-100. Ignored when indeterminate is true.

number optional
size

Diameter in pixels.

'sm' | 'default' | 'lg' | number optional
thickness

Stroke thickness in pixels.

number optional
color

Progress arc color. Defaults to primary.

string optional
trackColor

Track (background ring) color.

string optional
indeterminate

Indeterminate spinning mode.

boolean optional
showValue

Show the numeric value in the center.

boolean optional
suffix

Suffix appended to the value (e.g. '%').

string optional
ariaLabel

Accessible label.

string optional

npm dependencies

Files installed (3)

  • components/ui/circular-progress/CircularProgress.tsx 4.7 kB
    import * as React from 'react'
    import { cn } from '@/lib/utils'
    import { circularProgressVariants } from './circular-progress.variants'
    
    export interface CircularProgressProps extends React.HTMLAttributes<HTMLDivElement> {
      /** Progress value 0-100. Ignored when indeterminate is true. */
      value?: number
      /** Diameter in pixels. */
      size?: 'sm' | 'default' | 'lg' | number
      /** Stroke thickness in pixels. */
      thickness?: number
      /** Progress arc color. Defaults to primary. */
      color?: string
      /** Track (background ring) color. */
      trackColor?: string
      /** Indeterminate spinning mode. */
      indeterminate?: boolean
      /** Show the numeric value in the center. */
      showValue?: boolean
      /** Suffix appended to the value (e.g. '%'). */
      suffix?: string
      /** Accessible label. */
      ariaLabel?: string
    }
    
    const sizePxMap = {
      sm: 40,
      default: 56,
      lg: 80,
    } as const
    
    const CircularProgress = React.forwardRef<HTMLDivElement, CircularProgressProps>(
      (
        {
          className,
          value = 0,
          size = 'default',
          thickness = 8,
          color,
          trackColor,
          indeterminate = false,
          showValue = false,
          suffix = '%',
          ariaLabel = 'Progress',
          children,
          ...props
        },
        ref,
      ) => {
        const sizePx = React.useMemo(() => {
          if (typeof size === 'number') return size
          return sizePxMap[size] ?? 56
        }, [size])
    
        const normalizedValue = React.useMemo(() => Math.min(100, Math.max(0, value)), [value])
    
        const radius = React.useMemo(() => (sizePx - thickness) / 2, [sizePx, thickness])
        const circumference = React.useMemo(() => 2 * Math.PI * radius, [radius])
        const strokeDashoffset = React.useMemo(() => {
          if (indeterminate) return circumference * 0.25
          return circumference * (1 - normalizedValue / 100)
        }, [indeterminate, circumference, normalizedValue])
    
        const resolvedColor = React.useMemo(() => color || 'var(--primary)', [color])
        const resolvedTrackColor = React.useMemo(() => trackColor || 'var(--muted)', [trackColor])
    
        const viewBox = React.useMemo(() => `0 0 ${sizePx} ${sizePx}`, [sizePx])
        const center = React.useMemo(() => sizePx / 2, [sizePx])
    
        const fontSize = React.useMemo(() => {
          const s = sizePx
          if (s <= 40) return 'text-xs'
          if (s <= 56) return 'text-sm'
          return 'text-base'
        }, [sizePx])
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="circular-progress"
            data-size={typeof size === 'string' ? size : 'custom'}
            data-indeterminate={indeterminate ? 'true' : 'false'}
            className={cn(circularProgressVariants(), className)}
            style={{ width: `${sizePx}px`, height: `${sizePx}px` }}
            role="progressbar"
            aria-valuemin={0}
            aria-valuemax={100}
            aria-valuenow={indeterminate ? undefined : normalizedValue}
            aria-label={ariaLabel}
            {...props}
          >
            <style>{`
    @media (prefers-reduced-motion: no-preference) {
      .animate-spin-circular {
        animation: spin-circular 1.4s linear infinite;
      }
    }
    
    @keyframes spin-circular {
      from {
        transform: rotate(0deg);
      }
      to {
        transform: rotate(360deg);
      }
    }
    `}</style>
            <svg width={sizePx} height={sizePx} viewBox={viewBox} className="block">
              {/* Track */}
              <circle
                cx={center}
                cy={center}
                r={radius}
                fill="none"
                stroke={resolvedTrackColor}
                strokeWidth={thickness}
              />
              {/* Progress arc */}
              <g
                transform={indeterminate ? undefined : `rotate(-90 ${center} ${center})`}
                className={indeterminate ? 'animate-spin-circular' : ''}
                style={indeterminate ? { transformBox: 'fill-box', transformOrigin: 'center' } : undefined}
              >
                <circle
                  cx={center}
                  cy={center}
                  r={radius}
                  fill="none"
                  stroke={resolvedColor}
                  strokeWidth={thickness}
                  strokeLinecap="round"
                  strokeDasharray={circumference}
                  strokeDashoffset={strokeDashoffset}
                  className={!indeterminate ? 'transition-[stroke-dashoffset] duration-500 ease-out' : ''}
                />
              </g>
            </svg>
    
            {showValue || children ? (
              <div className="absolute inset-0 flex items-center justify-center">
                {children ? (
                  children
                ) : (
                  <span className={cn('text-foreground font-medium tabular-nums', fontSize)}>
                    {Math.round(normalizedValue)}
                    {suffix}
                  </span>
                )}
              </div>
            ) : null}
          </div>
        )
      },
    )
    CircularProgress.displayName = 'CircularProgress'
    
    export { CircularProgress }
  • components/ui/circular-progress/circular-progress.variants.ts 0.6 kB
    import type { VariantProps } from 'class-variance-authority'
    import { cva } from 'class-variance-authority'
    
    /**
     * Variant definitions live in their own file (rather than the package
     * `index.ts`) so `CircularProgress.tsx` can `import { circularProgressVariants } from
     * './circular-progress.variants'` without creating a circular dependency through
     * the index. See card.variants.ts for the same pattern + the SSR symptom that
     * motivated the split.
     */
    export const circularProgressVariants = cva('relative inline-flex items-center justify-center')
    
    export type CircularProgressVariants = VariantProps<typeof circularProgressVariants>
  • components/ui/circular-progress/index.ts 0.2 kB
    export { CircularProgress, type CircularProgressProps } from './CircularProgress'
    export { circularProgressVariants, type CircularProgressVariants } from './circular-progress.variants'

Raw manifest: https://uipkge.dev/r/react/circular-progress.json