Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
Data VisualizationUpdatedPro· Data Visualization packWCAG 2.2 AA

Widget · Donut Chart

Two-segment compare-A-vs-B donut tile — fixed 276×288 · center value + caption · L/R legend with drill-down · brand-palette segment colors retinted by applyTheme().

FigmaStorybookSource · PronpmPro

Unlock Widget · Donut Chart

Compare-A-vs-B donut tile with brand-palette segment colors. Part of the Data Visualization pack.

Data Visualization pack starts at $249/yr Y1 (renews at $174/yr). Or get every Pro pack with Solo for $999/yr.

Preview

Live preview
@oshon-ai/components
Figma-parity authoring sample
Spend by TypeLast 12 months
Prior 12 Month's Spend
$620K50 Products60%40%
Non-Rebated
$373K30 Products
Rebated
$248K20 Products
Drill-down — clickable title + clickable legend columns
last 30 days
Total MRR
$1.2M3,400 accounts78%22%
Drill-down trace
Click the title or either legend column to fire the drill-down handler.
Three real-world compare-A-vs-B patterns
Active vs Churned
Customer status
12,840accounts91%9%
Active
11,72091% retention
Churned
1,1209% attrition
iOS vs Android
Sessions today
280Ksessions58%42%
iOS
162K58%
Android
118K42%
Capacity
Compute
100nodes68%32%
Used
68$680K
Free
32$320K
Kebab menu — popover-anchored, three actions
Pipeline mixQ3 forecast
Forecast value
$4.2M42 deals67%33%
Committed
$2.8M24 deals
Best case
$1.4M18 deals
Menu trace
Click the ⋮ kebab to open the popover-anchored menu.
Brand-palette tokens — applyTheme() retints both segments
Default palette
Brand-driven
100%60%40%
Primary
seed 1--oshon-color-primary-700
Tertiary
seed 3--oshon-color-tertiary-700
Theme awareness

Segment colors default to the first + third seeds of the brand palette so any applyTheme() call retints the donut in lockstep with the rest of the surface. Pass per-segment color to override for category-specific palettes.

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add widgetdonutchart

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { Widget·DonutChart } from '@oshon-ai/components';

export default function Example() {
  return <Widget·DonutChart />;
}
Pro · Data Visualization

Compare-A-vs-B donut tile with brand-palette segment colors. Part of the Data Visualization pack.

Unlock Widget · Donut Chart

Compare-A-vs-B donut tile with brand-palette segment colors. Part of the Data Visualization pack.

Data Visualization pack starts at $249/yr Y1 (renews at $174/yr). Or get every Pro pack with Solo for $999/yr.

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<Widget·DonutChart
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

header

Top 48 px strip — title (Lato Bold 14, black, -0.28px tracking), subtitle (Lato Regular 12, neutral-500), trailing 24×24 kebab. Bottom edge a 1 px neutral-200 line.

pie-chart

260×144 frame at left:8, top:56. Section heading (Lato Bold 12 uppercase +0.48px tracking, neutral-900) at top:0. Donut SVG (120×120) at left:70, top:24 — two arcs painted via `pathLength="100"` `<circle>` strokes (segment 0 fills the ring, segment 1 over-paints from 12 o'clock clockwise for `percentValue`). Center value (Lato Bold 24/32 black, -0.96px tracking) and center caption (Lato Regular 12/14 neutral-600) are absolutely positioned in the donut center. Two callout labels (Lato Bold 12 uppercase +0.48px) sit at the upper-left (segment 0) and lower-right (segment 1), each connected to the donut edge by a 1 px right-angle leader line painted in the segment's color.

legend-left

122×64 column at left:8, top:216. Top row: 8×8 colored dot + label string (Lato Regular 12, black). Middle row: big value string (Lato Bold 18, black, -0.36px). Bottom row: count string (Lato Regular 12, neutral-600). Becomes a `<button>` with hover background tint + focus ring when `onSegmentClick` is supplied.

legend-right

122×64 column at left:146, top:216 — mirror of `legend-left` with the segment-1 color and data. A 1 px neutral-200 vertical divider between the columns sits at left:138, top:216, height:64.

kebab

Three-dot vertical menu button at right:12 in the header. Three modes: (1) `moreItems` supplied → renders as a Popover.Trigger and the component owns the popover-anchored `Menu` open state (picks fire `onMoreAction(itemId)` and dismiss). (2) `onMore` supplied → plain `<button>` that fires the callback (consumer wires their own menu). (3) Neither → presentational `<span>` so the surface stays RSC-safe along the read-only path.

Keyboard

Header kebab is a `<button>` when `onMore` or `moreItems` is supplied (Tab to focus, Enter / Space to activate, Oshon focus ring); otherwise it renders as a presentational `<span>` with `aria-hidden="true"`. Title becomes a `<button>` with link styling when `onTitleClick` is supplied. Each legend column becomes a full-column `<button>` when `onSegmentClick` is supplied — keyboard-activatable with the same focus + hover treatment as title. Donut SVG + leader lines are decorative (`aria-hidden="true"`); the percentages are exposed via the visible callout labels and via the legend column count strings.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-29

Do / Don't

✓ Do

Default — non-rebated vs rebated spend
<WidgetDonutChart
  title="Spend by Type"
  subtitle="Last 12 months"
  sectionLabel="Prior 12 Month's Spend"
  centerValue="$620K"
  centerCaption="50 Products"
  segments={[
    { label: 'Non-Rebated', value: '$373K', count: '30 Products', percent: '60%', percentValue: 60 },
    { label: 'Rebated',     value: '$248K', count: '20 Products', percent: '40%', percentValue: 40 },
  ]}
  onMore={() => openMenu()}
/>
Drill-down — clickable title + clickable legend columns
<WidgetDonutChart
  title="Spend by Type"
  segments={[a, b]}
  centerValue="$620K"
  onTitleClick={() => router.push('/dashboards/spend')}
  onSegmentClick={(seg, i) => router.push(`/dashboards/spend?segment=${i}`)}
/>
Popover-anchored menu via `moreItems`
<WidgetDonutChart
  title="Spend by Type"
  segments={[a, b]}
  centerValue="$620K"
  moreItems={[
    { id: 'view', label: 'View details' },
    { id: 'export', label: 'Export as CSV' },
  ]}
  onMoreAction={(id) => handle(id)}
/>
Composing a dashboard row with WidgetLeaderboard
<div style={{ display: "flex", gap: 8, alignItems: "flex-start" }}>
  <WidgetLeaderboard title="Top 10" items={items} />
  <WidgetDonutChart title="Spend by Type" segments={[a, b]} centerValue="$620K" />
</div>

✗ Don't

Hardcoded dimension override
<WidgetDonutChart title="x" segments={[a, b]} centerValue="$1" style={{ width: 400 }} />

WidgetDonutChart is authored for the 4×4 quarter slot — 276 × 288 px. Other slot sizes are served by sibling Widget* components. Forcing a different width breaks the dashboard grid math and ships a visual misalignment.

Three+ segments
<WidgetDonutChart segments={[a, b, c] as any} centerValue="$1" title="x" />

The legend area is split exactly 50 / 50 with a single vertical divider. Adding a third segment has no visual home in this widget — reach for a sibling pie/donut widget that supports an N-row legend, or ship a `<Pie>` primitive directly.

percentValue + percent label out of sync
<WidgetDonutChart segments={[{ ...a, percent: '60%', percentValue: 30 }, b]} centerValue="$1" title="x" />

`percentValue` drives the donut geometry; `percent` is the human-readable callout label. Keeping them in sync is the consumer's job — desync produces a chart whose visual proportions disagree with its callouts. If you only have a numeric value, format it once and pass both fields from the same source.

Design rationale

WidgetDonutChart is the second member of the dashboard-widget family — a fixed-dim 276×288 surface authored for the same 4×4 quarter slot as WidgetLeaderboard, but tuned for two-segment "compare A vs B" comparisons (e.g. rebated vs non-rebated spend, paid vs free users, active vs churned accounts). The donut is a single SVG with `pathLength="100"` `<circle>` strokes — no Recharts dependency, no canvas — so the widget bundles small and renders identically server- and client-side. Outer dims do NOT scale with `size`; only typography density does. Default segment colors are the first and third seeds of the three-seed palette (`--oshon-color-primary-700` + `--oshon-color-tertiary-700`) so `applyTheme()` retints the donut alongside the rest of the brand; per-segment `color` overrides ship for category-specific palettes. The kebab + title + legend interaction model mirrors WidgetLeaderboard exactly so dashboard widgets feel like a coherent family. Figma file `MsyCsSxIRkgRjWd1bSJpq6` node `12002:5447`.