PVR Tech Studio

Widgets

A library of 20+ ready-made dashboard widgets — KPI tiles in eight styles, chart.js donut/gauge/bar cards, a Recharts area-trend card, welcome heroes, activity/notification/timeline/people lists, a rich report panel, a mini calendar, and Google-Cloud / Firebase console-style sets — all showcased on the /widgets gallery and importable one-by-one from src/components/widgets/.

14 min read
Updated August 13, 2026

Overview

The Widgets showcase (/widgets, sidebar Components → Widget, lazy-loaded) is a modern, interactive gallery of every widget in src/components/widgets/, arranged in labelled sections: Welcome heroes (three variants side-by-side), a KPI tile gallery (one representative tile per style), Chart cards (donut, gauge, breakdown, area trend, stat list, and four weekly bar cards), Overview panels (the full-width report panel + mini calendar), Lists & activity, and Cloud & infrastructure (the GCP and Firebase console-style sets).

Every widget is fully data-driven via props — the page passes demo data, but the components carry no sample content of their own. Widgets are pure presentational cards: they render inside the shared Card (or their own token-styled shell), wrap themselves in a staggerItem motion variant so a surrounding Stagger grid cascades them in, and stay reduced-motion safe throughout.

Three cross-cutting rules apply everywhere:

  1. Colors are tokens only. DOM/SVG widgets use semantic utilities (bg-primary, text-success, …) or currentColor; the canvas charts (chart.js) can't read CSS variables, so they get their colors from useChartTokens(), which reads the raw --* vars and re-reads on theme/skin change.
  2. User-facing text is i18n keys. The page's labels live in the widgets namespace (src/locales/<lng>/widgets.json, 214 keys, en + ja at parity); the components themselves take already-translated strings as props (only WidgetMenu translates its own items).
  3. Widget headers are borderless. WidgetHeader sits inside the card's own padding with no divider band — the modern portlet look — and renders the ⋯ WidgetMenu by default (menu={false} opts out).

Architecture & files

FileResponsibility
src/pages/ui/WidgetsPage.tsxThe /widgets gallery — composes every widget with demo data, i18n'd section headings, and the KPI_GALLERY style catalog.
src/components/widgets/index.tsThe barrel — import every widget and its exported row types from @/components/widgets.
src/components/widgets/primitives/WidgetHeader.tsxBorderless card header (title · subtitle · optional icon · action slot · ⋯ menu by default).
src/components/widgets/primitives/WidgetMenu.tsxThe ⋯ overflow Dropdown — presentational demo actions (Refresh / Export / Details / Fullscreen / Hide) that fire toasts.
src/components/widgets/primitives/WidgetSection.tsxLabelled page section — icon-chip heading + description + a Stagger responsive grid.
src/components/widgets/primitives/TrendPill.tsxThe up/down percentage chip used across all widgets (sign drives arrow + success/danger tint).
src/components/widgets/primitives/Sparkline.tsxDependency-free SVG sparkline (line/area/bar) with an animated left→right clip-rect reveal; colored via currentColor.
src/components/widgets/primitives/MiniBarChart.tsxWeekly highlighted bar chart (react-chartjs-2 Bar) — one solid bar, the rest at ~35% alpha, staggered grow-in.
src/components/widgets/KpiTile.tsxThe KPI stat tile with eight variant styles.
src/components/widgets/DonutWidget.tsxchart.js Doughnut with a centered animated total, per-segment share rows, and footer stats.
src/components/widgets/GaugeWidget.tsxSegmented half-doughnut gauge (chart.js) + tone banner + optional sub-score bars + bottom stat tiles.
src/components/widgets/AreaTrendWidget.tsxRecharts area chart with a period Dropdown, headline value, breakdown rows, and footer stats.
src/components/widgets/BarStatWidget.tsxHeadline value + trend over a MiniBarChart with locale-aware weekday labels (Intl, no i18n keys).
src/components/widgets/BreakdownWidget.tsxStacked proportion bar + legend + optional 12-bar activity strip + footer stats.
src/components/widgets/StatListChartWidget.tsxIcon stat rows over a trend sparkline, with footer stats.
src/components/widgets/ActivityFeedWidget.tsxActivity feed — colored icon rows with title, detail, timestamp.
src/components/widgets/NotificationsWidget.tsxAvatar notification rows with unread dots and a count Badge.
src/components/widgets/TimelineWidget.tsxVertical timeline — time, colored node, title + description.
src/components/widgets/PeopleListWidget.tsxPeople list — avatar + presence, badge, count/rating meta.
src/components/widgets/WelcomeWidget.tsxGreeting hero in three variants (gradient/panel/lines) sharing one vertical layout.
src/components/widgets/ReportPanelWidget.tsxRich full-width report — headline revenue block, featured stats, highlight chips, team progress bars, monthly-goal footer.
src/components/widgets/MiniCalendarWidget.tsxMonth mini-calendar (reuses the calendar app's MiniCalendar) + upcoming-events list.
src/components/widgets/gcp/* (hues.ts + 4 cards)Google-Cloud-console set: service tile, quota meter, billing card, status list; skin-aware GcpHue accents.
src/components/widgets/firebase/* (2 cards)Firebase-console set: metric tile + plan-usage card with a primary-gradient header.
src/locales/en/widgets.json (+ ja/…)The widgets i18n namespace — 214 keys, all showcase labels.
src/hooks/useChartTokens.tsReads the raw token vars for canvas charts; builds the categorical series palette (Prism's --c1..--c6 when present, else hue-rotation around --primary).

The route is code-split:

src/app/[locale]/(app)/widgets/page.tsx   ← routes are code-split per page by default

Usage

Import from the barrel and drop widgets into a Stagger grid (each widget carries its own staggerItem variant, so the grid cascades them in automatically):

import {Stagger} from '@/components/motion'
import {KpiTile, DonutWidget, WidgetSection} from '@/components/widgets'
import {DollarSign} from 'lucide-react'
 
<WidgetSection title={t('chartsTitle')} subtitle={t('chartsSubtitle')} gridClassName="lg:grid-cols-3">
    <KpiTile variant="flat" tone="primary" icon={DollarSign} label={t('mRevenue')} value={48250} prefix="$" delta={12.4} spark={[12, 18, 14, 22, 19, 27, 24, 31]} />
    <DonutWidget
        title={t('donutTitle')}
        subtitle={t('donutSubtitle')}
        total={4820}
        totalLabel={t('donutTotal')}
        segments={[
            {label: t('segCity'), value: 2100},
            {label: t('segAirport'), value: 1450},
        ]}
    />
</WidgetSection>

WidgetSection provides the heading and the Stagger grid; outside a Stagger, a widget simply renders without the entrance cascade. All widget text arrives as props — pass t(…) results, never literals.

API / Props

All widgets share the pattern: an outer motion.div with variants={staggerItem} and h-full, a Card/token-styled shell, a WidgetHeader, and (where a footer exists) an mt-auto bottom-anchored stat row. Tones are the five semantic keys primary | success | warning | danger | info unless noted.

Primitives

WidgetHeader

PropTypeDefaultDescription
titleReactNode—The card heading (truncated).
subtitleReactNode—Small descriptor under the title.
actionReactNode—Right-aligned slot (filter, badge, "view all"…), shown before the ⋯ menu.
menubooleantrueRender the WidgetMenu ⋯ overflow. Pass menu={false} to opt out.
iconReactNode—Leading node before the title (e.g. a 3D illustration <img>).
toneTone—When set, the title renders as an uppercase tinted label (the "engage" look).
classNamestring—Extra classes on the header row.

WidgetMenu takes no props — it is the demo ⋯ Dropdown (Refresh / Export / View details / Fullscreen / Hide, i18n'd via widgets:menu*) whose items fire an info toast. Replace it with a real menu by passing your own action and menu={false} to WidgetHeader.

WidgetSection — title: string, subtitle: string, icon?: ReactNode (rendered in a primary-tinted chip), gridClassName?: string (grid column classes for the Stagger body), children.

TrendPill — value: number (signed percentage; sign drives the arrow and the success/danger tint), suffix?: string (trailing context like "vs last week"), className?.

Sparkline

PropTypeDefaultDescription
datanumber[]—The series (auto-scaled to min/max).
variant'line' | 'area' | 'bar''area'Smoothed Catmull-Rom curve (line/area) or baseline-growing bars.
toneTone'primary'Sets the text-color class; the SVG draws with currentColor, so it re-skins without useChartTokens.
width / heightnumber120 / 40SVG viewBox only — the element scales to its container via h-full w-full.

The line/area draw-in is an animated clip rect whose width grows from x=0 — strictly left→right, robust under preserveAspectRatio="none" (a stroke-dash pathLength would render partially there). Under reduced motion the full curve renders immediately.

MiniBarChart — data: number[], labels: string[], highlightIndex: number (the one bar drawn at full opacity; the rest use the same token at ~35% alpha via #rrggbbaa), tone? (default 'primary'). A chart.js Bar (registers BarElement/CategoryScale/LinearScale/Tooltip) with a staggered grow-in, disabled under reduced motion; colors from useChartTokens().

KPI tiles

KpiTile (KpiTileProps, exported)

PropTypeDefaultDescription
labelstring—Metric name.
valuenumber—Count-up headline via AnimatedNumber.
iconLucideIcon—Metric icon.
variantKpiVariant'flat'flat | minimal | gradient | glass | outline | chip | backgroundIcon | flip.
toneTone'primary'Accent tone for icon chip / gradient / dot.
deltanumber—Renders a TrendPill.
hintstring—TrendPill suffix (e.g. "vs last week").
sparknumber[]—Renders an inline Sparkline (variant-dependent placement).
prefix / suffix / decimalsstring / string / number—Passed through to AnimatedNumber.
tintbooleanfalseSoft-tinted card surface — flat variant only.

Solid-fill variants (gradient, the flip back face) use the text-*-foreground token map — never text-white — so contrast holds on skins with dark foregrounds (e.g. Amber). flip is a CSS 3D hover-flip (icon+label front, gradient value back).

Chart cards

DonutWidget — title, subtitle, total: number, totalLabel, prefix?, segments: DonutSegment[] ({label, value} — colored by cycling useChartTokens().series), footer?: {label; value; delta?}[], tint? (soft-tinted surface + uppercase tinted header), icon?. chart.js Doughnut, cutout: '68%', centered AnimatedNumber total, per-segment share rows with proportional bars.

GaugeWidget — title, subtitle, value (0–100), valueLabel, tone?: 'primary' | 'success' | 'warning' | 'info' (default primary), segments? (arc tick count, default 15), bannerIcon: LucideIcon, bannerText, stats: {label; value; delta?}[] (bottom stat tiles), breakdown?: {label; value}[] (labelled sub-score bars). A chart.js half-doughnut (rotation: -90, circumference: 180) of rounded ticks filled to the value.

AreaTrendWidget — title, subtitle, calloutLabel, periods: TrendPeriod[] ({key, label, change, data: {x; y}[], value?} — a period Dropdown in the header switches the chart and headline), breakdownLabel?, breakdown?: {label; value; delta?}[], footer?: {label; value}[]. The one Recharts widget (gradient AreaChart); axis/tooltip/stroke colors from useChartTokens().

BarStatWidget — title, subtitle, value: string, delta: number, data: number[] (7 values), highlightIndex, tone?, breakdownLabel?, breakdown?: {label; value; delta?}[], stats?: {label; value}[]. Weekday x-labels are generated with Intl.DateTimeFormat from the active language — dates never get i18n keys.

BreakdownWidget — title, subtitle, rows: BreakdownRow[] ({label, value, tone, delta?}; tone includes neutral), total? (defaults to the row sum), unit?, delta?, activity?: number[] (0–100 mini activity strip), activityLabel?, footer?: {label; value}[], tint?, icon?. Stacked proportion bar + legend, all DOM/token-based (no canvas).

StatListChartWidget — title, subtitle, rows: StatRow[] ({icon, tone, label, sublabel, amount, delta}), spark: number[], sparkTone?, sparkLabel?, footer?: {label; value}[].

Lists & feeds

ComponentKey props
ActivityFeedWidgettitle, subtitle, items: ActivityItem[] ({icon, tone, title, text, time}), action?.
NotificationsWidgettitle, subtitle, items: NotificationItem[] ({name, avatar?, text, time, unread?}), count? (header Badge).
TimelineWidgettitle, subtitle, items: TimelineItem[] ({time, tone, title, desc}).
PeopleListWidgettitle, subtitle, people: Person[] ({name, avatar?, status?, subtitle, badge?, time?, count?, rating?}), action?, compact?, unit? (count label — pass a translated one; the fallback is literal 'tasks').

Composite widgets

WelcomeWidget — greeting, name, message, ctaLabel, onCta?, avatar?, variant?: 'gradient' | 'panel' | 'lines' (default gradient), dateLabel? (chip line), stats?: {label; value}[] (3-up mini-stat row). All three variants share one vertical layout (chip · avatar+greeting · message · stats · CTA) for consistent spacing and equal height — only the background/text treatment differs. lines layers a HeroCanvas variant="lines" (tones ['primary', 'info']) under a surface gradient scrim; gradient is a primary gradient with text-primary-foreground.

ReportPanelWidget — title, subtitle, revenueLabel, revenue: number, revenuePrefix?, revenueDelta, compareLabel, stats: ReportStat[] ({label, value, delta} featured tiles), highlights?: {label; value; delta?}[] (chip strip), teamsLabel, teams: TeamBar[] ({name, avatar?, value, percent} — ranked Progress bars), goalLabel? + goalPercent? + goalCaption? (monthly-goal Progress footer).

MiniCalendarWidget — title, subtitle, events: CalEvent[] ({date: 'YYYY-MM-DD', title, time, tone} — dates dot the calendar, the first 3 list as "upcoming"), upcomingLabel?. Reuses the calendar app's MiniCalendar with the active i18n.language.

Google Cloud & Firebase sets

Accents in these sets use the GcpHue = 'primary' | 'info' type from src/components/widgets/gcp/hues.ts — see the skin rule.

ComponentKey props
GcpServiceTileicon, name, hue: GcpHue, value: number (AnimatedNumber), unit, statusLabel, healthy? (default true; drives the primary/info status dot).
GcpUsageMetertitle, subtitle, rows: QuotaRow[] ({label, used, limit, unit, hue} meters), footer?: {label; value}[].
GcpBillingCardlabel, amount: number, delta, projectLabel + project, creditLabel + credit, breakdownLabel? + breakdown?: SpendRow[] ({label, value, pct} — bars cycle primary → info), forecastLabel? + forecast?. Top accent bar is a primary→info gradient.
GcpStatusCardtitle, subtitle, services: ServiceStatus[] ({name, region, health: 'ok' | 'warn' | 'down', healthLabel} — ok/warn map to primary/info so they re-skin; only down keeps the universal danger red).
FirebaseMetricTileicon, name, value: number, unit?, delta?, prefix?.
FirebaseUsageCardtitle, planLabel, rows: FirebaseUsageRow[] ({label, used, limit, unit} — bars + per-row remaining caption), remainingLabel?, footer?: {label; value}[]. Primary-gradient header with text-primary-foreground.

Configuration & customization

The equal-height flex-fill chain

Grid cells stretch, but card content top-aligns — so a short card in a row of tall ones would show a dead gap at the bottom. Every widget therefore builds a flex-fill chain and bottom-anchors its footer:

<motion.div variants={staggerItem} className="h-full">   {/* the grid cell fills */}
    <Card className="flex h-full flex-col">              {/* the card fills the cell */}
        <CardBody className="flex flex-1 flex-col">      {/* the body absorbs the height */}
            <WidgetHeader … />
            {/* …content; an inner chart area can take flex-1 too… */}
            <div className="mt-auto grid … border-t border-border pt-4">{/* footer pins to the bottom */}</div>
        </CardBody>
    </Card>
</motion.div>

Follow this chain in any new widget: h-full wrapper → Card flex h-full flex-col → CardBody flex flex-1 flex-col → mt-auto on the footer/stat row. Where a card still reads short, add real content (a footer stat row, a breakdown, an activity strip) rather than leaving whitespace — that's why most widgets accept optional footer/breakdown/activity props.

Chart colors & useChartTokens()

The chart.js widgets (DonutWidget, GaugeWidget, MiniBarChart/BarStatWidget) render to a <canvas>, which cannot resolve CSS variables — so they call useChartTokens() (src/hooks/useChartTokens.ts), which reads the raw --primary/--info/--foreground/… values off <html> and re-reads via a MutationObserver on class/data-skin/data-oled. Every canvas chart therefore re-colors live when the user switches theme or skin. The Recharts AreaTrendWidget uses the same hook (Recharts takes literal color strings too). The SVG Sparkline doesn't need it — it draws with currentColor from a tone text class. Canvas animations (donut rotate-in, gauge fill, bar grow-in) are all gated by useReducedMotion(). See Charts for the full useChartTokens story.

The skin rule: adaptive accents are primary/info only

Color skins override only --primary and --info — not --success/--warning/--danger, which stay semantic. So:

  • Brand/decorative accents that should re-skin use only primary and info — the GcpHue type, the alternating breakdown dots in AreaTrendWidget/BarStatWidget/GcpBillingCard, the Firebase gradient. GcpStatusCard maps ok/warn to primary/info for the same reason; only a hard down keeps danger red.
  • True status meaning (TrendPill up/down, health down, warning banners) keeps success/warning/danger.
  • The multi-hue categorical palette (useChartTokens().series, used by DonutWidget) prefers a skin-provided --c1..--c6 palette (the Prism skin defines one — see Design skins); otherwise it anchors on the exact primary and hue-rotates around it with normalized saturation/lightness, so it stays distinct on every skin (including mono Graphite) and in dark mode.

Text & i18n

The showcase's labels live in src/locales/<lng>/widgets.json (en + ja at parity; other languages via npm run i18n:translate). Widgets take translated strings as props — when you add a widget instance, add its labels to the widgets namespace and pass t('widgets:…'). Only WidgetMenu translates internally (menuLabel/menuRefresh/menuExport/menuDetails/menuFullscreen/menuHide/menuToast). Dates (the hero date chip, BarStatWidget weekdays, MiniCalendarWidget) come from Intl with the active language — never i18n keys.

Examples

A KPI row (one line per tile)

<Stagger className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
    <KpiTile variant="flat" tone="primary" icon={DollarSign} label={t('mRevenue')} value={48250} prefix="$" delta={12.4} spark={spark} />
    <KpiTile variant="chip" tone="info" icon={Activity} label={t('mActive')} value={128} delta={6.4} />
    <KpiTile variant="gradient" tone="success" icon={TrendingUp} label={t('mSuccess')} value={4.6} suffix="%" decimals={1} delta={2.1} spark={spark} />
    <KpiTile variant="backgroundIcon" tone="danger" icon={Flame} label={t('kpiBounce')} value={32.1} suffix="%" decimals={1} delta={-4.2} />
</Stagger>

A gauge with sub-scores and bottom stats

<GaugeWidget
    title={t('gaugeTitle')}
    subtitle={t('gaugeSubtitle')}
    value={84}
    valueLabel={t('gaugeValueLabel')}
    tone="primary"
    bannerIcon={BadgeCheck}
    bannerText={t('gaugeBanner')}
    stats={[
        {label: t('gaugeOnTime'), value: '92%', delta: 3.1},
        {label: t('gaugeRating'), value: '4.8', delta: 1.4},
    ]}
    breakdown={[
        {label: t('gaugeSpeed'), value: 88},
        {label: t('gaugeReliability'), value: 94},
    ]}
/>

A custom header action instead of the demo ⋯ menu

<WidgetHeader
    title={t('trendTitle')}
    subtitle={t('trendSubtitle')}
    menu={false}
    action={<Button variant="outline" size="sm">{t('period7d')}</Button>}
/>

Best practices

  • Keep the flex-fill chain intact when composing or cloning a widget — h-full wrapper → flex h-full flex-col card → flex flex-1 flex-col body → mt-auto footer. Dropping any link reintroduces the dead-gap problem.
  • Canvas colors only via useChartTokens(). Never pass hex literals to chart.js/Recharts — they won't follow theme or skin changes.
  • Respect the skin rule. New decorative accents: primary/info. New status semantics: success/warning/danger. Multi-hue series: tk.series.
  • Use text-*-foreground on solid fills, never text-white — Amber and Bento define dark foregrounds.
  • Pass translated strings. Widgets are i18n-agnostic by design; the caller owns t(). Add keys to the widgets namespace (en + ja) for anything user-facing.
  • Render widgets inside a Stagger (or WidgetSection) so the built-in staggerItem entrance actually plays; gate any new imperative animation on useReducedMotion().
  • Prefer real content over whitespace — use the optional footer/breakdown/activity/stats props to balance card heights in a row.

Troubleshooting

SymptomCause / fix
A chart keeps its old colors after a theme/skin switchThe color was hardcoded instead of read from useChartTokens() — the hook re-reads on class/data-skin/data-oled mutations; literals never update.
Cards in a row have uneven heights / bottom gapsA link in the flex-fill chain is missing (h-full → flex h-full flex-col → flex flex-1 flex-col → mt-auto footer), or the row isn't a stretching grid.
Widgets don't animate inThey must sit inside a Stagger (e.g. via WidgetSection) — the staggerItem variants only run under a stagger container. Reduced motion also disables entrances.
The sparkline reveals from the center or renders partiallyKeep the animated clip-rect reveal — a stroke-dash pathLength breaks under preserveAspectRatio="none". Don't add a transform-origin-based reveal.
The ⋯ menu shows on a widget where it doesn't belongWidgetHeader renders WidgetMenu by default — pass menu={false}.
The ⋯ menu actions "do nothing" realBy design — WidgetMenu is presentational (toast per action). Wire a real menu via the action slot + menu={false}.
A GCP/Firebase accent doesn't change with the skinIt used success/warning/danger — skins don't override those. Use primary/info (GcpHue).
Gradient KPI text is unreadable on the Amber skinSomething used text-white instead of the FG map (text-primary-foreground etc.). Use the token foreground.

FAQ

Are these the same as StatCard or Panel? No. StatCard is a single dashboard stat primitive and Panel is the general collapsible portlet. The widgets library is a catalog of purpose-built dashboard cards with their own borderless WidgetHeader (no divider, no collapse toolbar).

Which chart library do they use? chart.js (react-chartjs-2) for the donut, gauge, and weekly bars — registering only the elements each needs — plus Recharts for AreaTrendWidget and a dependency-free SVG Sparkline. The dedicated chart showcases live under /charts (see Charts).

Do widgets fetch data? No — they are pure presentational components. Pass values from your API layer (see src/lib/api.ts) or state.

Can I use a widget outside the showcase? Yes — everything is exported from @/components/widgets. The home dashboards compose similar patterns; the showcase is the reference catalog.

Why does the demo menu export nothing? The showcase is a template gallery — WidgetMenu proves the interaction pattern (portal, separator, danger tone) and fires toasts; production apps replace it.

Notes for designers & content editors

  • All showcase copy lives in src/locales/<lng>/widgets.json — edit labels there, never in components. Names of people/products in the demo data are literal by convention.
  • Colors re-skin automatically. Every widget follows the token system; switching skin or dark mode re-colors tiles, charts, gauges, and the GCP/Firebase accents with zero per-widget design work.
  • Widget headers are deliberately borderless — no divider band. Keep titles short (they truncate) and subtitles to a terse descriptor, matching the Panel subtitle convention.
  • The welcome heroes share one layout across all three variants so a row of them lines up; pick a variant by mood (vivid gradient · quiet panel · animated lines), not by structure.
  • Trend pills always color by sign (green up / red down) — don't request inverted semantics per widget.

Was this page helpful?