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 · Grouped Bar

Wide 608×288 dashboard headline — up to 3 series × 7 categories · auto-fit y-axis · color-keyed legend with visibility toggle · drill-down title + bars · applyTheme()-retinted data colors.

FigmaStorybookSource · PronpmPro

Unlock Widget · Grouped Bar

Wide grouped-bar chart with up to 3 series × 7 categories and color-keyed legend toggles. 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
Weekly spend by segmentLast 7 days
Mon
Tue
Wed
Thu
Fri
Sat
Sun
Weekly spend by segment, Last 7 days. Rebated: $836K total across 7 categories. Non-Rebated: $609K total across 7 categories. Pending: $294K total across 7 categories.
Built-in legend toggles re-fit the y-axis
Try unchecking a seriesThe y-axis auto-fits
Mon
Tue
Wed
Thu
Fri
Sat
Sun
Try unchecking a series, The y-axis auto-fits. Rebated: $836K total across 7 categories. Non-Rebated: $609K total across 7 categories. Pending: $294K total across 7 categories.
How it works
  • Legend checkboxes paint in their series tone (key)
  • Toggling hides the bars + retints the y-axis to the new max
  • Lock the axis with explicit yMax to prevent re-fit
Drill-down — clickable title + clickable bars
Title also wired
Mon
Tue
Wed
Thu
Fri
Sat
Sun
Click any bar for daily detail, Title also wired. Rebated: $836K total across 7 categories. Non-Rebated: $609K total across 7 categories.
Drill-down trace
Click the title or any bar to fire the drill-down handler.
Single-series · locked y-axis ceiling
Daily MRRSingle series, yMax pinned at $200K
Mon
Tue
Wed
Thu
Fri
Sat
Sun
Daily MRR, Single series, yMax pinned at $200K. MRR: $1.1M total across 7 categories.
Kebab menu — popover-anchored, three actions
With a popover-anchored menu
Mon
Tue
Wed
Thu
Fri
Sat
Sun
With a popover-anchored menu. Rebated: $836K total across 7 categories. Non-Rebated: $609K total across 7 categories. Pending: $294K total across 7 categories.
Menu trace
Click the ⋮ kebab to open the popover-anchored menu.

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 widgetgroupedbarchart

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·GroupedBar } from '@oshon-ai/components';

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

Wide grouped-bar chart with up to 3 series × 7 categories and color-keyed legend toggles. Part of the Data Visualization pack.

Unlock Widget · Grouped Bar

Wide grouped-bar chart with up to 3 series × 7 categories and color-keyed legend toggles. 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·GroupedBar
  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, blue-stone-700 / brand-teal, -0.28px tracking) + optional subtitle (Lato Regular 12, neutral-600), per-series legend (Checkbox + Lato Medium 12 label per series), trailing 20×20 kebab. Bottom edge carries a 1 px neutral-300 hairline separating header from chart band. Top corners radius 10 px.

legend

Header-anchored series toggle — one `<label>`-wrapped `<Checkbox>` per series. Each checked-box paints in the matching series color via an inline `--oshon-color-primary-700` override so the legend doubles as a color key. Toggling fires `onSeriesToggle(seriesId, visible)`; the matching bars hide (and the auto-scaled y-axis re-fits to the remaining visible series).

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.

y-axis

5-tick numeric axis at left:12, vertical-centered on the matching gridline. Labels (Lato Medium 12, neutral-500) format via `formatYTick` — default outputs compact currency (`200000` → `"$200K"`, `1500000` → `"$1.5M"`, `0` → `"0"`). The axis ceiling auto-rounds to a "nice" number via `niceCeiling()` when `yMax` is omitted; supply `yMax` explicitly to lock the axis.

gridline

5 horizontal hairlines (Oshon neutral-200) spanning x:56 → x:600, evenly spaced from CHART_TOP (y:76) to CHART_BOTTOM (y:251). The bottom gridline is the "0" baseline that bars stand on; the top gridline marks `yMax`.

bar-group

7 grouped-bar columns at left:85, top:76, with 40 px column-width + 35 px gap (Figma anchors). Each column hosts one `<bar>` per visible series, 12 px wide each (auto-scaled when more series are toggled), 2 px between, bottom-aligned at the "0" baseline. Bar height = `(value / yMax) × 175 px`.

bar

Single bar within a column. Renders as a `<div>` (presentational) by default; becomes a `<button>` with a focus ring + descriptive aria-label when `onBarClick` is supplied — e.g. `"Rebated on Tue: $148K"`. Fill color flows from the series tuple — first three series default to teal / amber / purple (Figma authoring); additional series cycle through the palette; consumers override per-series via `series[i].color`.

day-label

X-axis category label below each bar group at top:258 (CHART_BOTTOM + 7 px gap). Lato Medium 12, neutral-600, centered in the 40 px column.

Keyboard

Header kebab is a `<button>` when `onMore` or `moreItems` is supplied (Tab to focus, Enter / Space to activate, Oshon focus ring); otherwise renders as a presentational `<span>` with `aria-hidden="true"`. Title becomes a `<button>` with hover-underline link styling when `onTitleClick` is supplied. Each legend item is a native `<label>`-wrapped `<Checkbox>` — Tab to focus + Space to toggle. Bars become `<button>`s with focus rings + descriptive aria-labels (`"<Series> on <Day>: <Formatted value>"`) when `onBarClick` is supplied. The chart itself carries `role="figure"` + an SR-only summary listing the per-series totals across the rendered categories so screen readers get a scannable digest.

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 — Figma authoring sample (3 series across Mon-Sun)
<WidgetGroupedBarChart
  title="Clickable Title"
  subtitle="Subtitle"
  series={[
    { id: 'rebated',   label: 'Name Option' },
    { id: 'unrebated', label: 'Name Option' },
    { id: 'projected', label: 'Name Option' },
  ]}
  data={[
    { id: 'mon', label: 'Mon', values: [ 91000,  32000,  60000] },
    { id: 'tue', label: 'Tue', values: [148000, 137000,  62000] },
    { id: 'wed', label: 'Wed', values: [148000, 137000,  95000] },
    { id: 'thu', label: 'Thu', values: [120000, 150000,  41000] },
    { id: 'fri', label: 'Fri', values: [     0,      0,      0] },
    { id: 'sat', label: 'Sat', values: [     0,      0,      0] },
    { id: 'sun', label: 'Sun', values: [     0,      0,      0] },
  ]}
  onMore={() => openMenu()}
/>
Drill-down — clickable title + clickable bars
<WidgetGroupedBarChart
  title="Weekly Spend"
  subtitle="Last 7 days"
  series={SPEND_SERIES}
  data={SPEND_BY_DAY}
  onTitleClick={() => router.push('/dashboards/spend')}
  onBarClick={(catId, seriesId, value) => openDetail({ catId, seriesId, value })}
/>
Controlled legend — series visibility flows up to parent
const [visible, setVisible] = useState(['rebated', 'unrebated']);
<WidgetGroupedBarChart
  title="Spend"
  series={SPEND_SERIES}
  data={SPEND_BY_DAY}
  visibleSeriesIds={visible}
  onSeriesToggle={(id, on) =>
    setVisible((prev) => on ? [...prev, id] : prev.filter((x) => x !== id))
  }
/>
Custom y-axis ceiling + tick formatter
<WidgetGroupedBarChart
  title="Hours"
  series={HOURS_SERIES}
  data={HOURS_BY_DAY}
  yMax={40}
  yTickCount={5}
  formatYTick={(v) => v === 0 ? '0' : `${v}h`}
/>

✗ Don't

Hardcoded dimension override
<WidgetGroupedBarChart title="x" series={[]} data={[]} style={{ width: 800, height: 400 }} />

WidgetGroupedBarChart is authored for the wide half-row slot — 608 × 288 px. Other slot sizes are served by sibling Widget* components. Forcing different outer dims breaks the dashboard grid math and ships a visual misalignment.

Overlong category list breaking the 7-column footprint
<WidgetGroupedBarChart title="x" series={SPEND_SERIES} data={[/* 14 categories */]} />

The plot area is 490 px wide with 75 px column-stride — 7 columns is the canonical Figma anchor. Beyond that the rightmost columns clip the widget edge. For wider time-series, reach for a virtualized chart pattern or chunk the data into multiple widgets stacked horizontally.

Mixing positive + negative bars (no negative axis support)
<WidgetGroupedBarChart series={S} data={[{ id: 'mon', label: 'Mon', values: [-5000, 10000, 0] }]} />

The Figma authoring is a positive-only axis with the 0-baseline at CHART_BOTTOM. Negative values clamp to 0 in `barHeight()`. For diverging-axis charts (e.g. revenue vs cost), build a sibling widget that handles the symmetric layout explicitly.

Design rationale

WidgetGroupedBarChart is the fifth member of the dashboard-widget family — a fixed-dim 608×288 surface authored for the wide half-row slot, 2× the width of the half-quarter WidgetMetric and 2× the width of the full-quarter WidgetTrackingItem / WidgetLeaderboard / WidgetDonutChart. It is tuned for "compare 3-or-fewer series across 7 categories" — the canonical day-of-week × spend-by-segment chart. The title paints in the brand primary so `applyTheme()` retints the headline: this widget visually anchors the dashboard, so the title pulls more weight than the neutral title of the half-quarter siblings. The legend doubles as a color key (each checked checkbox paints in the matching series tone via an inline `--oshon-color-primary-700` override) and a visibility toggle (toggling re-fits the auto-scaled y-axis to the remaining visible series). The default series palette is the three seeds of the brand palette (primary / secondary / tertiary 700) so data colors retint alongside the title and rest of the surface; per-series `color` overrides exist for category-specific palettes. The y-axis auto-rounds to a "nice" ceiling (`niceCeiling()`) so default axes read as $200K instead of $187,432; consumers lock the axis with explicit `yMax`. Bar widths auto-scale within the 40 px column slot so 1 / 2 / 3 / 4-series compositions all paint correctly without breaking the dashboard grid math. `onTitleClick`, `onBarClick`, and the popover-anchored kebab Menu mirror sibling widgets exactly so dashboard widgets feel like a coherent family. Figma file `MsyCsSxIRkgRjWd1bSJpq6` node `12002:6287`.