radial-chart-02
Half-circle radial gauge for storage capacity on a dashboard, with used and available GB below it. Built on shadcn Card and Chart parts with a capacity-based axis on recharts 3.8.
pnpm dlx shadcn add @01orbital/radial-chart-02"use client"
import {
Label,
PolarAngleAxis,
PolarRadiusAxis,
RadialBar,
RadialBarChart,
} from "recharts"
import {
Stat,
StatGroup,
StatLabel,
StatValue,
} from "@/components/stats/stats"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
const COPY = {
title: "Storage",
description: "Workspace capacity",
used: "Used",
available: "Available",
} as const
// Capacity and usage are in GB; the axis uses the capacity as its full scale.
const STORAGE = { used: 68.4, capacity: 100 }
const AVAILABLE = Number((STORAGE.capacity - STORAGE.used).toFixed(1))
const PERCENT = Math.round((STORAGE.used / STORAGE.capacity) * 100)
const DATA = [{ used: STORAGE.used, fill: "var(--color-used)" }]
const CHART_CONFIG = {
used: { label: COPY.used, color: "var(--chart-2)" },
} satisfies ChartConfig
/** A half-circle storage gauge with used and available capacity below it. */
export default function RadialChart02() {
return (
<Card className="@container/storage mx-auto w-full max-w-xs">
<CardHeader>
<CardTitle>
<h2>{COPY.title}</h2>
</CardTitle>
<CardDescription>{COPY.description}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
config={CHART_CONFIG}
role="img"
aria-label={`${STORAGE.used} of ${STORAGE.capacity} GB used; ${AVAILABLE} GB available`}
className="mx-auto aspect-3/2 w-full max-w-72"
>
<RadialBarChart
accessibilityLayer={false}
data={DATA}
startAngle={180}
endAngle={0}
cy="85%"
// The enlarged radii fill a shallow frame rather than a full square.
innerRadius="115%"
outerRadius="150%"
margin={{ top: 6, right: 6, bottom: 6, left: 6 }}
>
<PolarAngleAxis
type="number"
domain={[0, STORAGE.capacity]}
tick={false}
/>
<RadialBar dataKey="used" background cornerRadius={10} />
<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">
<tspan
x={viewBox.cx}
y={(viewBox.cy ?? 0) - 16}
fontSize={36}
className="fill-foreground font-medium tabular-nums"
>
{PERCENT}%
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy ?? 0) + 8}
fontSize={12}
className="fill-muted-foreground"
>
{COPY.used.toLowerCase()}
</tspan>
</text>
)
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter>
<StatGroup
cols={1}
gap="sm"
className="w-full @[18rem]/storage:grid-cols-2"
>
<Stat className="gap-1">
<StatLabel className="text-sm/5 text-muted-foreground">
{COPY.used}
</StatLabel>
<StatValue className="text-sm/6 font-medium tabular-nums">
{STORAGE.used} / {STORAGE.capacity} GB
</StatValue>
</Stat>
<Stat className="gap-1 @[18rem]/storage:text-right">
<StatLabel className="text-sm/5 text-muted-foreground">
{COPY.available}
</StatLabel>
<StatValue className="text-sm/6 font-medium tabular-nums">
{AVAILABLE} GB
</StatValue>
</Stat>
</StatGroup>
</CardFooter>
</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: