shadimage
dashboardcn
uiactivity-ringssource: ok · hit

Activity Rings

Concentric progress rings, one per goal, in the style of a fitness watch.

npx shadcn add "https://dashboardcn.com/r/activity-rings.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/15/2026, 3:14:17 AM

registry/dashboardcn/ui/activity-rings.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface ActivityRing {
  label: string
  /** Progress toward `max`. Values past `max` draw a full ring. */
  value: number
  /** Goal for the ring. Defaults to 100. */
  max?: number
  /** Any CSS color. Defaults to chart-1 through chart-5 in order. */
  color?: string
}

export interface ActivityRingsProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Outermost ring first. */
  rings: ActivityRing[]
  /** Diameter in pixels. */
  size?: number
  /** Stroke width of each ring in pixels. */
  thickness?: number
  /** Space between rings in pixels. */
  gap?: number
  /** Opacity of the unfilled track, drawn in the ring's color. */
  trackOpacity?: number
  /** Ring drawn at full strength while the rest dim. `null` shows every ring. */
  activeIndex?: number | null
  onActiveIndexChange?: (index: number | null) => void
  /** Content rendered in the middle. */
  children?: React.ReactNode
}

const defaultColors = [
  "var(--chart-1)",
  "var(--chart-2)",
  "var(--chart-3)",
  "var(--chart-4)",
  "var(--chart-5)",
]

// Round so server and client render identical attributes.
const round = (n: number) => Math.round(n * 1000) / 1000

/** Concentric progress rings, one per goal, in the style of a fitness watch. */
function ActivityRings({
  rings,
  size = 160,
  thickness = 12,
  gap = 4,
  trackOpacity = 0.2,
  activeIndex: activeProp,
  onActiveIndexChange,
  className,
  children,
  ...props
}: ActivityRingsProps) {
  const [activeState, setActiveState] = React.useState<number | null>(null)
  const active = activeProp === undefined ? activeState : activeProp
  const setActive = (index: number | null) => {
    setActiveState(index)
    onActiveIndexChange?.(index)
  }

  const center = size / 2
  const label = rings
    .map((ring) => {
      const max = ring.max ?? 100
      return `${ring.label} ${Math.round((ring.value / (max || 1)) * 100)}%`
    })
    .join(", ")

  return (
    <div
      data-slot="activity-rings"
      className={cn("relative inline-flex shrink-0", className)}
      style={{ width: size, height: size }}
      {...props}
    >
      <svg
        width={size}
        height={size}
        viewBox={`0 0 ${size} ${size}`}
        role="img"
        aria-label={label}
        className="absolute inset-0 -rotate-90"
      >
        {rings.map((ring, index) => {
          const max = ring.max ?? 100
          const fraction = Math.min(1, Math.max(0, ring.value / (max || 1)))
          const r = center - thickness / 2 - index * (thickness + gap)
          if (r <= 0) return null
          const color = ring.color ?? defaultColors[index % defaultColors.length]
          const dimmed = active !== null && active !== index
          return (
            <g
              key={ring.label}
              data-ring=""
              data-active={active === index || undefined}
              onMouseEnter={() => setActive(index)}
              onMouseLeave={() => setActive(null)}
              className="transition-opacity duration-200"
              style={{ opacity: dimmed ? 0.35 : 1 }}
            >
              <circle
                cx={center}
                cy={center}
                r={round(r)}
                fill="none"
                stroke={color}
                strokeOpacity={trackOpacity}
                strokeWidth={thickness}
              />
              {fraction > 0 ? (
                <circle
                  cx={center}
                  cy={center}
                  r={round(r)}
                  fill="none"
                  stroke={color}
                  strokeWidth={thickness}
                  strokeLinecap="round"
                  pathLength={1}
                  strokeDasharray="1 1"
                  strokeDashoffset={round(1 - fraction)}
                  className="transition-[stroke-dashoffset] duration-700 ease-out"
                />
              ) : null}
            </g>
          )
        })}
      </svg>
      {children ? (
        <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
          {children}
        </div>
      ) : null}
    </div>
  )
}

export { ActivityRings }

More from dashboardcn

Browse all components in this library →