Radial Chart

Composable radial charts — 24-hour clocks, categories and time series around a circle or a partial arc, plus angular ring bars — with dot bar, stacked bar, ring bar, line and area layers

Preview
Merged23,166
Ready for merge827
Draft1,860

Installation

pnpm dlx shadcn@latest add @bklit/radial-chart

Usage

RadialChart is the shell: it measures itself, owns the shared geometry, value scale, hover-by-angle, legend hover and the enter flag. Layers draw into it and read everything from context.

import {
  RadialAxis,
  RadialCenter,
  RadialChart,
  RadialDotBar,
  RadialLegend,
  RadialScrubber,
} from "@bklitui/ui/charts";

const series = [
  { dataKey: "merged", label: "Merged", color: "var(--chart-1)" },
  { dataKey: "ready", label: "Ready for merge", color: "var(--chart-2)" },
  { dataKey: "draft", label: "Draft", color: "var(--chart-3)", emphasize: true },
];

// 96 × 15-minute bins, clockwise from 00:00. Bins after "now" have null counts.
const chartData = [
  { time: "00:00", merged: 41, ready: 0, draft: 0, yesterday: 52 },
  // …
  { time: "14:30", merged: null, ready: null, draft: null, yesterday: 917 },
];

export function PullRequestClock() {
  return (
    <RadialChart data={chartData} xDataKey="time" series={series}>
      <RadialAxis />
      <RadialDotBar ghostDataKey="yesterday" />
      <RadialScrubber />
      <RadialCenter />
      <RadialLegend />
    </RadialChart>
  );
}

Children are routed by component: SVG layers (RadialAxis, RadialDotBar, RadialBar, RadialRingBar, RadialLine, RadialArea, RadialScrubber) render in a group centred on the chart in the order given, RadialCenter renders as HTML in the hole and RadialLegend below the chart. Place RadialCenter / RadialLegend as direct children so they are routed correctly.

Series colors default to the palette slot for their index — var(--chart-1) … var(--chart-5) (see radialSeriesColor).

Angle, orientation and arcs

Three props decide what the circle encodes.

angleType — what the angle means:

ValueBins are…Axis labelsTracks "now"
"clock"time slots of one day (e.g. 24 hours, 96 × 15 minutes)00 / 06 / 12 / 18yes
"band"categories (datum[xDataKey] is the category)every category, rotated radiallyno
"time"evenly spaced dates (datum[xDataKey] is a Date / ms / ISO string)UTC time ticks (e.g. months)no

When the chart tracks "now" (trackNow, on for clock charts), bins where no series has a value are future bins: dot / bar / area layers draw comparison data there from ghostDataKey, totals skip them, and the scrubber rests on the boundary.

orientation — "radial" (default) puts bins around the angle with the value running outward. "angular" turns each bin into a concentric ring (first bin innermost) whose value sweeps along the angle — progress rings and gauges. Draw angular charts with RadialRingBar.

startAngle / endAngle — the arc in degrees, 0 = 12 o'clock, clockwise. The default is the full turn. Partial arcs (e.g. -90 → 90 for a semicircle, -120 → 120 for a horseshoe) are cropped to their bounding box with fitArc, so a semicircle is about twice as wide as it is tall.

Value scale — by default every layer scales itself. Pass maxValue to share one scale (and to give RadialAxis value rings): a number, "auto" (largest single value of any series / valueKeys key) or "stacked" (largest per-bin sum). minValue maps a value to the band base ("auto" = smallest value, useful for temperatures). Automatic domains are rounded to nice ticks unless niceValues={false}. Angular charts always share a scale (default "stacked").

Components

RadialChart

PropTypeDefaultDescription
dataRecord<string, unknown>[]requiredOne datum per bin, clockwise from the start angle
seriesRadialSeries[]requiredSeries, innermost / first stacked first
xDataKeystring"time"Bin label / category / time key
angleType"clock" | "band" | "time""clock"What the angle encodes
orientation"radial" | "angular""radial"Bins along the angle, or as concentric rings
startAnglenumber0Arc start in degrees (0 = 12 o'clock, clockwise)
endAnglenumberstartAngle + 360Arc end in degrees
fitArcbooleantrueCrop the stage to a partial arc's bounding box
trackNowbooleantrue for clockTreat bins after the last bin with data as future (ghost) bins
nowIndexnumberlast bin with dataBin the resting scrubber points at
maxValuenumber | "auto" | "stacked"unset ("stacked" when angular)Shared value scale for layers without their own maxValue
minValuenumber | "auto"0Value mapped to the band base
valueKeysstring[]-Extra datum keys (not series) included in the auto domain
niceValuesbooleantrueRound an automatic domain to nice tick values
ringGapnumber0.3Angular charts: gap between rings as a fraction of a ring step
layoutPartial<RadialLayout>defaultRadialLayoutRing radii as fractions of the chart radius (see below)
animatebooleantrueEnter animations (skipped for prefers-reduced-motion)
hoveredIndexnumber | null-Controlled hovered bin
onHoverChange(index: number | null) => void-Hover callback
maxWidthnumber640Max width in px
classNamestring-Wrapper class

RadialSeries

FieldTypeDefaultDescription
dataKeystringrequiredDatum key holding the series value
labelstringdataKeyLegend / center label
colorstringvar(--chart-N)Fill / stroke color (palette slot for the series index)
opacitynumber1Base opacity of the series' marks
rampFromnumber1Opacity at the band base, rising to full outward
emphasizebooleanfalseTint the legend total with the series color

RadialLayout (fractions of the chart radius): center 0.37 (hole), tickInner 0.38, tickMinorOuter 0.393, tickMajorOuter 0.405, bandInner 0.408 (value base), bandOuter 0.83 (value top), scrubberOuter 0.895, axisLabel 0.955.

RadialAxis

Axis chrome: center circle, tick ring, angle labels (clock hours, rotated categories or time ticks), optional value grid rings with labels and, on angular charts, value ticks along the arc with a label at the start of each ring. Render it after the data layers when you want value rings and labels on top. Grid rings and ticks animate in on enter with the same timing as the radar chart grid (shared constants in animation.ts), and are skipped for prefers-reduced-motion.

PropTypeDefaultDescription
showCirclebooleantrueCenter circle with a thin stroke
centerGradientbooleanfalseFaint radial-gradient fill inside the center circle (stroke kept)
tickCountnumber24 for clock charts, else 0Ticks just outside the center circle
majorTickEverynumber6Every n-th tick is long
labelsstring[] | falsedepends on angleTypeLabels spaced evenly from the start angle, or false to hide
labelColorstringvar(--muted-foreground)Label color
strokestringvar(--foreground)Circle / tick / grid color
labelOrientation"horizontal" | "radial" | "tangential""radial" for band, else "horizontal"Label rotation
labelRadiusnumberlayout.axisLabelLabel radius (fraction of the chart radius)
labelEverynumber1Band charts: label every n-th category
labelFormat(value, index) => stringString(value)Band charts: format datum[xDataKey]
timeTicksnumber | (Date | number)[]12Time charts: tick count hint or explicit tick times
timeTickFormat(date: Date) => stringshort month (UTC) for spans over 60 daysTime charts: tick label format
timeLabelPosition"tick" | "between""between"Time charts: label at the tick or centred between ticks
angleGridbooleantrue for time and angular chartsSpokes across the value band at every angle tick
valueTicksnumber | number[]none (radial), 5 (angular)Value grid: tick count hint or explicit values
valueTickFormat(value: number) => stringen-US numberValue tick label format
showValueLabelsbooleantrueShow value tick labels
valueLabelAnglenumberthe start angleRadial charts: angle (degrees) of the value labels
showRingLabelsbooleantrueAngular charts: category label at the start of each ring
gridOpacitynumber0.12Grid ring / spoke opacity

RadialDotBar

Each bin is a stacked column of dots on concentric rows, innermost series first. A radial scale keeps the dot count proportional to the value. layout="grid" keeps dot density even (outer rows hold more dots); "columns" lines dots up in strict radial columns. Dots cascade outward around the clock on enter. Fill dots with patterns via patterns — see Patterned dots.

PropTypeDefaultDescription
ghostDataKeystring-Comparison value drawn as ghost dots in future bins
ghostColorstringvar(--foreground)Ghost dot color
ghostOpacitynumber0.22Ghost dot opacity
ghostDotSizenumber0.26Ghost dot diameter as a fraction of the row spacing
maxValuenumberlargest stack / ghost valueValue mapped to the outermost row
rowsnumber14Concentric rows across the value band
layout"grid" | "columns""grid"Even dot density or strict radial columns
dotSizenumber0.44Dot diameter as a fraction of the row spacing
rampExtentnumber0.5Fraction of rows over which rampFrom series reach full opacity
annotations{ index, label, color? }[]-Callout labels outside a bin's tip
fadedOpacitynumber0.2Opacity of other series while a legend item is hovered
patternsRecord<string, PatternPresetId | RadialDotPattern>-Pattern fills keyed by series dataKey ("ghost" for ghost dots)

RadialBar

Stacked arc bars, one per bin, innermost series first. It uses a radial scale, so segment area tracks the value. Works on clock, band and time charts and on partial arcs. Clock charts draw a dim ghost bar from ghostDataKey in future bins.

<RadialChart data={chartData} xDataKey="time" series={series}>
  <RadialAxis />
  <RadialBar ghostDataKey="yesterday" cornerRadius={4} />
  <RadialScrubber />
  <RadialCenter />
  <RadialLegend />
</RadialChart>
PropTypeDefaultDescription
ghostDataKeystring-Comparison value drawn as a dim ghost bar in future bins
ghostColorstringvar(--foreground)Ghost bar color
ghostOpacitynumber0.12Ghost bar opacity
maxValuenumberchart scale, else largest stack / ghostValue mapped to the band top
padAnglenumber18% of a bin's angleAngular gap between bars in radians, constant width outward
cornerRadiusnumber0Corner radius in px
stackGapnumber0Radial gap in px between segments; above 0 every segment is rounded on its own
rampExtentnumber0.6Band fraction over which rampFrom series reach full opacity
fadedOpacitynumber0.35Opacity of other bins while a bin is hovered
seriesFadedOpacitynumber0.2Opacity of other series while a legend item is hovered

RadialRingBar

The layer for orientation="angular" charts: each bin is a ring (first bin innermost) and its series stack end to end along the arc, over a faint full-arc track. Rings sweep in with a stagger on enter; hovering a ring dims the others. See Angular rings and partial arcs.

PropTypeDefaultDescription
maxValuenumberthe chart's value domainValue mapped to the arc end
cornerRadiusnumber | "round"0Corner radius in px, or "round" for fully rounded ends
segmentGapnumber0Gap in px between stacked segments, constant across the ring's width
trackbooleantrueFaint full-arc track behind every ring
trackColorstringvar(--foreground)Track color
trackOpacitynumber0.06Track opacity
fadedOpacitynumber0.35Opacity of other rings while a ring is hovered
seriesFadedOpacitynumber0.2Opacity of other series while a legend item is hovered

RadialLine

A smooth line through the bin centers, drawn clockwise on enter. When dataKey matches a chart series the line inherits its color and dims with the legend; any other key (e.g. "yesterday") draws a comparison line. An open line ends in a pulsing "now" marker; a line with a value in every bin of a full circle closes the loop. Without a chart maxValue, pass the same maxValue to every line so they share a scale.

The hover marker is locked to the crosshair: it moves at the same animated angle as RadialScrubber (or a matching spring when there's no scrubber) and its radius follows the curve at that angle, so it slides along the line as the crosshair springs — every line, e.g. today and yesterday, gets its own dot, hidden over gaps. markerMotion="spring" gives the marker its own spring (markerSpring) and "none" snaps it to the bin. RadialArea's activeDot works the same way (activeDotMotion, activeDotSpring).

<RadialChart data={chartData} xDataKey="time" series={[{ dataKey: "merged", label: "Merged today" }]}>
  <RadialAxis />
  <RadialLine dataKey="yesterday" dash stroke="var(--muted-foreground)" maxValue={maxValue} />
  <RadialLine dataKey="merged" strokeWidth={2.25} maxValue={maxValue} />
  <RadialScrubber variant="gradient" />
  <RadialCenter />
  <RadialLegend />
</RadialChart>
PropTypeDefaultDescription
dataKeystringrequiredDatum key plotted by this line
strokestringseries color, else var(--foreground)Stroke color
strokeWidthnumber2Stroke width in px
strokeOpacitynumberseries opacity, else 1Stroke opacity
dashboolean | stringsolidtrue for dashes with 3× gaps, or any stroke-dasharray
curveCurveFactorycurveCatmullRomClosed / curveCatmullRomd3 curve
closedbooleantrue when every bin has a value (full circle)Join the last bin back to the first
maxValuenumberchart scale, else largest valueValue mapped to the band top
showMarkersbooleantruePoint marker on the hovered bin, riding the line
markerMotion"crosshair" | "spring" | "none""crosshair"Locked to the crosshair, its own spring, or snap to the bin (reduced motion snaps)
markerSpringSpringOptions{ stiffness: 240, damping: 24 }markerMotion="spring": the marker's spring
showNowMarkerbooleantrue for open linesMarker at the line's last value ("now")
pulsebooleantrueSoft pulse around the "now" marker (off under reduced motion)
markerRadiusnumberscales with the chartMarker radius in px
fadedOpacitynumber0.2Opacity while another series is hovered in the legend
enterDelaynumber0Delay before the clockwise draw starts, in seconds

RadialArea

A filled radial area with a gradient rising from the band base to the outer edge. Without dataKey it draws every chart series (stacked outward by default); with dataKey it draws a single area, and baseDataKey turns it into a band (e.g. a min–max range). fill={false} gives an outline only.

// Every series, stacked outward, ghosting yesterday for the rest of the day
<RadialChart data={chartData} xDataKey="time" series={series}>
  <RadialAxis />
  <RadialArea ghostDataKey="yesterday" />
  <RadialScrubber />
  <RadialCenter />
  <RadialLegend />
</RadialChart>
PropTypeDefaultDescription
dataKeystring-Draw a single area for this key; omit to draw every series
baseDataKeystringband baseInner edge of a single area, e.g. for a min/max band
stackedbooleantrueSeries mode: stack outward or overlay from the base
colorstringseries colorFill / stroke color for a single area
fillbooleantrueDraw the fill (false for outline only)
fillOpacitynumber0.32Fill opacity at the outer edge
rampFromnumberseries rampFrom, else 0.25Fraction of fillOpacity at the band base
stroke"outer" | "both" | "none""outer"Which edges get a stroke
strokeWidthnumber1.5Stroke width in px
strokeOpacitynumber1Stroke opacity
strokeDasharraystring-e.g. "4 4" for a dashed edge
curve"catmullRom" | "basis" | "linear""catmullRom"Curve through the bin centers (closed when every bin has a value)
ghostDataKeystring-Comparison value drawn as a dim, dashed-edge ghost area in future bins
ghostColorstringvar(--foreground)Ghost color
ghostOpacitynumber0.1Ghost fill opacity (its stroke is twice this)
maxValuenumberchart scale, else largest valueValue mapped to the outer radius
activeDotbooleantrueDot on the outer edge of the hovered bin, riding the edge
activeDotMotion"crosshair" | "spring" | "none""crosshair"Locked to the crosshair, its own spring, or snap to the bin (reduced motion snaps)
activeDotSpringSpringOptions{ stiffness: 240, damping: 24 }activeDotMotion="spring": the dot's spring
fadedOpacitynumber0.2Opacity of other series while a legend item is hovered

RadialScrubber

Line from the center circle outward: solid or dashed with an end dot, or a gradient that fades in toward the rim without one (RadialScrubberVariant). It springs to the hovered bin; at rest it marks "now" on clock charts and hides elsewhere. Not drawn on angular charts. Its spring is the chart's crosshair: RadialLine markers and RadialArea active dots ride along it (see below).

PropTypeDefaultDescription
variant"solid" | "dashed" | "gradient""solid"Line style; gradient is transparent at the center circle and full strength at the outer end
dashArraystring"4 4"dashed only: stroke-dasharray (butt caps)
showDotbooleantrue for solid / dashed, false for gradientEnd dot
colorstringvar(--foreground)Line + dot color
lineOpacitynumber0.7Line opacity
strokeWidthnumber1.5Line width
dotRadiusnumberscales with the chartEnd dot radius in px
rest"now" | "hidden""now" when tracking now, else "hidden"Resting position
springSpringOptions{ stiffness: 420, damping: 42 }Spring toward the hovered bin; crosshair-locked hover dots follow it

RadialCenter

Center stat in the hole. By default it shows the sum of the series totals at rest and the hovered bin's sum while hovering. Pass a render function for custom content.

PropTypeDefaultDescription
defaultLabelstring"Total"Label under the value at rest
formatOptionsChartStatFlowFormat-Number format for the value
prefixstring-Value prefix
suffixstring-Value suffix
classNamestring-Container class
valueClassNamestring-Value class
labelClassNamestring-Label class
children(state: RadialCenterState) => ReactNode-Full control over the content

The render function receives { index, datum, label, isFuture, totals, values }: the hovered bin (all null at rest), its x label, whether it is a future bin, per-series totals over non-future bins and the hovered bin's per-series values.

RadialLegend

Dot, muted label and series total per series. emphasize series tint their total. Hovering an item dims the other series in every layer.

PropTypeDefaultDescription
formatValue(value: number) => stringen-US integerFormat the per-series totals
showValuesbooleantrueShow totals (turn off when sums are meaningless, e.g. temperatures or percentages)
classNamestring-Wrapper class

Examples

Pull requests by time of day

RadialDotBar stacks each series as dots in its 15-minute bin, with yesterday drawn as a ghost behind today and an annotation on the draft spike. RadialCenter takes a render function for a custom readout — here merged PRs and the change against yesterday at the same time.

Merged23,166
Ready for merge827
Draft1,860
const series = [{ dataKey: "merged", label: "Merged", color: "var(--chart-1)", rampFrom: 0.4 },{ dataKey: "ready", label: "Ready for merge", color: "var(--chart-2)" },{ dataKey: "draft", label: "Draft", color: "var(--chart-3)", emphasize: true },];<RadialChart data={chartData} xDataKey="time" series={series}><RadialAxis centerGradient /><RadialDotBar  ghostDataKey="yesterday"  annotations={[{ index: 8, label: "02:14", color: "var(--chart-3)" }]}/><RadialScrubber /><RadialCenter>  {({ datum, isFuture, totals, values, label }) => {    // Today vs yesterday over the same bins: a count, not a rate    let merged = totals.merged;    let delta = merged - yesterdayMergedSoFar;    let caption = "merged by 14:30";    if (datum) {      merged = isFuture ? datum.yesterdayMerged : values.merged;      delta = isFuture ? null : merged - datum.yesterdayMerged;      caption = isFuture ? `merged at ${label} yesterday` : `merged at ${label}`;    }    return (      <>        <ChartStatFlow value={merged} label={caption} />        {delta !== null && (          <span style={{ color: delta < 0 ? "var(--chart-negative)" : "var(--chart-positive)" }}>            {delta < 0 ? "−" : "+"}            {Math.abs(delta).toLocaleString()} vs yesterday          </span>        )}      </>    );  }}</RadialCenter><RadialLegend /></RadialChart>

The examples mix the three RadialScrubber styles: variant="solid" (the default, a line ending in a dot — the dot bars above), "dashed" (patterned dots) and "gradient" (a line that fades in toward the rim with no end dot — the line and temperature charts).

Today vs yesterday

RadialLine draws a smooth line around the clock. Draw a comparison key that isn't a series (here yesterday's merged PRs) as a dashed line for the whole day, and give both lines one maxValue so they share a scale. Today's line ends at "now".

Merged today23,209
const series = [{ dataKey: "merged", label: "Merged today", color: "var(--chart-1)" },];<RadialChart data={chartData} xDataKey="time" series={series}><RadialAxis />{/* Not a series: drawn as a comparison line for the whole day */}<RadialLine  dataKey="yesterday"  dash  stroke="var(--muted-foreground)"  strokeOpacity={0.7}  strokeWidth={1.5}  maxValue={maxValue}/><RadialLine dataKey="merged" strokeWidth={2.25} maxValue={maxValue} /><RadialScrubber variant="gradient" /><RadialCenter>  {({ datum, totals, label }) => (    <ChartStatFlow      value={datum ? (datum.merged ?? datum.yesterday) : totals.merged}      label={datum ? `merged at ${label}` : "merged by 14:30"}    />  )}</RadialCenter><RadialLegend /></RadialChart>

Time axis

With angleType="time", datum[xDataKey] holds a date per evenly spaced bin and RadialAxis draws UTC time ticks with month names between them. Here 366 days of San Francisco temperatures share one scale: valueKeys pulls the record extremes into the domain, minValue="auto" maps the coldest record to the band base, and two banded RadialAreas plus a RadialLine draw the record range, the normal range and the daily average.

const series = [{ dataKey: "avg", label: "Daily average", color: "var(--chart-1)" },];// 366 days, °Fconst days = [{ date: Date.UTC(2000, 0, 1), avg: 50.2, min: 43.6, max: 55.7, minmin: 37, maxmax: 62 },// …];const annualAverage = days.reduce((total, d) => total + d.avg, 0) / days.length;const dayFormat = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });<RadialChartangleType="time"data={days}xDataKey="date"series={series}valueKeys={["minmin", "maxmax"]}minValue="auto"maxValue="auto"niceValues={false}><RadialAxis showCircle={false} valueTicks={4} valueTickFormat={(v) => `${v}°F`} />{/* Record extremes */}<RadialArea  dataKey="maxmax"  baseDataKey="minmin"  color="var(--chart-1)"  fillOpacity={0.14}  rampFrom={1}  stroke="none"  curve="linear"/>{/* Normal range: average low → average high */}<RadialArea  dataKey="max"  baseDataKey="min"  color="var(--chart-1)"  fillOpacity={0.28}  rampFrom={1}  stroke="none"  curve="linear"/><RadialLine dataKey="avg" strokeWidth={1.5} /><RadialScrubber variant="gradient" /><RadialCenter>  {({ datum }) => (    <ChartStatFlow      value={datum ? datum.avg : annualAverage}      label={datum ? dayFormat.format(datum.date) : "annual average"}      suffix="°F"      formatOptions={{ maximumFractionDigits: 1 }}    />  )}</RadialCenter></RadialChart>

Categories

With angleType="band" every bin is a category and RadialAxis labels each one, rotated along its spoke. maxValue="stacked" gives the chart one shared scale so the axis can draw value rings; rendering RadialAxis after RadialBar keeps the rings and labels on top. Colors fall back to --chart-1 … --chart-5 by series position.

Under 14
14–24
25–44
45–64
65+
const series = [{ dataKey: "under14", label: "Under 14" },{ dataKey: "age14to24", label: "14–24" },{ dataKey: "age25to44", label: "25–44" },{ dataKey: "age45to64", label: "45–64" },{ dataKey: "age65plus", label: "65+" },]; // colors default to --chart-1 … --chart-5 by position// 50 states + DC, sorted by total population, largest firstconst states = [{ state: "CA", name: "California", under14: 7205000, age14to24: 6014000, age25to44: 10605000, age45to64: 8819000, age65plus: 4114000 },// …];const compact = new Intl.NumberFormat("en-US", { notation: "compact" });<RadialChartangleType="band"data={states}xDataKey="state"series={series}maxValue="stacked"><RadialBar />{/* after the bars, so value rings + labels sit on top */}<RadialAxis  showCircle={false}  valueTicks={[10e6, 20e6, 30e6, 40e6]}  valueTickFormat={(v) => compact.format(v)}/><RadialCenter>  {({ datum, totals, values }) => {    const counts = datum ? values : totals;    return (      <ChartStatFlow        value={Object.values(counts).reduce((a, b) => a + b, 0)}        formatOptions={{ notation: "compact", maximumFractionDigits: 1 }}        label={datum ? String(datum.name) : "US population"}      />    );  }}</RadialCenter><RadialLegend showValues={false} /></RadialChart>

Angular rings and partial arcs

orientation="angular" turns each datum into a ring and sweeps the value along the arc; startAngle={-90} / endAngle={90} makes it a semicircle, and fitArc crops the stage to the half disc. RadialRingBar stacks each device's content types end to end; on angular charts RadialAxis puts value ticks along the arc and a label at the start of each ring.

Photos
Apps
Documents
System
const series = [{ dataKey: "photos", label: "Photos", color: "var(--chart-1)" },{ dataKey: "apps", label: "Apps", color: "var(--chart-2)" },{ dataKey: "documents", label: "Documents", color: "var(--chart-3)" },{ dataKey: "system", label: "System", color: "var(--chart-4)" },];// One ring per device, innermost first. Values are % of the device's capacity (GB).const chartData = [{ device: "Phone", capacity: 256, photos: 46.1, apps: 21.1, documents: 3.5, system: 8.6 },{ device: "Laptop", capacity: 1024, photos: 20.7, apps: 16.4, documents: 23, system: 7.2 },{ device: "Cloud", capacity: 2048, photos: 41.3, apps: 0, documents: 20.1, system: 0 },];const percentUsed = (d) => d.photos + d.apps + d.documents + d.system;const totalCapacity = chartData.reduce((total, d) => total + d.capacity, 0);const overallUsed =chartData.reduce((total, d) => total + percentUsed(d) * d.capacity, 0) / totalCapacity;<RadialChartdata={chartData}xDataKey="device"series={series}orientation="angular"startAngle={-90}endAngle={90}maxValue={100}ringGap={0.18}><RadialAxis  showCircle={false}  valueTicks={[0, 25, 50, 75, 100]}  valueTickFormat={(v) => `${v}%`}/><RadialRingBar cornerRadius={3} segmentGap={3} />{/* The hole sits on the arc's center: lift the content into the half-disc */}<RadialCenter className="-translate-y-[30%]">  {({ datum }) => (    <ChartStatFlow      value={datum ? percentUsed(datum) : overallUsed}      formatOptions={{ maximumFractionDigits: 0 }}      suffix="%"      label={datum ? `${datum.device} used` : "used across devices"}    />  )}</RadialCenter><RadialLegend className="mt-8" showValues={false} /></RadialChart>

Patterned dots

patterns fills a series' dots with a pattern preset ("diagonal", "horizontal", "vertical", "cross", "dots", "circles", "accent") keyed by dataKey; use "ghost" for the ghost layer (e.g. a striped forecast). Large dots in layout="columns" read best.

Solar11.3 kWh
Grid7.9 kWh
const series = [{ dataKey: "solar", label: "Solar", color: "var(--chart-2)" },{ dataKey: "grid", label: "Grid", color: "var(--chart-1)" },];<RadialChart data={chartData} xDataKey="time" series={series}><RadialAxis /><RadialDotBar  layout="columns"  rows={5}  dotSize={0.9}  maxValue={3.2}  ghostDataKey="forecast"  ghostColor="var(--muted-foreground)"  ghostDotSize={0.9}  ghostOpacity={0.6}  patterns={{    grid: { preset: "diagonal", strokeWidth: 1.5 },    ghost: { preset: "diagonal", stroke: "none" },  }}/><RadialScrubber variant="dashed" /><RadialCenter>  {({ datum, isFuture, totals, values, label }) => {    const counts = datum ? values : totals;    let caption = "kWh so far";    if (datum) {      caption = isFuture ? `forecast ${label}` : `kWh at ${label}`;    }    return (      <ChartStatFlow        value={isFuture ? datum.forecast : counts.solar + counts.grid}        formatOptions={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }}        label={caption}      />    );  }}</RadialCenter><RadialLegend formatValue={(v) => `${v.toFixed(1)} kWh`} /></RadialChart>

Pass a preset id for defaults, or a RadialDotPattern:

FieldTypeDefaultDescription
presetPatternPresetId"diagonal"Pattern preset
fillstring-Custom fill (e.g. url(#my-pattern)); ignores preset, color, scale, units
colorstringseries colorStripe / dot color
scalenumber1Pattern tile scale
strokeWidthnumberpreset width × scaleStripe stroke width in px
backgroundstringtransparentTile background behind the stripes
strokestringcolorRing around each dot so patterned dots read as circles; "none" hides it
ringWidthnumber1Ring width in px
units"dot" | "chart""dot"Fit the tile to each dot, or one continuous texture across the chart

Hooks

useRadialChart

Build your own layer from the shared context — geometry (radius, innerRadius, outerRadius, centerRadius, layout), angles (binAngle, binStartAngle, angleToBin, fractionToAngle, timeToAngle), the value scale (valueDomain, valueScale, valueToAngle, ringBand), state (hoveredIndex, hoveredSeries, futureBins, nowIndex, totals) and the enter flag (revealed).

import { polarToCartesian, radialNumberAt, useRadialChart } from "@bklitui/ui/charts";

function PeakMarkers({ dataKey }: { dataKey: string }) {
  const { data, binAngle, valueScale, revealed } = useRadialChart();
  return (
    <g opacity={revealed ? 1 : 0}>
      {data.map((d, i) => {
        const value = radialNumberAt(d, dataKey);
        if (value === null) return null;
        const { x, y } = polarToCartesian(binAngle(i), valueScale(value));
        return <circle cx={x} cy={y} fill="var(--chart-1)" key={i} r={2} />;
      })}
    </g>
  );
}

valueScale uses the chart's shared domain; for a layer with its own scale use createRadialScale({ maxValue, innerRadius, outerRadius }). Other helpers: radialSeriesColor, radialTimeOf, radialArcPath, radialWedgePath and defaultRadialLayout.

See the charts gallery for every radial variant.