shadimage
dashboardcn
blockactivity-rings-cardsource: ok · refreshed

Activity Rings Card

A card with concentric goal rings and a tile per ring with its value. Hovering a tile or a ring focuses the pair.

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

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

Dependencies

Bundle weight · light

Registry dependencies

card

Files3 · source cached 10/8/2026, 10:33:04 AM

registry/dashboardcn/blocks/activity-rings-card.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { formatNumber, type NumberFormat } from "@/registry/dashboardcn/lib/format"
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ActivityRings, type ActivityRing } from "@/registry/dashboardcn/ui/activity-rings"

export interface ActivityRingsMetric extends ActivityRing {
  /** Text shown for the value, e.g. "1h 45m". Defaults to the formatted value plus `unit`. */
  display?: string
  format?: NumberFormat
  currency?: string
  /** Suffix after the number, e.g. "kcal" or "km". */
  unit?: string
}

export interface ActivityRingsCardProps
  extends Omit<React.ComponentProps<typeof Card>, "children"> {
  title: string
  /** Muted text under the title, e.g. the date. */
  caption?: string
  /** Outermost ring first. */
  metrics: ActivityRingsMetric[]
  /** "stacked" puts the tiles above the rings; "side" puts them next to the rings. */
  layout?: "stacked" | "side"
  /** Ring diameter in pixels. */
  size?: number
  thickness?: number
  gap?: number
  /** Content in the middle of the rings, e.g. a total. */
  center?: React.ReactNode
  /** Slot in the top-right corner, e.g. a menu button. */
  action?: React.ReactNode
}

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

function metricText(metric: ActivityRingsMetric) {
  if (metric.display) return metric.display
  const number = formatNumber(metric.value, {
    format: metric.format,
    currency: metric.currency,
    maximumFractionDigits: metric.format ? undefined : 1,
  })
  return metric.unit ? `${number} ${metric.unit}` : number
}

/** A card with concentric goal rings and a tile per ring. Hovering a tile or a ring focuses the pair. */
function ActivityRingsCard({
  title,
  caption,
  metrics,
  layout = "stacked",
  size = 180,
  thickness = 14,
  gap = 4,
  center,
  action,
  className,
  ...props
}: ActivityRingsCardProps) {
  const [active, setActive] = React.useState<number | null>(null)
  const rings = metrics.map((metric, index) => ({
    ...metric,
    color: metric.color ?? defaultColors[index % defaultColors.length],
  }))

  const tiles = (
    <ul
      className="grid gap-2"
      style={{
        gridTemplateColumns: `repeat(${layout === "side" ? 1 : Math.min(rings.length, 3)}, minmax(0, 1fr))`,
      }}
    >
      {rings.map((metric, index) => {
        const max = metric.max ?? 100
        const share = Math.min(1, Math.max(0, metric.value / (max || 1)))
        return (
          <li
            key={metric.label}
            data-active={active === index || undefined}
            onMouseEnter={() => setActive(index)}
            onMouseLeave={() => setActive(null)}
            className={cn(
              "flex min-w-0 flex-col gap-1 rounded-lg border px-3 py-2.5 transition-[opacity,border-color] duration-200",
              active !== null && active !== index && "opacity-50",
              active === index && "border-foreground/20"
            )}
          >
            <span className="text-muted-foreground flex items-center gap-1.5 text-xs">
              <span
                aria-hidden="true"
                className="size-2 shrink-0 rounded-full"
                style={{ backgroundColor: metric.color }}
              />
              <span className="truncate">{metric.label}</span>
            </span>
            <span className="truncate text-sm font-semibold tabular-nums">
              {metricText(metric)}
            </span>
            <span className="sr-only">{Math.round(share * 100)}% of goal</span>
          </li>
        )
      })}
    </ul>
  )

  return (
    <Card
      data-slot="activity-rings-card"
      className={cn("@container/card gap-4", className)}
      {...props}
    >
      <CardHeader>
        <CardTitle className="text-base">{title}</CardTitle>
        {caption ? (
          <span className="text-muted-foreground text-sm">{caption}</span>
        ) : null}
        {action ? <CardAction>{action}</CardAction> : null}
      </CardHeader>
      <CardContent
        className={cn(
          "flex gap-5",
          layout === "side"
            ? "flex-col items-center @sm/card:flex-row @sm/card:items-center @sm/card:justify-between"
            : "flex-col"
        )}
      >
        {layout === "side" ? (
          <>
            <ActivityRings
              rings={rings}
              size={size}
              thickness={thickness}
              gap={gap}
              activeIndex={active}
              onActiveIndexChange={setActive}
            >
              {center}
            </ActivityRings>
            <div className="w-full @sm/card:max-w-[14rem]">{tiles}</div>
          </>
        ) : (
          <>
            {tiles}
            <ActivityRings
              rings={rings}
              size={size}
              thickness={thickness}
              gap={gap}
              activeIndex={active}
              onActiveIndexChange={setActive}
              className="self-center"
            >
              {center}
            </ActivityRings>
          </>
        )}
      </CardContent>
    </Card>
  )
}

export { ActivityRingsCard }
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 }
registry/dashboardcn/lib/format.ts
export type NumberFormat = "number" | "compact" | "currency" | "percent"

export interface FormatNumberOptions {
  format?: NumberFormat
  /** ISO 4217 code, used when format is "currency". Defaults to USD. */
  currency?: string
  locale?: string
  maximumFractionDigits?: number
  /** Abbreviate large values, e.g. "$158K" instead of "$158,143". */
  compact?: boolean
}

const formatterCache = new Map<string, Intl.NumberFormat>()

function getFormatter(locale: string, options: Intl.NumberFormatOptions) {
  const key = locale + JSON.stringify(options)
  let formatter = formatterCache.get(key)
  if (!formatter) {
    formatter = new Intl.NumberFormat(locale, options)
    formatterCache.set(key, formatter)
  }
  return formatter
}

/**
 * Format a metric value for display.
 *
 * formatNumber(1234567)                          -> "1,234,567"
 * formatNumber(1234567, { format: "compact" })   -> "1.2M"
 * formatNumber(48.2, { format: "currency" })     -> "$48.20"
 * formatNumber(158143, { format: "currency", compact: true }) -> "$158K"
 * formatNumber(0.124, { format: "percent" })     -> "12.4%"
 */
export function formatNumber(
  value: number,
  {
    format = "number",
    currency = "USD",
    locale = "en-US",
    maximumFractionDigits,
    compact = false,
  }: FormatNumberOptions = {}
): string {
  if (!Number.isFinite(value)) return "—"

  // Compact values keep three significant digits: $158K, $1.23M, 41.2K.
  const compactOptions: Intl.NumberFormatOptions = compact
    ? maximumFractionDigits === undefined
      ? { notation: "compact", maximumSignificantDigits: 3 }
      : { notation: "compact", maximumFractionDigits }
    : {}
  switch (format) {
    case "compact":
      return getFormatter(locale, {
        notation: "compact",
        maximumFractionDigits: maximumFractionDigits ?? 1,
      }).format(value)
    case "currency":
      return getFormatter(locale, {
        style: "currency",
        currency,
        maximumFractionDigits: maximumFractionDigits ?? 2,
        ...compactOptions,
      }).format(value)
    case "percent":
      return getFormatter(locale, {
        style: "percent",
        maximumFractionDigits: maximumFractionDigits ?? 1,
      }).format(value)
    default:
      return getFormatter(locale, {
        maximumFractionDigits: maximumFractionDigits ?? 0,
        ...compactOptions,
      }).format(value)
  }
}

/**
 * Format a fractional change as a signed percentage.
 *
 * formatDelta(0.124)  -> "+12.4%"
 * formatDelta(-0.03)  -> "-3.0%"
 * formatDelta(0)      -> "0.0%"
 */
export function formatDelta(delta: number, locale = "en-US"): string {
  if (!Number.isFinite(delta)) return "—"
  return getFormatter(locale, {
    style: "percent",
    signDisplay: "exceptZero",
    maximumFractionDigits: 1,
    minimumFractionDigits: 1,
  }).format(delta)
}

/**
 * Fractional change between two values. Returns 0 when there is no baseline.
 */
export function computeDelta(current: number, previous: number): number {
  if (!previous) return 0
  return (current - previous) / Math.abs(previous)
}

More from dashboardcn

Browse all components in this library →