Preview
- region =
all - value =
60M - trend =
up· PY 18%
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 widgetmetric
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·Metric } from '@oshon-ai/components';
export default function Example() {
return <Widget·Metric />;
}Pixel-aligned KPI tile authored for the dashboard half-quarter slot. 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·Metric 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), optional subtitle (Lato Regular 12, neutral-500), trailing 24×24 kebab. Top corners radius 8 px. Title becomes a `<button>` with link styling when `onTitleClick` is supplied.
valueBig headline metric centered horizontally between the header and the picker label — Lato Bold 24/32, -0.96px tracking, black. Becomes a `<button>` with focus ring when `onValueClick` is supplied. Use `valueLabel` to override the SR-announced text when the visible string is shorthand (e.g. visible "60M", labeled "60 million users").
picker-labelCaption text rendered above the picker trigger at left:12, top:88 — Lato Regular 12/16, gray-800. Hidden when no `pickerLabel` is supplied or the picker is omitted.
picker-triggerFilter picker trigger at left:12, top:108 — 114 × 24 px box with 1 px neutral-300 border, 4 px corner radius, white surface, Lato Regular 10 px value text, trailing 16×16 chevron-down icon. Wraps a `Select.Root` so keyboard nav, typeahead, and the popover-anchored item list come from the Radix primitive. Hidden when `pickerOptions` is empty / omitted.
trend-pillRight-side trend pill at left:150, top:108 — 114 × 24 px tinted box. Tones flow through the semantic palette so `applyTheme()` retints them: `up` ⇒ `--oshon-color-success-700` (bg color-mixed at 16% + full-saturation fg + chevron-up); `down` ⇒ `--oshon-color-error-700` + chevron-down; `neutral` ⇒ `--oshon-color-on-surface-muted` + em-dash. Label text is Lato Bold 12 uppercase tracking +0.48px. Hidden when `trendLabel` is omitted.
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 renders as a presentational `<span>` with `aria-hidden="true"`. Title becomes a `<button>` with link styling when `onTitleClick` is supplied. Big metric becomes a `<button>` with focus ring when `onValueClick` is supplied. Picker uses Radix Select primitives — keyboard navigation (↑/↓/Home/End/Enter/Esc/typeahead) is provided by the underlying primitive.
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
<WidgetMetric
title="Clickable Title Name"
subtitle="Subheadername"
value="60M"
pickerLabel="Multi Picker"
pickerOptions={[
{ value: 'all', label: 'All (2)' },
{ value: 'us', label: 'US' },
{ value: 'eu', label: 'EU' },
]}
defaultPickerValue="all"
trend="up"
trendLabel="PY 18%"
onMore={() => openMenu()}
/><WidgetMetric
title="Active Users"
value="60M"
valueLabel="60 million active users"
trend="up"
trendLabel="+18%"
onTitleClick={() => router.push('/dashboards/users')}
onValueClick={() => router.push('/dashboards/users/detail')}
/><WidgetMetric
title="Active Users"
value="60M"
trend="up"
trendLabel="+18%"
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" }}>
<WidgetMetric title="Users" value="60M" trend="up" trendLabel="+18%" />
<WidgetMetric title="Sessions" value="240M" trend="down" trendLabel="-3%" />
</div>✗ Don't
<WidgetMetric title="x" value="60M" style={{ width: 400, height: 200 }} />WidgetMetric is authored for the half-height quarter slot — 276 × 144 px. Other slot sizes are served by sibling Widget* components. Forcing different outer dims breaks the dashboard grid math and ships a visual misalignment.
<WidgetMetric title="x" value="60M" trend="up" trendLabel="-15%" />
`trend` is the visual signal of business-significance, but a `down` label paired with `up` tone produces a visually confusing pill. Painting `-15%` as up is supported (e.g. cost reduction), but the consumer is responsible for keeping the tone aligned with the dashboard's domain semantics. When in doubt, match the sign.
<><WidgetMetric title="x" value="60M" /><Select.Root /* … */ /></>
The widget owns the picker / metric / trend layout — placing a sibling Select breaks the 276 × 144 footprint and the dashboard grid math. Pass `pickerOptions`, `pickerValue`, and `onPickerChange` into the widget itself; the picker slot is wired to a Radix Select internally.
Design rationale
WidgetMetric is the fourth member of the dashboard-widget family — a fixed-dim 276×144 surface authored for the half-height quarter slot, complementing the full-quarter WidgetTrackingItem / WidgetLeaderboard / WidgetDonutChart. It is tuned for the canonical "headline number + filter + trend" pattern (e.g. "60M users · All regions · +18% YoY"). The big metric value (Lato Bold 24, -0.96 tracking, centered) is the visual focal point; the picker delegates to the package's own Select compound so keyboard nav / typeahead / portal positioning come from one tested primitive. The trend pill paints in three utility tones (green / red / neutral) and renders a directional chevron — tone is consumer-driven (business-significance is a domain decision). Outer dims do NOT scale with `size`; only typography density does, mirroring the rest of the widget family so dashboard rows stay grid-aligned regardless of which size token the host app applies. `onTitleClick`, `onValueClick`, and the popover-anchored kebab Menu mirror sibling widgets exactly so dashboard widgets feel like a coherent family. Figma file `MsyCsSxIRkgRjWd1bSJpq6` node `12002:5432`.