charts
Registry
Ring Metric
Donut with a total in the hole and a percent legend.
Preview
Preview
Members
12,123
Total
50.5%
49.5%
Transactions
1,942
Volume
51.5%
46.3%
2.2%
Loading
Loading
Loading
Installation
Install with the shadcn CLI. This copies source into your project.
bash
npx shadcn@latest add https://livedocs.xyz/r/ring-metric.jsonNamespace (after one-time setup)
npx shadcn@latest add @livedocs/ring-metricUsage
tsx
import { ringMembers, ringConfig } from "@/components/ui/chart"
import { RingMetric } from "@/components/ui/ring-metric"
export function Example() {
return (
<RingMetric
title="Members"
centerLabel="Total"
data={ringMembers}
config={ringConfig}
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| centerLabel | string | — | Caption under the total |
| data | RingSlice[] | — | Slices with key, label, value |
Source
"use client";
import * as React from "react";
import { Cell, Pie, PieChart as RechartsPieChart, ResponsiveContainer, type PieSectorShapeProps } from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
PixelSwatch,
colorVar,
useChart,
type ChartConfig,
} from "@/components/ui/chart";
import { Card } from "@/components/ui/card";
import { ChartInteractiveSector } from "@/components/ui/pie-chart";
import { ChartSkeleton, useChartReducedMotion, useChartReactions } from "@/components/ui/chart-reactions";
import { cn } from "@/lib/utils";
export type RingSlice = { key: string; label: string; value: number };
function RingMetricRoot({
title,
centerLabel,
data,
config,
className,
isLoading,
reaction,
}: {
title?: string;
centerLabel?: string;
data: RingSlice[];
config: ChartConfig;
className?: string;
isLoading?: boolean;
reaction?: import("@/components/ui/chart-reactions").ChartReactionOptions;
}) {
const total = data.reduce((sum, item) => sum + item.value, 0);
return (
<Card className={cn("@container/ring min-w-0 p-5", className)}>
<ChartSkeleton isLoading={isLoading}>
{title ? <p className="text-[15px] font-medium tracking-tight">{title}</p> : null}
<ChartContainer
isLoading={isLoading}
loadingVariant="ring"
reaction={reaction}
config={config}
data={data as unknown as Record<string, unknown>[]}
className="mt-2 min-h-64 w-full min-w-0 justify-center [&_.recharts-surface:focus:not(:focus-visible)]:outline-none"
variant="plain"
>
<RingBody data={data} total={total} centerLabel={centerLabel} />
</ChartContainer>
</ChartSkeleton>
</Card>
);
}
function RingBody({
data,
total,
centerLabel,
}: {
data: RingSlice[];
total: number;
centerLabel?: string;
}) {
const { selected, setSelected } = useChart();
const [focused, setFocused] = React.useState<string>();
const [hovered, setHovered] = React.useState<string>();
const activeKey = hovered ?? focused ?? selected;
const tooltipKey = hovered ?? focused;
const tooltipItem = data.find((item) => item.key === tooltipKey);
const reducedMotion = useChartReducedMotion();
const { animationsEnabled = true } = useChartReactions();
return (
<div className="grid min-w-0 items-center gap-6 @min-[26rem]/ring:grid-cols-[minmax(0,1fr)_minmax(10rem,1fr)]">
<div className="relative h-56 w-full min-w-0 max-w-56 justify-self-center">
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart>
<defs />
<ChartTooltip active={tooltipItem ? false : undefined} content={<ChartTooltipContent />} cursor={false} />
<Pie
data={data}
dataKey="value"
nameKey="key"
innerRadius={58}
outerRadius={84}
paddingAngle={2}
cornerRadius={0}
stroke="var(--background)"
rootTabIndex={-1}
isAnimationActive={animationsEnabled && !reducedMotion}
animationBegin={0}
animationDuration={600}
shape={(props: PieSectorShapeProps) => {
const item = data[props.index];
if (!item) return <g />;
return (
<ChartInteractiveSector
geometry={props}
label={`${item.label}: ${item.value.toLocaleString("en-US")}`}
selected={selected === item.key}
muted={Boolean(activeKey && activeKey !== item.key)}
emphasized={activeKey === item.key}
onActivate={() => setSelected(item.key)}
onHoverChange={(active) => setHovered(active ? item.key : undefined)}
onFocus={() => setFocused(item.key)}
onBlur={() => setFocused(undefined)}
/>
);
}}
>
{data.map((item) => (
<Cell
key={item.key}
fill={colorVar(item.key)}
/>
))}
</Pie>
</RechartsPieChart>
</ResponsiveContainer>
{tooltipItem ? (
<div role="tooltip" className="pointer-events-none absolute left-1/2 top-0 z-10 w-max max-w-full -translate-x-1/2 rounded-sm bg-[var(--chart-tooltip-background,var(--popover))] px-3.5 py-3 text-xs text-[var(--chart-tooltip-foreground,var(--popover-foreground))] shadow-lg">
<div className="flex items-center justify-between gap-6">
<span className="flex items-center gap-2 text-[var(--chart-tooltip-muted,var(--muted-foreground))]">
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: colorVar(tooltipItem.key) }} />
{tooltipItem.label}
</span>
<span className="font-mono font-bold tabular-nums">{tooltipItem.value.toLocaleString("en-US")}</span>
</div>
</div>
) : null}
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<p className="tabular-nums text-3xl font-medium tracking-tight">
{total.toLocaleString("en-US")}
</p>
{centerLabel ? (
<p className="text-xs text-muted-foreground">
{centerLabel}
</p>
) : null}
</div>
</div>
<ul className="space-y-2 text-sm">
{data.map((item) => {
const share = total ? (item.value / total) * 100 : 0;
const muted = activeKey && activeKey !== item.key;
return (
<li key={item.key}>
<button
type="button"
onClick={() => setSelected(item.key)}
aria-pressed={selected === item.key}
onMouseEnter={() => setHovered(item.key)}
onMouseLeave={() => setHovered(undefined)}
onFocus={() => setFocused(item.key)}
onBlur={() => setFocused(undefined)}
className={cn(
"flex w-full items-center justify-between gap-2 rounded-md text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
muted && "opacity-60"
)}
style={{ transition: animationsEnabled && !reducedMotion ? "opacity 220ms ease" : "none" }}
>
<span className="flex items-center gap-2 text-muted-foreground">
<PixelSwatch color={colorVar(item.key)} />
{item.label}
</span>
<span className="tabular-nums text-xs text-foreground">
{item.value.toLocaleString("en-US")}
</span>
</button>
<p className="pl-4 tabular-nums text-[10px] text-muted-foreground">
{share.toFixed(1)}%
</p>
</li>
);
})}
</ul>
</div>
);
}
export const RingMetric = Object.assign(RingMetricRoot, {});