Progress Circle

Circular progress indicator.

Installation

$npx @309-thingspire/ui@latest add progress-circle

Usage

import { ProgressCircle } from "@/components/progress-circle/progress-circle"
<ProgressCircle />

Examples

Live preview rendered from ProgressCircle.preview.tsx. Switch to the Code tab to view the underlying component source.

Loading preview…
import React from 'react';
import { colors, shadows, spacing, typography } from '../../style-tokens';

import type { ProgressCircleColor, ProgressCircleInteractionState, ProgressCircleProps, ProgressCircleSize } from './ProgressCircle.types';

type SizeConfig = {
  circleSize: number;
  strokeWidth: number;
  labelWidth: number;
  showLabelByDefault: boolean;
};

const palette = colors.primitive.palette;
const textBase = colors.semantic.theme.text.base;

const PROGRESS_MIN = spacing.scale['0'];
const PROGRESS_MAX = spacing.scale['10'] * spacing.scale['10'];
const PROGRESS_DEFAULT = PROGRESS_MAX / spacing.scale['2'];

const SIZE_CONFIG: Record<ProgressCircleSize, SizeConfig> = {
  xs: {
    circleSize: spacing.scale['32'],
    strokeWidth: spacing.scale['4'],
    labelWidth: spacing.scale['0'],
    showLabelByDefault: false,
  },
  sm: {
    circleSize: spacing.scale['64'] + spacing.scale['4'],
    strokeWidth: spacing.scale['4'],
    labelWidth: spacing.scale['64'],
    showLabelByDefault: true,
  },
  md: {
    circleSize: spacing.scale['96'],
    strokeWidth: spacing.scale['8'],
    labelWidth: spacing.scale['64'],
    showLabelByDefault: true,
  },
  lg: {
    circleSize: spacing.scale['120'],
    strokeWidth: spacing.scale['8'],
    labelWidth: spacing.scale['64'],
    showLabelByDefault: true,
  },
};

const COLOR_TO_STROKE: Record<ProgressCircleColor, string> = {
  green: palette.green['8'],
  purple: palette.purple['8'],
  red: palette.red['8'],
};

function clampProgress(value: number): number {
  return Math.max(PROGRESS_MIN, Math.min(PROGRESS_MAX, value));
}

function withFocusRing(interactionState: ProgressCircleInteractionState): string {
  if (interactionState !== 'focus') {
    return 'none';
  }

  return shadows.focusRing.light.css;
}

export function ProgressCircle({
  size = 'md',
  color = 'green',
  interactionState = 'default',
  progressValue = PROGRESS_DEFAULT,
  showLabel,
  label,
  className,
  style,
  ...props
}: ProgressCircleProps) {
  const config = SIZE_CONFIG[size];
  const disabled = interactionState === 'disabled';
  const progress = clampProgress(progressValue);

  const radius = (config.circleSize - config.strokeWidth) / spacing.scale['2'];
  const circumference = spacing.scale['2'] * Math.PI * radius;
  const dashOffset = circumference - (progress / PROGRESS_MAX) * circumference;

  const trackColor = palette.gray['2'];
  const fillColor = disabled ? palette.gray['4'] : COLOR_TO_STROKE[color];
  const shouldShowLabel = typeof showLabel === 'boolean' ? showLabel : config.showLabelByDefault;
  const progressText = label ?? `${Math.round(progress)}%`;

  return (
    <div
      className={className}
      role="progressbar"
      aria-valuemin={PROGRESS_MIN}
      aria-valuemax={PROGRESS_MAX}
      aria-valuenow={Math.round(progress)}
      aria-label={progressText}
      style={{
        width: config.circleSize,
        height: config.circleSize,
        position: 'relative',
        display: 'inline-flex',
        alignItems: 'center',
        justifyContent: 'center',
        boxShadow: withFocusRing(interactionState),
        ...style,
      }}
      {...props}
    >
      <svg
        width={config.circleSize}
        height={config.circleSize}
        viewBox={`0 0 ${config.circleSize} ${config.circleSize}`}
        style={{
          display: 'block',
          transform: `rotate(-${spacing.scale['80'] + spacing.scale['10']}deg)`,
        }}
        aria-hidden="true"
      >
        <circle
          cx={config.circleSize / spacing.scale['2']}
          cy={config.circleSize / spacing.scale['2']}
          r={radius}
          fill="none"
          stroke={trackColor}
          strokeWidth={config.strokeWidth}
        />
        <circle
          cx={config.circleSize / spacing.scale['2']}
          cy={config.circleSize / spacing.scale['2']}
          r={radius}
          fill="none"
          stroke={fillColor}
          strokeWidth={config.strokeWidth}
          strokeLinecap={progress <= PROGRESS_MIN ? 'butt' : 'round'}
          strokeDasharray={circumference}
          strokeDashoffset={dashOffset}
          style={{
            transitionProperty: 'stroke-dashoffset, stroke',
            transitionDuration: `${spacing.scale['160']}ms`,
            transitionTimingFunction: 'linear',
          }}
        />
      </svg>

      {shouldShowLabel ? (
        <span
          style={{
            position: 'absolute',
            width: config.labelWidth,
            left: `calc(50% - ${config.labelWidth / spacing.scale['2']}px)`,
            top: `calc(50% - ${typography.scale.captionL.medium.lineHeight / spacing.scale['2']}px)`,
            color: disabled ? textBase.staticDarkQuaternary : textBase.staticDark,
            textAlign: 'center',
            whiteSpace: 'nowrap',
            ...{
              fontFamily: typography.scale.captionL.medium.fontFamily,
              fontSize: typography.scale.captionL.medium.fontSize,
              fontWeight: typography.scale.captionL.medium.fontWeight,
              lineHeight: `${typography.scale.captionL.medium.lineHeight}px`,
              letterSpacing: `${typography.scale.captionL.medium.letterSpacing}px`,
            },
          }}
        >
          {progressText}
        </span>
      ) : null}
    </div>
  );
}

API Reference

Props 문서 준비 중입니다. (component-spec.json 추가 시 표시됩니다.)