Skip to content
UI Kit
Accent color

Radial chart

Data that goes round: 24 hours as a clock, twelve months as a rose with true areas, weekdays by hour as a ring heatmap, and groups with their parts as a sunburst.

One of 7 components in Charts

4 variants

Variants

Clock of hours

Twenty four bars around a clock face from midnight, the busiest hour in the accent and named in the middle.

Open in Studio

Rose of months

Twelve wedges from January, the area of each wedge is the value, so summer does not look bigger than it is.

Open in Studio

Ring heatmap

Seven rings for the weekdays, 24 segments for the hours, five accent steps for the value. Arrow keys walk around and between rings.

Open in Studio

Sunburst

Groups inside, their parts outside. A part lights up its whole path, the list beside it picks a group.

Open in Studio

Details

What Radial chart brings along, read from the registry and the source.

Category
Charts
Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/radial-chart.tsx
Brings along
utilskitchartchart-figure
npm packages
None
Languages
English and German, 13 texts of its own

Props

The RadialChartProps interface from components/ui/radial-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    RadialVariant

    hoursroseclock-heatmapsunburst

  • valuesoptional
    number[]

    hours: 24 values from midnight. rose: 12 values from January.

  • rowsoptional
    number[][]

    clock-heatmap: one row of 24 values per day, Monday first

  • rowLabelsoptional
    string[]

    clock-heatmap: names of the rows. Default: the weekdays of the language.

  • groupsoptional
    RadialGroup[]

    sunburst: groups with their parts

  • formatoptional
    Intl.NumberFormatOptions
  • unitLabeloptional
    string

    Name of the value, e.g. "Visits"

Built-in texts

Radial chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 13 texts
ringsEnglish {a} inside, {b} outsideGerman {a} innen, {b} außen
totalEnglish TotalGerman Gesamt
hourEnglish {h} to {n}German {h} bis {n} Uhr
peakEnglish Busiest hourGerman Stärkste Stunde
monthEnglish MonthGerman Monat
valueEnglish ValueGerman Wert
groupEnglish GroupGerman Gruppe
partEnglish PartGerman Teil
shareEnglish {share} of allGerman {share} von allem
dayEnglish DayGerman Tag
hourShortEnglish HourGerman Stunde
lessEnglish LessGerman Weniger
moreEnglish MoreGerman Mehr

Accessibility

Read from the source of the component.

  • Radial chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 310 lines
components/ui/radial-chart.tsx
'use client';

import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, polar, numberFormatter, rampColor, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Data that goes round: hours of a day, months of a year, parts of parts.
 *   hours          24 bars around a clock face, the busiest hour in the accent
 *   rose           twelve wedges, one per month, the area is the value
 *   clock-heatmap  rings for the weekdays, segments for the hours, the tint is the value
 *   sunburst       groups inside, their parts outside, a part lights its whole path
 *
 * Every wedge is reachable with the arrow keys (up and down change the ring
 * in the heatmap and the level in the sunburst). The center names what is
 * picked, the table holds every value.
 */

export type RadialVariant = 'hours' | 'rose' | 'clock-heatmap' | 'sunburst';

export interface RadialGroup {
  label: string;
  value?: number;
  children?: { label: string; value: number }[];
}

export interface RadialChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: RadialVariant;
  /** hours: 24 values from midnight. rose: 12 values from January. */
  values?: number[];
  /** clock-heatmap: one row of 24 values per day, Monday first */
  rows?: number[][];
  /** clock-heatmap: names of the rows. Default: the weekdays of the language. */
  rowLabels?: string[];
  /** sunburst: groups with their parts */
  groups?: RadialGroup[];
  format?: Intl.NumberFormatOptions;
  /** Name of the value, e.g. "Visits" */
  unitLabel?: string;
  className?: string;
}

const TEXTS = {
  en: { rings: '{a} inside, {b} outside', total: 'Total', hour: '{h} to {n}', peak: 'Busiest hour', month: 'Month', value: 'Value', group: 'Group', part: 'Part', share: '{share} of all', day: 'Day', hourShort: 'Hour', less: 'Less', more: 'More' },
  de: { rings: '{a} innen, {b} außen', total: 'Gesamt', hour: '{h} bis {n} Uhr', peak: 'Stärkste Stunde', month: 'Monat', value: 'Wert', group: 'Gruppe', part: 'Teil', share: '{share} von allem', day: 'Tag', hourShort: 'Stunde', less: 'Weniger', more: 'Mehr' },
};

/** A ring sector from a0 to a1 degrees (clockwise from twelve), between radius r0 and r1 */
function sector(cx: number, cy: number, r0: number, r1: number, a0: number, a1: number) {
  const large = a1 - a0 > 180 ? 1 : 0;
  const [x1, y1] = polar(cx, cy, r1, a0);
  const [x2, y2] = polar(cx, cy, r1, a1);
  const [x3, y3] = polar(cx, cy, r0, a1);
  const [x4, y4] = polar(cx, cy, r0, a0);
  if (r0 <= 0.5) return `M${cx} ${cy}L${x1} ${y1}A${round(r1)} ${round(r1)} 0 ${large} 1 ${x2} ${y2}Z`;
  return `M${x1} ${y1}A${round(r1)} ${round(r1)} 0 ${large} 1 ${x2} ${y2}L${x3} ${y3}A${round(r0)} ${round(r0)} 0 ${large} 0 ${x4} ${y4}Z`;
}

/**
 * Round data: a 24 hour clock, a rose of months, a weekday by hour ring heatmap or a sunburst.
 *   <RadialChart variant="hours" values={visitsPerHour} unitLabel="Visits" />
 */
export function RadialChart(props: RadialChartProps) {
  const { variant = 'hours', values = [], rows = [], groups = [], format, unitLabel = '', label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  const intl = useIntlLocale();
  const fmt = numberFormatter(intl, format);
  const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
  const [wrapRef, width] = useWidth<HTMLDivElement>(560);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const name = label ?? title ?? unitLabel;
  const size = Math.round(Math.max(240, Math.min(width, 440)));
  const cx = size / 2;
  const cy = size / 2;
  const R = size / 2 - 26;
  const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
  const dayNames = props.rowLabels ?? Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(intl, { weekday: 'short', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 9, 19 + i))));
  const monthName = (i: number, style: 'short' | 'long' = 'short') => new Intl.DateTimeFormat(intl, { month: style, timeZone: 'UTC' }).format(new Date(Date.UTC(2026, i, 1)));
  // English reads the hour as 2 PM, German as 14 (Uhr)
  const en = intl === 'en-US';
  const clock = (h: number) => (en ? `${h % 12 || 12} ${h < 12 ? 'AM' : 'PM'}` : String(h));
  const hourText = (h: number) => t.hour.replace('{h}', clock(h)).replace('{n}', clock((h + 1) % 24));
  /** A label outside the ring: right of the clock it starts there, left of it it ends there */
  const anchorAt = (deg: number) => (deg > 30 && deg < 150 ? 'start' : deg > 210 && deg < 330 ? 'end' : 'middle');

  // Wedges per variant: { a0, a1, r0, r1, fill, label, value, ring }
  type Wedge = { a0: number; a1: number; r0: number; r1: number; fill: string; title: string; value: number; ring: number; share?: number; parent?: number };
  const wedges: Wedge[] = [];
  const inner = R * 0.3;
  if (variant === 'hours' || variant === 'rose') {
    const n = variant === 'hours' ? 24 : 12;
    const vals = values.slice(0, n);
    const max = Math.max(...vals, 1);
    const peak = vals.indexOf(max);
    vals.forEach((v, i) => {
      const a0 = (360 * i) / n + (variant === 'hours' ? 1.2 : 1);
      const a1 = (360 * (i + 1)) / n - (variant === 'hours' ? 1.2 : 1);
      // rose: area true, the radius grows with the square root
      const r1 = variant === 'rose' ? inner + Math.sqrt(v / max) * (R - inner) : inner + (v / max) * (R - inner);
      wedges.push({ a0, a1, r0: inner, r1: Math.max(inner + 2, r1), fill: i === peak ? 'var(--primary)' : variant === 'rose' ? 'color-mix(in oklab, var(--primary) 34%, var(--card))' : 'color-mix(in oklab, var(--primary) 45%, var(--card))', title: variant === 'hours' ? hourText(i) : monthName(i, 'long'), value: v, ring: 0 });
    });
  } else if (variant === 'clock-heatmap') {
    const all = rows.flat();
    const lo = Math.min(...all);
    const hi = Math.max(...all);
    const band = (R - inner) / Math.max(1, rows.length);
    rows.forEach((row, ri) => {
      row.slice(0, 24).forEach((v, h) => {
        const step = Math.min(4, Math.floor(((v - lo) / (hi - lo || 1)) * 5));
        wedges.push({ a0: (360 * h) / 24 + 0.6, a1: (360 * (h + 1)) / 24 - 0.6, r0: inner + ri * band + 1, r1: inner + (ri + 1) * band - 1, fill: v === 0 ? 'var(--kitch-empty)' : rampColor(step), title: `${dayNames[ri]}, ${hourText(h)}`, value: v, ring: ri });
      });
    });
  } else {
    const total = groups.reduce((a, g) => a + (g.children?.reduce((s, c) => s + c.value, 0) ?? g.value ?? 0), 0) || 1;
    let at = 0;
    const r1 = inner + (R - inner) * 0.48;
    groups.forEach((g, gi) => {
      const gv = g.children?.reduce((s, c) => s + c.value, 0) ?? g.value ?? 0;
      const span = (gv / total) * 360;
      const color = pairColor(gi);
      const gIndex = wedges.length;
      wedges.push({ a0: at + 0.5, a1: at + span - 0.5, r0: inner, r1: r1 - 1, fill: color, title: g.label, value: gv, ring: 0, share: gv / total });
      let c0 = at;
      (g.children ?? []).forEach((c, ci) => {
        const cs = (c.value / total) * 360;
        wedges.push({ a0: c0 + 0.5, a1: c0 + cs - 0.5, r0: r1 + 1, r1: R, fill: `color-mix(in oklab, ${color} ${Math.max(28, 70 - ci * 12)}%, var(--card))`, title: c.label, value: c.value, ring: 1, share: c.value / total, parent: gIndex });
        c0 += cs;
      });
      at += span;
    });
  }

  const n = wedges.length;
  const total = variant === 'sunburst' ? wedges.filter((w) => w.ring === 0).reduce((a, w) => a + w.value, 0) : wedges.reduce((a, w) => a + w.value, 0);
  const lit = (i: number) => active === null || active === i || (variant === 'sunburst' && (wedges[active]?.parent === i || wedges[i]?.parent === active));

  const pick = (clientX: number, clientY: number, el: HTMLElement) => {
    const svg = el.querySelector('svg');
    if (!svg) return;
    const b = svg.getBoundingClientRect();
    const k = size / (b.width || size);
    const dx = (clientX - b.left) * k - cx;
    const dy = (clientY - b.top) * k - cy;
    const r = Math.hypot(dx, dy);
    const deg = ((Math.atan2(dy, dx) * 180) / Math.PI + 90 + 360) % 360;
    const hit = wedges.findIndex((w) => deg >= w.a0 - 0.8 && deg <= w.a1 + 0.8 && r >= w.r0 - 2 && r <= (variant === 'hours' || variant === 'rose' ? R : w.r1) + 2);
    setActive(hit >= 0 ? hit : null);
  };
  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? -1;
    let next: number | null = cur;
    if (variant === 'clock-heatmap' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
      const w = wedges[cur < 0 ? 0 : cur];
      const h = cur < 0 ? 0 : cur % 24;
      const r2 = Math.max(0, Math.min(rows.length - 1, (w?.ring ?? 0) + (e.key === 'ArrowUp' ? 1 : -1)));
      next = r2 * 24 + h;
    } else if (variant === 'sunburst' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
      const w = wedges[cur];
      if (e.key === 'ArrowUp') next = w && w.ring === 0 ? wedges.findIndex((x) => x.parent === cur) : cur;
      else next = w && w.parent !== undefined ? w.parent : cur;
      if (next === -1) next = cur;
    } else if (e.key === 'ArrowRight') next = (cur + 1) % n;
    else if (e.key === 'ArrowLeft') next = cur <= 0 ? n - 1 : cur - 1;
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = n - 1;
    else if (e.key === 'Escape') next = null;
    else return;
    e.preventDefault();
    setActive(next);
  };
  const a = active !== null ? wedges[active] : null;
  const live = a ? `${a.title}. ${fmt(a.value)}${a.share !== undefined ? `, ${t.share.replace('{share}', pct(a.share))}` : ''}` : '';
  const table =
    variant === 'clock-heatmap' ? (
      <ChartTable caption={name} head={[t.day, ...Array.from({ length: 24 }, (_, h) => String(h))]} rows={rows.map((r, i) => [dayNames[i], ...r.map(fmt)])} />
    ) : variant === 'sunburst' ? (
      <ChartTable caption={name} head={[t.part, t.group, t.value]} rows={groups.flatMap((g) => (g.children ?? []).map((c) => [c.label, g.label, fmt(c.value)]))} />
    ) : (
      <ChartTable caption={name} head={[variant === 'hours' ? t.hourShort : t.month, unitLabel || t.value]} rows={wedges.map((w) => [w.title, fmt(w.value)])} />
    );

  const peakW = variant === 'hours' ? wedges.reduce((m, w, i) => (w.value > (wedges[m]?.value ?? -1) ? i : m), 0) : -1;

  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef}>
        <div ref={wrapRef} className={cn('flex flex-col items-center gap-6', variant === 'sunburst' && width >= 640 && 'grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] items-center')}>
          <ChartSurface
            label={name}
            summary={summary ?? `${t.total} ${fmt(total)}`}
            labels={labels}
            live={live}
            table={table}
            onKeyDown={onKeyDown}
            onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(0)}
            onBlur={() => setActive(null)}
            onPointerMove={(e) => pick(e.clientX, e.clientY, e.currentTarget)}
            onPointerDown={(e) => pick(e.clientX, e.clientY, e.currentTarget)}
            onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
            className="mx-auto w-full"
            style={{ maxWidth: size }}
          >
            <svg width="100%" viewBox={`0 0 ${size} ${size}`} aria-hidden className="block overflow-visible">
              {/* Guides */}
              {(variant === 'hours' || variant === 'rose') && (
                <g>
                  {[0.25, 0.5, 0.75, 1].map((q) => (
                    <circle key={q} cx={cx} cy={cy} r={round(variant === 'rose' ? inner + Math.sqrt(q) * (R - inner) : inner + q * (R - inner))} fill="none" stroke="var(--kitch-grid)" strokeWidth="1" />
                  ))}
                  <circle cx={cx} cy={cy} r={round(inner)} fill="none" stroke="var(--kitch-axis)" strokeWidth="1" />
                </g>
              )}
              {wedges.map((w, i) => (
                <path
                  key={i}
                  d={sector(cx, cy, w.r0, w.r1, w.a0, w.a1)}
                  fill={w.fill}
                  opacity={lit(i) ? 1 : 0.32}
                  stroke={active === i ? 'var(--foreground)' : 'none'}
                  strokeWidth={active === i ? 1.5 : 0}
                  className={cn('transition-opacity duration-200', anim && (variant === 'clock-heatmap' ? 'kitch-cell' : 'kitch-pop'))}
                  style={{ ...d(variant === 'clock-heatmap' ? 60 + (i % 24) * 18 + w.ring * 40 : 80 + i * (variant === 'sunburst' ? 50 : 26)), transformOrigin: `${cx}px ${cy}px`, transformBox: 'view-box' }}
                />
              ))}
              {/* Clock and month labels */}
              {variant === 'hours' &&
                [0, 3, 6, 9, 12, 15, 18, 21].map((h) => {
                  const deg = (360 * h) / 24;
                  const [x, y] = polar(cx, cy, R + (anchorAt(deg) === 'middle' ? 14 : 8), deg);
                  return (
                    <text key={h} x={x} y={y} dy="0.34em" textAnchor={anchorAt(deg)} className={cn('text-[11px] tabular-nums', h % 6 === 0 ? 'font-medium' : '')} fill={h % 6 === 0 ? 'var(--foreground)' : 'var(--muted-foreground)'}>
                      {en && h % 6 !== 0 ? h % 12 : clock(h)}
                    </text>
                  );
                })}
              {variant === 'rose' &&
                wedges.map((_, i) => {
                  const [x, y] = polar(cx, cy, R + 14, (360 * (i + 0.5)) / 12);
                  return (
                    <text key={i} x={x} y={y} dy="0.34em" textAnchor="middle" className="text-[11px]" fill={active === i ? 'var(--foreground)' : 'var(--muted-foreground)'}>
                      {monthName(i).slice(0, 3)}
                    </text>
                  );
                })}
              {variant === 'clock-heatmap' &&
                [0, 6, 12, 18].map((h) => {
                  const deg = (360 * h) / 24;
                  const [x, y] = polar(cx, cy, R + (anchorAt(deg) === 'middle' ? 14 : 8), deg);
                  return (
                    <text key={h} x={x} y={y} dy="0.34em" textAnchor={anchorAt(deg)} className="text-[11px] font-medium tabular-nums" fill="var(--foreground)">
                      {clock(h)}
                    </text>
                  );
                })}
              {/* Center: what is picked, else the total */}
              <circle cx={cx} cy={cy} r={round(inner - 3)} fill="var(--kitch-surface)" />
              <text x={cx} y={cy - 7} textAnchor="middle" className="text-[11px]" fill="var(--muted-foreground)">
                {a ? (a.title.length > 18 ? `${a.title.slice(0, 17)}…` : a.title) : variant === 'hours' && peakW >= 0 ? t.peak : t.total}
              </text>
              <text x={cx} y={cy + 13} textAnchor="middle" className="text-[16px] font-semibold tabular-nums" fill="var(--foreground)">
                {a ? fmt(a.value) : variant === 'hours' && peakW >= 0 ? (en ? clock(peakW) : `${peakW} Uhr`) : fmt(total)}
              </text>
            </svg>
          </ChartSurface>
          {variant === 'clock-heatmap' && (
            <div className="flex w-full max-w-sm flex-wrap items-center justify-between gap-x-6 gap-y-2 text-[12px] text-muted-foreground">
              <span>{t.rings.replace('{a}', dayNames[0]).replace('{b}', dayNames[dayNames.length - 1])}</span>
              <span className="flex items-center gap-1.5">
                {t.less}
                {[0, 1, 2, 3, 4].map((s) => (
                  <span key={s} aria-hidden className="size-3 rounded-[3px]" style={{ background: rampColor(s) }} />
                ))}
                {t.more}
              </span>
            </div>
          )}
          {variant === 'sunburst' && (
            <ul className="grid w-full gap-1">
              {wedges.map((w, i) =>
                w.ring === 0 ? (
                  <li key={i}>
                    <button
                      type="button"
                      onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)}
                      onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
                      onFocus={() => setActive(i)}
                      onBlur={() => setActive(null)}
                      onClick={() => setActive(active === i ? null : i)}
                      className={cn('flex min-h-11 w-full items-center gap-2.5 rounded-md px-2.5 text-left text-[13px] transition-colors', active === i || wedges[active ?? -1]?.parent === i ? 'bg-accent' : 'hover:bg-accent/60')}
                    >
                      <span aria-hidden className="size-2.5 shrink-0 rounded-[3px]" style={{ background: w.fill }} />
                      <span className="min-w-0 flex-1 truncate text-foreground">{w.title}</span>
                      <span className="font-medium tabular-nums text-foreground">{fmt(w.value)}</span>
                      <span className="w-10 text-right tabular-nums text-muted-foreground">{pct(w.share ?? 0)}</span>
                    </button>
                  </li>
                ) : null,
              )}
            </ul>
          )}
          {variant === 'hours' && <p className="text-[12px] text-muted-foreground">{unitLabel}</p>}
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Frequent questions

How do I install Radial chart?

With aiw add @ai-website/radial-chart. The command puts components/ui/radial-chart.tsx into your project, the code is yours from then on. It brings along chart and chart-figure. It needs no further npm packages.

Which variants does Radial chart have?

4 variants, Clock of hours, Rose of months, Ring heatmap and Sunburst. You pick one with the variant prop, for example variant="rose".

Can I translate the texts of Radial chart?

Yes. Radial chart carries 13 texts of its own in English and German, such as “Total”, “Busiest hour” and “Month”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Radial chart?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Radial chart with aiw_components and install it with aiw_add @ai-website/radial-chart. Name the variant in your prompt, for example variant="hours", and the assistant writes your content straight into the props.

Can I adjust Radial chart without code?

Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching RadialChart code to copy.

Often searched

Jump to

Copied to the clipboard