Donut Chart
Rounded share segments with a linked center readout and legend.
Preview
$115,150$$111155,,115500
Monthly revenue
Select a plan to filter
"use client";
import {
DonutChart,
DonutChartCenter,
DonutChartLegend,
DonutChartPlot,
type DonutSegment,
} from "@/components/charts/donut-chart";
/** Pass a handful of nonnegative parts; shares are computed from the total. */
export function DonutChartExample({ segments }: { segments: readonly DonutSegment[] }) {
return (
<DonutChart
segments={segments}
label="Monthly revenue by plan"
formatValue={(value) =>
value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
}
>
<div className="grid items-center gap-6 sm:grid-cols-[minmax(0,16rem)_1fr]">
<DonutChartPlot>
<DonutChartCenter totalLabel="Monthly revenue" />
</DonutChartPlot>
<DonutChartLegend />
</div>
</DonutChart>
);
}
"use client";
// beui.dev/charts/donut-chart
import { cn } from "@/lib/utils";
import { DonutChartCenter } from "./donut-chart/center";
import { type DonutChartProps, DonutContext, useDonutModel } from "./donut-chart/context";
import { DonutChartLegend } from "./donut-chart/legend";
import { DonutChartPlot } from "./donut-chart/plot";
/**
* A ring of rounded segments with a linked center readout and legend.
*
* ```tsx
* <DonutChart segments={segments} formatValue={usd}>
* <DonutChartPlot>
* <DonutChartCenter totalLabel="Monthly revenue" />
* </DonutChartPlot>
* <DonutChartLegend />
* </DonutChart>
* ```
*/
export function DonutChart({ className, children, ...props }: DonutChartProps) {
const model = useDonutModel(props);
return (
<DonutContext.Provider value={model}>
<div className={cn("w-full", className)}>
{children ?? (
<div className="grid items-center gap-6 sm:grid-cols-[minmax(0,16rem)_1fr]">
<DonutChartPlot>
<DonutChartCenter />
</DonutChartPlot>
<DonutChartLegend />
</div>
)}
</div>
</DonutContext.Provider>
);
}
export { DonutChartCenter, type DonutChartCenterProps } from "./donut-chart/center";
export { type DonutChartProps, useDonutChart } from "./donut-chart/context";
export { DonutChartLegend, type DonutChartLegendProps } from "./donut-chart/legend";
export type { DonutSegment, PlacedSegment as DonutPlacedSegment } from "./donut-chart/model";
export { DonutChartPlot, type DonutChartPlotProps } from "./donut-chart/plot";
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
"use client";
import { useEffect, useState } from "react";
/**
* Returns true only on devices that have a true hover (mouse / trackpad).
* Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere
* — gate hover-only effects (scale lifts, magnetic pulls) behind this.
*/
export function useHoverCapable() {
const [canHover, setCanHover] = useState(false);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setCanHover(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return canHover;
}
Copy the source code
"use client";
// beui.dev/charts/donut-chart
import { cn } from "@/lib/utils";
import { DonutChartCenter } from "./donut-chart/center";
import { type DonutChartProps, DonutContext, useDonutModel } from "./donut-chart/context";
import { DonutChartLegend } from "./donut-chart/legend";
import { DonutChartPlot } from "./donut-chart/plot";
/**
* A ring of rounded segments with a linked center readout and legend.
*
* ```tsx
* <DonutChart segments={segments} formatValue={usd}>
* <DonutChartPlot>
* <DonutChartCenter totalLabel="Monthly revenue" />
* </DonutChartPlot>
* <DonutChartLegend />
* </DonutChart>
* ```
*/
export function DonutChart({ className, children, ...props }: DonutChartProps) {
const model = useDonutModel(props);
return (
<DonutContext.Provider value={model}>
<div className={cn("w-full", className)}>
{children ?? (
<div className="grid items-center gap-6 sm:grid-cols-[minmax(0,16rem)_1fr]">
<DonutChartPlot>
<DonutChartCenter />
</DonutChartPlot>
<DonutChartLegend />
</div>
)}
</div>
</DonutContext.Provider>
);
}
export { DonutChartCenter, type DonutChartCenterProps } from "./donut-chart/center";
export { type DonutChartProps, useDonutChart } from "./donut-chart/context";
export { DonutChartLegend, type DonutChartLegendProps } from "./donut-chart/legend";
export type { DonutSegment, PlacedSegment as DonutPlacedSegment } from "./donut-chart/model";
export { DonutChartPlot, type DonutChartPlotProps } from "./donut-chart/plot";
"use client";
/* The value rolls digit by digit (up when it grows, down when it shrinks) in
* a stable container, and the caption slides through a clipped line, so
* moving between segments reads as one readout updating rather than a
* replaced block. Reduced motion: both swap with a short fade. */
import { AnimatePresence, motion } from "motion/react";
import { type ReactNode, useState } from "react";
import { DigitSwap } from "@/components/motion/digit-swap";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useDonutChart } from "./context";
import type { PlacedSegment } from "./model";
export type DonutChartCenterProps = {
/**
* Custom content for the hole. Receives the focused segment, or null when
* nothing is focused, and the total. Defaults to a rolling value and caption.
*/
children?: (segment: PlacedSegment | null, total: number) => ReactNode;
/** Caption under the total when nothing is focused. */
totalLabel?: ReactNode;
className?: string;
};
export function DonutChartCenter({ children, totalLabel = "Total", className }: DonutChartCenterProps) {
const { placed, active, total, formatValue, reduce } = useDonutChart();
const segment = placed.find((item) => item.id === active) ?? null;
const value = segment?.value ?? total;
// Roll digits in the direction the value moved, adjusted during render so
// the first frame of a change already rolls the right way.
const [previous, setPrevious] = useState(value);
const [direction, setDirection] = useState<"up" | "down">("up");
if (value !== previous) {
setPrevious(value);
setDirection(value > previous ? "up" : "down");
}
if (children) {
return (
<div aria-live="polite" className={cn("relative max-w-[56%]", className)}>
{children(segment, total)}
</div>
);
}
return (
<div aria-live="polite" className={cn("relative max-w-[56%]", className)}>
<p className="text-2xl font-medium tracking-tight tabular-nums">
<DigitSwap value={formatValue(value)} direction={direction} />
</p>
<div className="relative mt-1 h-4 overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.p
key={segment?.id ?? "total"}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: "100%" }}
animate={{ opacity: 1, y: "0%" }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: "-100%" }}
transition={{ duration: reduce ? 0.12 : 0.22, ease: EASE_OUT }}
className="flex items-center justify-center gap-1.5 whitespace-nowrap text-xs leading-4 text-muted-foreground"
>
{segment ? (
<>
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: segment.color }}
/>
<span className="truncate">
{segment.name} · {(segment.share * 100).toFixed(1)}%
</span>
</>
) : (
totalLabel
)}
</motion.p>
</AnimatePresence>
</div>
</div>
);
}
"use client";
import { useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useHoverCapable } from "@/lib/hooks/use-hover-capable";
import { buildDonut, type DonutSegment } from "./model";
export interface DonutChartProps {
segments: readonly DonutSegment[];
/** Ring thickness in viewBox units; the chart is 200 wide. */
thickness?: number;
/** Degrees of empty space between neighbouring segments. */
gap?: number;
/** Focused segment, shared by the ring, center, and legend. Controlled. */
activeId?: string | null;
defaultActiveId?: string | null;
onActiveChange?: (id: string | null) => void;
/** Called when a legend item is clicked. */
onSelect?: (id: string) => void;
/** Marks a legend item as pressed. */
selectedId?: string | null;
formatValue?: (value: number) => string;
/** Accessible name for the chart. */
label?: string;
className?: string;
children?: ReactNode;
}
const defaultFormat = (value: number) => value.toLocaleString("en-US");
export function useDonutModel({
segments,
thickness = 22,
gap = 3,
activeId,
defaultActiveId = null,
onActiveChange,
onSelect,
selectedId = null,
formatValue = defaultFormat,
label = "Donut chart",
}: DonutChartProps) {
const reduce = useReducedMotion() ?? false;
const canHover = useHoverCapable();
const radius = 100 - thickness / 2 - 8;
const { placed, total } = buildDonut(segments, { radius, thickness, gap });
const [internal, setInternal] = useState(defaultActiveId);
const raw = activeId === undefined ? internal : activeId;
// Resolve during render by id; a segment that left the data clears focus.
const active = raw !== null && placed.some((segment) => segment.id === raw) ? raw : null;
const latest = useRef({ controlled: activeId !== undefined, onActiveChange });
useLayoutEffect(() => {
latest.current = { controlled: activeId !== undefined, onActiveChange };
});
const setActive = useCallback((id: string | null) => {
if (!latest.current.controlled) setInternal(id);
latest.current.onActiveChange?.(id);
}, []);
return {
placed,
total,
radius,
thickness,
active,
setActive,
onSelect,
selectedId,
formatValue,
label,
reduce,
canHover,
};
}
export type DonutModel = ReturnType<typeof useDonutModel>;
export const DonutContext = createContext<DonutModel | null>(null);
export function useDonutChart() {
const model = useContext(DonutContext);
if (!model) throw new Error("Donut chart parts must be rendered inside DonutChart.");
return model;
}
"use client";
import { motion } from "motion/react";
import type { ReactNode } from "react";
import { SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useDonutChart } from "./context";
import type { PlacedSegment } from "./model";
export type DonutChartLegendProps = {
/** Extra line under each name, such as terms or a note. */
renderDetail?: (segment: PlacedSegment) => ReactNode;
/** Trailing value. Defaults to the share and formatted value. */
renderValue?: (segment: PlacedSegment) => ReactNode;
className?: string;
};
/**
* Legend rows share focus with the ring: hovering or focusing a row pops its
* segment, and arrow keys move between rows. Each row carries a share bar.
*/
export function DonutChartLegend({ renderDetail, renderValue, className }: DonutChartLegendProps) {
const { placed, active, setActive, onSelect, selectedId, formatValue, reduce, canHover } =
useDonutChart();
return (
<ul className={cn("space-y-1", className)}>
{placed.map((segment, index) => {
const focused = active === segment.id;
return (
<li key={segment.id}>
<button
type="button"
data-donut-legend
aria-pressed={onSelect ? selectedId === segment.id : undefined}
onClick={() => onSelect?.(segment.id)}
onPointerEnter={canHover ? () => setActive(segment.id) : undefined}
onPointerLeave={canHover ? () => setActive(null) : undefined}
onFocus={() => setActive(segment.id)}
onBlur={() => setActive(null)}
onKeyDown={(event) => {
const step = event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0;
if (!step) return;
event.preventDefault();
const rows = event.currentTarget
.closest("ul")
?.querySelectorAll<HTMLButtonElement>("[data-donut-legend]");
rows?.[(index + step + placed.length) % placed.length]?.focus();
}}
className={cn(
"w-full rounded-xl px-3 py-2 text-left outline-none transition-colors duration-150",
"focus-visible:ring-2 focus-visible:ring-ring",
focused ? "bg-muted" : "hover:bg-muted/60",
selectedId === segment.id && "ring-1 ring-border",
)}
>
<span className="flex items-center gap-3">
<span
aria-hidden="true"
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: segment.color }}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{segment.name}</span>
<span className="shrink-0 text-right text-sm tabular-nums">
{renderValue ? (
renderValue(segment)
) : (
<>
<span className="font-medium">{(segment.share * 100).toFixed(1)}%</span>
<span className="ml-2 text-muted-foreground">{formatValue(segment.value)}</span>
</>
)}
</span>
</span>
{renderDetail ? (
<span className="mt-0.5 block pl-5.5 text-xs text-muted-foreground">
{renderDetail(segment)}
</span>
) : null}
<span aria-hidden="true" className="mt-2 ml-5.5 block h-1 overflow-hidden rounded-full bg-muted">
<motion.span
className="block h-full origin-left rounded-full"
style={{ backgroundColor: segment.color }}
initial={reduce ? false : { scaleX: 0 }}
animate={{ scaleX: segment.share }}
transition={reduce ? { duration: 0 } : { ...SPRING_LAYOUT, delay: 0.2 + index * 0.06 }}
/>
</span>
</button>
</li>
);
})}
</ul>
);
}
export interface DonutSegment {
id: string;
name: string;
/** Any CSS color, such as "#2563eb" or "var(--chart-1)". */
color: string;
/** Nonnegative value. Zero and invalid values are left out of the ring. */
value: number;
}
export type PlacedSegment = DonutSegment & {
/** Share of the total, from 0 to 1. */
share: number;
/** Centerline arc path in the 200 × 200 viewBox. */
path: string;
};
export const DONUT_SIZE = 200;
const CENTER = DONUT_SIZE / 2;
const point = (radius: number, degrees: number) => {
const radians = (degrees * Math.PI) / 180;
return [CENTER + radius * Math.sin(radians), CENTER - radius * Math.cos(radians)];
};
/**
* Lays segments clockwise from 12 o'clock as round-capped arcs. Each arc is
* shortened by the gap plus its own cap, so neighbouring pills keep an even
* gap. Arcs too small for their caps collapse to a dot.
*/
export function buildDonut(
segments: readonly DonutSegment[],
{ radius, thickness, gap }: { radius: number; thickness: number; gap: number },
) {
const valid = segments.filter((segment) => Number.isFinite(segment.value) && segment.value > 0);
const total = valid.reduce((sum, segment) => sum + segment.value, 0);
const capDegrees = ((thickness / 2) / radius) * (180 / Math.PI);
const single = valid.length === 1;
let cursor = 0;
const placed: PlacedSegment[] = valid.map((segment) => {
const share = total ? segment.value / total : 0;
const sweep = share * 360;
const inset = single ? 0 : gap / 2 + capDegrees;
const start = cursor + inset;
const end = Math.max(start + 0.01, cursor + sweep - inset);
cursor += sweep;
let path: string;
if (single) {
// A full ring needs two half arcs; one arc cannot start and end together.
const [x1, y1] = point(radius, 0);
const [x2, y2] = point(radius, 180);
path = `M ${x1} ${y1} A ${radius} ${radius} 0 1 1 ${x2} ${y2} A ${radius} ${radius} 0 1 1 ${x1} ${y1}`;
} else {
const [x1, y1] = point(radius, start);
const [x2, y2] = point(radius, end);
path = `M ${x1} ${y1} A ${radius} ${radius} 0 ${end - start > 180 ? 1 : 0} 1 ${x2} ${y2}`;
}
return { ...segment, share, path };
});
return { placed, total };
}
"use client";
/* The ring sweeps in once it scrolls into view: it turns from -90deg and
* grows while segments fade in clockwise, 60ms apart. Hovering a segment, or
* focusing its legend item, pops it outward from the ring's centre and fades
* the rest. Everything animates transform and opacity. Hover is read from
* static hit arcs, so a popping segment never slides out from under the
* pointer and toggles itself. Reduced motion: the ring appears in place and
* emphasis changes without easing. */
import { motion, useInView } from "motion/react";
import { type ReactNode, useRef, useState } from "react";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { useDonutChart } from "./context";
import { DONUT_SIZE } from "./model";
const CENTER = DONUT_SIZE / 2;
/** Scale around the ring's centre rather than each arc's own box. */
const RING_ORIGIN = {
transformBox: "view-box",
transformOrigin: `${CENTER}px ${CENTER}px`,
} as const;
export type DonutChartPlotProps = {
/** Centered overlay, usually `DonutChartCenter`. */
children?: ReactNode;
className?: string;
};
export function DonutChartPlot({ children, className }: DonutChartPlotProps) {
const { placed, radius, thickness, active, setActive, label, reduce, canHover } =
useDonutChart();
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.4 });
const shown = reduce || inView;
// Stagger segments only for the first sweep, not when emphasis clears.
const [entered, setEntered] = useState(false);
return (
<div ref={ref} className={cn("relative mx-auto aspect-square w-full max-w-64", className)}>
<svg
viewBox={`0 0 ${DONUT_SIZE} ${DONUT_SIZE}`}
role="img"
aria-label={label}
className="size-full overflow-visible"
>
<circle
cx={CENTER}
cy={CENTER}
r={radius}
fill="none"
strokeWidth={thickness}
className="stroke-muted"
opacity={0.6}
/>
<motion.g
style={RING_ORIGIN}
initial={reduce ? false : { rotate: -90, scale: 0.88, opacity: 0 }}
animate={shown ? { rotate: 0, scale: 1, opacity: 1 } : undefined}
transition={{ duration: 0.9, ease: EASE_OUT }}
onAnimationComplete={() => setEntered(true)}
>
{placed.map((segment, index) => {
const focused = active === segment.id;
const dimmed = active !== null && !focused;
return (
<motion.path
key={segment.id}
d={segment.path}
fill="none"
stroke={segment.color}
strokeWidth={thickness}
strokeLinecap="round"
style={RING_ORIGIN}
initial={reduce ? false : { opacity: 0 }}
animate={
shown
? { opacity: dimmed ? 0.3 : 1, scale: focused ? 1.05 : 1 }
: undefined
}
transition={
reduce
? { duration: 0 }
: {
opacity: {
duration: 0.4,
ease: EASE_OUT,
delay: entered ? 0 : 0.15 + index * 0.06,
},
scale: SPRING_LAYOUT,
}
}
pointerEvents="none"
aria-hidden="true"
/>
);
})}
</motion.g>
{/* Static, slightly wider hit arcs own hover. They never scale, so the
pointer stays on the same segment while its visual pops. The svg's
img role already hides them; the legend carries keyboard access. */}
<g>
{placed.map((segment) => (
<path
key={segment.id}
d={segment.path}
fill="none"
stroke="transparent"
strokeWidth={thickness + 8}
strokeLinecap="round"
pointerEvents={shown ? "stroke" : "none"}
className="cursor-pointer"
onPointerEnter={canHover ? () => setActive(segment.id) : undefined}
onPointerLeave={canHover ? () => setActive(null) : undefined}
/>
))}
</g>
</svg>
{children ? (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-center">
{children}
</div>
) : null}
</div>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export type DigitSwapDirection = "up" | "down";
export interface DigitSwapProps {
/** Numeric or masked value rendered in fixed character slots. */
value: string | number;
/** Replays every glyph when the value itself contains unchanged characters. */
animationKey?: string | number;
/** Direction the next glyph enters from. */
direction?: DigitSwapDirection;
/** Per-glyph transition duration in seconds. */
duration?: number;
/** Delay in seconds between neighboring glyphs. */
stagger?: number;
/** Number of final characters that receive `suffixClassName`. */
suffixLength?: number;
className?: string;
glyphClassName?: string;
suffixClassName?: string;
}
type GlyphMotionContext = {
direction: DigitSwapDirection;
reduceMotion: boolean;
};
const GLYPH_VARIANTS = {
enter: ({ direction, reduceMotion }: GlyphMotionContext) => ({
opacity: 0,
transform: reduceMotion
? "none"
: `translateY(${direction === "up" ? "45%" : "-45%"})`,
}),
visible: {
opacity: 1,
transform: "translateY(0%)",
},
exit: ({ direction, reduceMotion }: GlyphMotionContext) => ({
opacity: 0,
transform: reduceMotion
? "none"
: `translateY(${direction === "up" ? "-45%" : "45%"})`,
}),
};
/**
* Fixed-slot digits and mask glyphs that roll when their value changes.
*
* Each slot holds an invisible in-flow copy of its character, so the slot has
* a real text baseline (it lines up with neighbouring text) and punctuation
* keeps its natural width. Digits use a fixed 1ch slot so rolls never jitter.
* Slots clip with `clip-path`, which, unlike `overflow: hidden`, leaves the
* baseline alone.
*/
export function DigitSwap({
value,
animationKey,
direction = "up",
duration = 0.18,
stagger = 0.006,
suffixLength = 0,
className,
glyphClassName,
suffixClassName,
}: DigitSwapProps) {
const reduceMotion = useReducedMotion() ?? false;
const text = String(value);
const suffixStart = Math.max(0, text.length - Math.max(0, suffixLength));
const motionContext: GlyphMotionContext = { direction, reduceMotion };
const glyphs = Array.from(text, (character, position) => ({
character,
id: `glyph-${position}`,
position,
}));
return (
<span
data-slot="digit-swap"
data-direction={direction}
className={cn("inline-flex items-baseline whitespace-nowrap", className)}
>
<span className="sr-only">{text}</span>
<span aria-hidden="true" className="inline-flex items-baseline">
{glyphs.map(({ character, id, position }) => {
if (character === " ") {
return <span key={id} className="inline-block w-[0.7ch]" />;
}
const glyphKey =
animationKey === undefined
? `${id}-${character}`
: `${id}-${character}-${animationKey}`;
return (
<span
key={id}
data-slot="digit-swap-glyph"
className={cn(
"relative inline-block h-[1.1em] shrink-0 leading-[1.1em] [clip-path:inset(0)]",
/\d/.test(character) && "w-[1ch] text-center",
)}
>
<span className="invisible">{character}</span>
<AnimatePresence initial={false} custom={motionContext}>
<motion.span
key={glyphKey}
custom={motionContext}
variants={GLYPH_VARIANTS}
initial="enter"
animate="visible"
exit="exit"
transition={{
duration: reduceMotion ? Math.min(duration, 0.12) : duration,
delay: reduceMotion ? 0 : position * stagger,
ease: EASE_OUT,
}}
className={cn(
"absolute inset-0 flex items-center justify-center leading-none",
glyphClassName,
position >= suffixStart ? suffixClassName : undefined,
)}
>
{character}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
</span>
);
}
API Reference
DonutChart
segmentsreadonly DonutSegment[]—thickness?numberRing thickness in viewBox units; the chart is 200 wide.
—gap?numberDegrees of empty space between neighbouring segments.
—activeId?string | nullFocused segment, shared by the ring, center, and legend. Controlled.
—defaultActiveId?string | null—onActiveChange?((id: string | null) => void)—onSelect?((id: string) => void)Called when a legend item is clicked.
—selectedId?string | nullMarks a legend item as pressed.
—formatValue?((value: number) => string)—label?stringAccessible name for the chart.
—className?string—DonutChartCenter
children?((segment: PlacedSegment | null, total: number) => ReactNode)Custom content for the hole. Receives the focused segment, or null when nothing is focused, and the total. Defaults to a rolling value and caption.
—totalLabel?ReactNodeCaption under the total when nothing is focused.
TotalclassName?string—DonutChartLegend
renderDetail?((segment: PlacedSegment) => ReactNode)Extra line under each name, such as terms or a note.
—renderValue?((segment: PlacedSegment) => ReactNode)Trailing value. Defaults to the share and formatted value.
—className?string—DonutChartPlot
children?ReactNodeCentered overlay, usually `DonutChartCenter`.
—className?string—Related components
Updated