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 · lightRegistry 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)
}