Widget · Trend List
Wide 608×288 leaderboard — ranked list + 7-day sparkline · signed delta pills (success/error semantic palette) · null-safe gaps · drill-down row + title · applyTheme()-retinted trend line.
Preview
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 widgettrendlist
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·TrendList } from '@oshon-ai/components';
export default function Example() {
return <Widget·TrendList />;
}Leaderboard + sparkline combo for top-N-with-trend dashboard headlines. 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·TrendList 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 16, blue-stone-700, -0.32px tracking) + optional subtitle (Lato Regular 12, neutral-600) + trailing 20×20 kebab. Bottom edge carries a 1 px neutral-300 hairline separating header from content. Top corners radius 10 px.
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. When interactive, the kebab is wrapped in the Oshon `Tooltip` primitive (`moreLabel`, default `"More options"`) so hover/focus surfaces the Radix-backed dark chip; the tooltip dismisses automatically when the popover opens.
list-panelLeft-anchored 316 × 240 panel hosting the column headers + scrollable rows. Right edge carries a 1 px neutral-200 hairline separating it from the trend panel.
column-headers24 px-tall header row — three columns at left:12 (Lato Medium 12, neutral-700): name (`nameLabel`, default `"Data Name"`, w:120, left-aligned), value (`valueLabel`, default `"$ Name"`, w:74, right-aligned), delta (`deltaLabel`, default `"+/- Prev"`, w:74, center-aligned). Bottom edge: 1 px neutral-200 hairline.
rowsVertical-scrolling row container at top:24, bottom:0. Each row renders as a `<div>` (presentational) by default; becomes a `<button>` with hover background tint + focus ring + descriptive aria-label when `onItemClick` is supplied. Empty `items` → "No data" placeholder. The browser-native scrollbar sits at the right edge when content overflows the 216 px content band.
rowSingle 40 px-tall row — name (Lato Medium 12, neutral-800, w:120, ellipsizes) at left:12, value (Lato Medium 12, neutral-800, w:74, right-aligned) at left:144, delta pill at right:23 (52 × 24, radius 4). Bottom edge: 1 px neutral-200 hairline. When `onItemClick` is supplied, the row `<button>` is wrapped in the Oshon `Tooltip` primitive so hover/focus surfaces a `"<Name>: <Value> (<Δ>)"` summary chip.
delta-pillSigned-delta indicator — 52 × 24 px rounded-4 chip. Sign drives chrome through the semantic palette: positive ⇒ `--oshon-color-success-100` bg + `--oshon-color-success-700` text; negative ⇒ `--oshon-color-error-100` bg + `--oshon-color-error-700` text; zero ⇒ surface-muted bg + neutral text. `applyTheme()` retints all three states alongside the rest of the brand. Pill text flows through `formatDelta` (default emits a signed percentage: `10` → `"+10%"`, `-4` → `"-4%"`). Wrapped in the Oshon `Tooltip` primitive — hover/focus surfaces a `"<pillText> vs previous period"` chip — and stays keyboard-reachable (`tabIndex=0`) on the read-only row path; on the interactive-row path the pill drops out of the tab order (`tabIndex=-1`) so the row button is the single focus stop.
trend-panelRight-anchored 292 × 240 panel hosting the y-axis labels, gridlines, sparkline, and day labels. Renders the chart in a single SVG overlay so polyline + dots stay vector-crisp at any zoom.
y-axis5-tick numeric axis at left:12, vertical-centered on the matching gridline. Labels (Lato Medium 12, neutral-600, right-aligned in a 36 px column) 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:280 (within the trend panel), evenly spaced from CHART_TOP (y:20) to CHART_BOTTOM (y:204). The bottom gridline is the "0" baseline that the sparkline rests on; the top gridline marks `yMax`.
trend-lineSVG `<polyline>` connecting consecutive non-null sparkline points. Points with `value: null` (or `undefined`) break the line so missing-data gaps render as empty space rather than dropped-to-zero artifacts. Stroke flows through `trendColor` (default `var(--oshon-color-tertiary-700)` — the third seed of the brand palette so `applyTheme()` retints the trend line).
trend-dot8 px (radius 4) SVG `<circle>` per non-null point. Fill matches `trendColor`. Hovering bumps r to 6 (120 ms ease) and surfaces an Oshon `Tooltip` (Radix-backed dark chip + arrow) — default text is `"<xLabel>: <formattedY>"` (e.g. `"Mon: $80K"`); customize via `formatPointTooltip(label, rawValue, formattedY)` or return `""` to suppress (returning `""` also removes the focus-trigger wrapper so a bare circle paints with no tab stop). Each dot is keyboard-reachable (`tabIndex=0`, `role="img"`, `aria-label=tooltipText`) so the tooltip opens on focus too. Up to 7 dots paint (one per visible column in the 7-day Figma anchor); points beyond the 7th are dropped.
day-labelX-axis category label below each column at top:212 (within the trend panel). Lato Medium 12, neutral-600, centered in the 30 px column. Label text comes from the `xLabels` prop (default `["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]`); pass `formatXTick(label, index)` to abbreviate, localize, or re-case (e.g. `(s) => s.slice(0, 1)` to render `M T W T F S S`).
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 list row becomes a full-width `<button>` with hover background tint + focus ring + descriptive aria-label (`"<Name>, <Value>, <DeltaText>"`) when `onItemClick` is supplied. The sparkline carries `aria-hidden` and is paired with an SR-only summary listing the rendered points so screen readers get a scannable digest. List scrolls vertically when content overflows the 216 px content band. Tooltip surfaces flow through the Oshon `Tooltip` primitive (Radix-backed dark chip + arrow) on every tooltip-bearing slot — title, row, delta-pill, kebab, and trend-dot — so focusing a trigger opens the tooltip after a 300 ms delay and Escape dismisses it; native HTML `title=` and SVG `<title>` are deliberately NOT used.
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
<WidgetTrendList
title="Top Distributors"
subtitle="Last 7 days"
items={[
{ id: 'a', name: 'Acme Co.', value: '$670K', delta: 10, daily: [10000, 20000, 15000, 16000, null, null, null] },
{ id: 'b', name: 'Globex', value: '$590K', delta: -4, daily: [25000, 28000, 20000, 25000, null, null, null] },
{ id: 'c', name: 'Initech', value: '$470K', delta: 8, daily: [30000, 25000, 20000, 20000, null, null, null] },
{ id: 'd', name: 'Umbrella', value: '$390K', delta: 11, daily: [15000, 20000, 15000, 15000, null, null, null] },
]}
onMore={() => openMenu()}
/><WidgetTrendList
title="Top Distributors"
subtitle="Last 7 days"
items={DISTRIBUTORS_WITH_DAILY}
onTitleClick={() => router.push('/dashboards/distributors')}
onItemClick={(item) => openDetail(item.id)}
/><WidgetTrendList
title="Hours by Team"
items={TEAMS_WITH_DAILY}
xLabels={["Mon","Tue","Wed","Thu","Fri","Sat","Sun"]}
formatXTick={(label) => label.slice(0, 1)}
/><WidgetTrendList
title="Hours by Day"
items={TEAMS_WITH_DAILY}
yMax={40}
yTickCount={5}
formatYTick={(v) => v === 0 ? '0' : `${v}h`}
formatPointTooltip={(label, _, fy) => `${label} → ${fy}`}
trendColor="var(--oshon-color-primary-700)"
/>✗ Don't
<WidgetTrendList title="x" items={[]} trendData={[]} style={{ width: 800, height: 400 }} />WidgetTrendList 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.
<WidgetTrendList title="x" items={ROWS} trendData={[/* 14 days */]} />The trend plot area is 210 px wide with 30 px column-stride — 7 columns is the canonical Figma anchor. Beyond that the rightmost columns clip the panel edge. For wider time-series, reach for WidgetGroupedBarChart (no list, full-width 7-col plot) or chunk the data into multiple widgets stacked horizontally.
<WidgetTrendList title="x" items={ROWS} trendData={[{ id: 'mon', label: 'Mon', value: -5000 }]} />The Figma authoring is a positive-only axis with the 0-baseline at CHART_BOTTOM. Negative values clamp to 0 in `plotPoint()`. For diverging-axis charts (e.g. revenue vs cost), build a sibling widget that handles the symmetric layout explicitly, or normalize the trend data to a positive series before passing it.
Design rationale
WidgetTrendList is the sixth member of the dashboard-widget family — a fixed-dim 608×288 surface authored for the wide half-row slot, same outer footprint as WidgetGroupedBarChart but with a different internal split: list-on-the-left + sparkline-on-the-right. It is tuned for "rank a list of N entities AND show how the aggregate metric trended over the last 7 days" — the canonical top-distributors / top-products dashboard headline. The title paints in the brand primary so `applyTheme()` retints the headline, matching the rest of the wide-row family. The list scrolls vertically when content exceeds the 216 px content band so consumers can pass arbitrary-length item arrays without breaking the surface. Delta pills paint through the semantic success/error palette (positive ⇒ `--oshon-color-success-100/700`, negative ⇒ `--oshon-color-error-100/700`); the trend line defaults to the third seed of the brand palette (`--oshon-color-tertiary-700`) so the sparkline retints alongside the brand. The sparkline auto-rounds to a "nice" y-axis ceiling (`niceCeiling()`) so default axes read $200K instead of $187,432; consumers lock the axis with explicit `yMax`. Polyline breaks across `value: null` so missing-data gaps render as empty space rather than dropped-to-zero artifacts. `onTitleClick`, `onItemClick`, and the popover-anchored kebab Menu mirror sibling widgets exactly. Figma file `MsyCsSxIRkgRjWd1bSJpq6` node `12002:6161`.