radial-chart-04
Compact radial progress ring for sprint task completion, beside completed and remaining task counts. Built on shadcn Card and Chart parts, with the task total setting the full circle on recharts 3.8.
pnpm dlx shadcn add @01orbital/radial-chart-04"use client"
import {
Label,
PolarAngleAxis,
PolarRadiusAxis,
RadialBar,
RadialBarChart,
} from "recharts"
import {
Stat,
StatGroup,
StatLabel,
StatValue,
} from "@/components/stats/stats"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
const COPY = {
title: "Sprint progress",
description: "Design system refresh",
complete: "Complete",
remaining: "Remaining",
} as const
const TASKS = { complete: 18, total: 24 }
const REMAINING = TASKS.total - TASKS.complete
const PERCENT = Math.round((TASKS.complete / TASKS.total) * 100)
const DATA = [{ complete: TASKS.complete, fill: "var(--color-complete)" }]
const CHART_CONFIG = {
complete: { label: COPY.complete, color: "var(--primary)" },
} satisfies ChartConfig
/** A compact sprint ring beside the completed and remaining task counts. */
export default function RadialChart04() {
return (
<Card className="@container/sprint mx-auto w-full max-w-sm">
<CardHeader>
<CardTitle>
<h2>{COPY.title}</h2>
</CardTitle>
<CardDescription>{COPY.description}</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 items-center gap-6 @[18rem]/sprint:grid-cols-2">
<ChartContainer
config={CHART_CONFIG}
role="img"
aria-label={`${TASKS.complete} of ${TASKS.total} tasks complete, ${PERCENT}%`}
className="aspect-square w-full max-w-40"
>
<RadialBarChart
accessibilityLayer={false}
data={DATA}
startAngle={90}
endAngle={-270}
innerRadius="76%"
outerRadius="100%"
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<PolarAngleAxis
type="number"
domain={[0, TASKS.total]}
tick={false}
/>
<RadialBar dataKey="complete" background cornerRadius={8} />
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (!viewBox || !("cx" in viewBox)) return null
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
fontSize={24}
className="fill-foreground font-medium tabular-nums"
>
{PERCENT}%
</text>
)
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
<StatGroup cols={1} gap="sm">
<Stat className="gap-1">
<StatLabel className="text-sm/5 text-muted-foreground">
{COPY.complete}
</StatLabel>
<StatValue className="text-2xl text-2xl/8 font-medium tabular-nums">
{TASKS.complete}
</StatValue>
</Stat>
<Stat className="gap-1">
<StatLabel className="text-sm/5 text-muted-foreground">
{COPY.remaining}
</StatLabel>
<StatValue className="text-2xl text-2xl/8 font-medium tabular-nums">
{REMAINING}
</StatValue>
</Stat>
</StatGroup>
</CardContent>
</Card>
)
}
import type { ComponentProps } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Grid, type GridProps } from "@/components/grid/grid"
/**
* A list of `Stat` items laid out by `Grid`, rendered as a `ul`. It defaults
* to 3 columns and the large gap. Like `Grid`, its columns follow the width
* it is given, so size its parent to narrow it. `className` lands on the
* list.
*/
function StatGroup({
cols = 3,
gap = "lg",
...props
}: ComponentProps<"ul"> & {
/**
* The most columns, 3 by default. They step up as the list's space
* widens, the way `Grid`'s do.
*/
cols?: GridProps["cols"]
/**
* Space between stats: `xs` 8px, `sm` 16px, `md` 24px, `lg` 40px, `xl` 64px, `px` 1px, `none` 0. `lg`
* is the default.
*/
gap?: GridProps["gap"]
}) {
return (
<Grid
data-slot="stat-group"
render={<ul role="list" />}
cols={cols}
gap={gap}
// Grid's props are typed for its default div; this group renders a list.
{...(props as Omit<GridProps, "cols" | "gap">)}
/>
)
}
// card sets the stat on a filled panel; rule sets a line down its left side.
const statVariants = cva("grid content-start gap-2", {
variants: {
/**
* The stat style. `default` (the default) adds nothing. `card` sets it
* on a `rounded-xl` panel with 32px padding, filled with `muted`, or
* with `card` and `card-foreground` text in dark mode. `rule` draws a
* 1px line in `border` down its left side, 24px from the text.
*/
variant: {
default: "",
card: "rounded-xl bg-muted p-8 dark:bg-card dark:text-card-foreground",
rule: "border-l border-border pl-6",
},
},
defaultVariants: { variant: "default" },
})
/**
* One figure and its label, as an `li` inside `StatGroup`. Holds a
* `StatValue` and a `StatLabel`, stacked with 8px gaps.
*
* `card` sets it on a filled, rounded panel with 32px padding. `rule` draws a
* line down its left side.
*/
function Stat({
className,
variant = "default",
...props
}: ComponentProps<"li"> & VariantProps<typeof statVariants>) {
return (
<li
data-slot="stat"
data-variant={variant}
className={cn(statVariants({ variant }), className)}
{...props}
/>
)
}
/** The figure itself, such as a number or a percentage, at 32px in the body font. */
function StatValue({ className, ...props }: ComponentProps<"p">) {
return (
<p
data-slot="stat-value"
className={cn("text-[2rem]/10 tracking-tight", className)}
{...props}
/>
)
}
/** The text that says what the figure measures. */
function StatLabel({ className, ...props }: ComponentProps<"p">) {
return (
<p
data-slot="stat-label"
className={cn("text-base/7 text-pretty", className)}
{...props}
/>
)
}
export { StatGroup, Stat, StatValue, StatLabel }
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const GRID_DEFAULTS = { cols: 3, gap: "lg" } as const
// Columns follow the screen, stepping at 40rem and 48rem (and 64rem for six). Gap sizes read the shared --gap-*
// values in globals.css.
const containerGridVariants = cva("grid grid-cols-1", {
variants: {
/**
* The most columns. Below 40rem of screen width the grid is one
* column (two for `6`); it steps up at 40rem and 48rem: `2` is two
* columns from 40rem, `3` two then three, `4` two then four, `5` three
* then five, and `6` three, then four, then six from 64rem. The default
* is 3.
*/
cols: {
1: "",
2: "sm:grid-cols-2",
3: "sm:grid-cols-2 md:grid-cols-3",
4: "sm:grid-cols-2 md:grid-cols-4",
5: "sm:grid-cols-3 md:grid-cols-5",
6: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6",
},
/**
* Space between items: `xs` 8px, `sm` 16px, `md` 24px, `lg` 40px, `xl` 64px, `px` 1px, `none` 0. `lg`
* is the default. The sizes are the `--gap-*` values in globals.css.
*/
gap: {
xs: "gap-(--gap-xs)",
sm: "gap-(--gap-sm)",
md: "gap-(--gap-md)",
lg: "gap-(--gap-lg)",
xl: "gap-(--gap-xl)",
px: "gap-px",
none: "gap-0",
},
},
defaultVariants: GRID_DEFAULTS,
})
type GridState = { slot: "grid" } & VariantProps<typeof containerGridVariants>
type GridProps = useRender.ComponentProps<"div", GridState> &
VariantProps<typeof containerGridVariants>
// className, render and every other prop go to the grid.
function Grid({
className,
cols = GRID_DEFAULTS.cols,
gap = GRID_DEFAULTS.gap,
render,
...props
}: GridProps) {
const grid = useRender({
defaultTagName: "div",
render,
state: { slot: "grid", cols, gap },
props: mergeProps<"div">(
{ className: cn(containerGridVariants({ cols, gap }), className) },
props
),
})
return grid
}
export { Grid, type GridProps }
Dependencies
npm: recharts@3.8.0. The CLI installs them for you.
Registry pieces it installs with it: