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.
Preview
- 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
yMaxto prevent re-fit
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 widgetgroupedbarchart
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·GroupedBar } from '@oshon-ai/components';
export default function Example() {
return <Widget·GroupedBar />;
}Wide grouped-bar chart with up to 3 series × 7 categories and color-keyed legend toggles. 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·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.
| 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, 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.
legendHeader-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).
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.
y-axis5-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.
gridline5 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-group7 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`.
barSingle 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-labelX-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
<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()}
/><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 })}
/>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))
}
/><WidgetGroupedBarChart
title="Hours"
series={HOURS_SERIES}
data={HOURS_BY_DAY}
yMax={40}
yTickCount={5}
formatYTick={(v) => v === 0 ? '0' : `${v}h`}
/>✗ Don't
<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.
<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.
<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`.