radar-chart-02
Fine radar chart for dashboards: a thin blue listening-profile outline over a dashed polar grid, with labeled scores and a ruled caption.
pnpm dlx shadcn add @01orbital/radar-chart-02"use client"
import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from "recharts"
import { Eyebrow } from "@/components/eyebrow/eyebrow"
import {
Stat,
StatGroup,
StatValue,
} from "@/components/stats/stats"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import { Separator } from "@/components/ui/separator"
// The copy: change text here, not in the markup.
const COPY = {
title: "Listening profile",
description: "How October's listening splits by mood, out of 100",
} as const
const MOODS = [
{ mood: "Calm", score: 82 },
{ mood: "Focus", score: 74 },
{ mood: "Warm", score: 63 },
{ mood: "Bright", score: 38 },
{ mood: "Energy", score: 46 },
{ mood: "Dark", score: 55 },
]
const CHART_CONFIG = {
score: { label: "Score", color: "var(--art-accent)" },
} satisfies ChartConfig
// Each axis prints its score, so the shape is never the only record of it.
const SCORES = new Map(MOODS.map((entry) => [entry.mood, entry.score]))
export default function RadarChart02() {
return (
<Card className="mx-auto w-full max-w-[26rem] gap-8 [--card-spacing:24px]">
<CardHeader>
<CardTitle>
<h2>{COPY.title}</h2>
</CardTitle>
<CardDescription>{COPY.description}</CardDescription>
</CardHeader>
<CardContent className="grid min-w-0 gap-8">
<StatGroup cols={1} gap="none">
<Stat className="justify-items-center gap-1 text-center">
<StatValue className="text-4xl/10 tabular-nums">
{Math.round(
MOODS.reduce((sum, entry) => sum + entry.score, 0) /
MOODS.length
)}
</StatValue>
<Eyebrow variant="mono">avg / 100</Eyebrow>
</Stat>
</StatGroup>
<figure className="grid min-w-0 gap-3">
<ChartContainer
config={CHART_CONFIG}
className="mx-auto aspect-square w-full max-w-64"
>
<RadarChart
accessibilityLayer
data={MOODS}
outerRadius="58%"
margin={{ left: 24, right: 24 }}
>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<PolarGrid strokeDasharray="2 4" radialLines={false} />
{/* Stock ChartContainer's label color misses recharts 3's label layer. */}
<PolarAngleAxis
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
dataKey="mood"
tickFormatter={(mood: string) => `${mood} ${SCORES.get(mood)}`}
/>
<Radar
dataKey="score"
fill="var(--color-score)"
fillOpacity={0.04}
stroke="var(--color-score)"
strokeWidth={1.5}
dot={{ r: 2, fillOpacity: 1 }}
/>
</RadarChart>
</ChartContainer>
<Separator />
<figcaption className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<Eyebrow variant="mono" render={<span />}>
October
</Eyebrow>
<span className="text-sm/7 tabular-nums">{MOODS.length} moods</span>
</figcaption>
</figure>
</CardContent>
</Card>
)
}
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"
// default is a quiet 14px line, cased as written; icon puts a 16px icon in
// front of it; mono is a 12px uppercase mono label for spec-sheet designs;
// caps is the same size in a sans face. tone picks the ink: muted (the
// default) or the surrounding text color, for a label over art or on a dark
// island where muted-foreground is too quiet.
const eyebrowVariants = cva("", {
variants: {
/**
* The label style. `default` (the default) is a 14px line at weight 500,
* cased as written. `icon` is the same line with a 16px icon, placed
* first among the children, 12px before the words. `mono` is a 12px
* uppercase mono label at weight 500 with 0.12em letter spacing. `caps`
* is the same label in the sans face, for a brand and series label such
* as "Studio Neri / Clay school". The color comes from `tone`.
*/
variant: {
default: "text-sm/5 font-medium",
icon: "flex items-center gap-3 text-sm/5 font-medium [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
mono: "font-mono text-xs/5 font-medium tracking-[0.12em] uppercase",
caps: "text-xs/5 font-medium tracking-[0.12em] uppercase",
},
/**
* The ink. `muted` (the default) is `muted-foreground`. `ink` leaves the
* color to the surrounding text, for a label over art, on a dark island
* or anywhere the muted grey is too quiet to read.
*/
tone: {
muted: "text-muted-foreground",
ink: "",
},
},
defaultVariants: { variant: "default", tone: "muted" },
})
/**
* A short label set above a heading, such as a section name, rendered as a
* `<p>`. Leave 8px between it and the heading. Pass `render` to use another
* element.
*
* `default` is a 14px line at weight 500, cased as written. `icon` adds a
* 16px icon before the words: put the icon first, with `aria-hidden`. `mono`
* is a 12px uppercase mono label with 0.12em letter spacing; `caps` the same
* in the sans face, for a brand and series name such as "Studio Neri / Clay
* school". `tone="muted"` (the default) sets it in `muted-foreground`;
* `tone="ink"` takes the surrounding text color, for a label over art or on
* a dark island where the grey is too quiet.
*/
function Eyebrow({
className,
variant = "default",
tone = "muted",
render,
...props
}: useRender.ComponentProps<"p"> & VariantProps<typeof eyebrowVariants>) {
return useRender({
defaultTagName: "p",
render,
state: { slot: "eyebrow", variant, tone },
props: mergeProps<"p">(
{ className: cn(eyebrowVariants({ variant, tone }), className) },
props
),
})
}
export { Eyebrow, eyebrowVariants }
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: