Preview
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 add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add widgetdonutchart
Wire the tokens into your Tailwind v4 stylesheet:
/* 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.
'use client';
import { Widget·DonutChart } from '@oshon-ai/components';
export default function Example() {
return <Widget·DonutChart />;
}Compare-A-vs-B donut tile with brand-palette segment colors. Part of the Data Visualization pack.
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.
<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.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* 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 thedisabledprop
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.
headerTop 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-chart260×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-left122×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-right122×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.
kebabThree-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
<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()}
/><WidgetDonutChart
title="Spend by Type"
segments={[a, b]}
centerValue="$620K"
onTitleClick={() => router.push('/dashboards/spend')}
onSegmentClick={(seg, i) => router.push(`/dashboards/spend?segment=${i}`)}
/><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)}
/><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
<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.
<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.
<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`.