Recipe: building a console/dashboard page

You're building something like Vercel's project dashboard, a Datadog-style usage console, or an admin panel — a sidebar or topbar, a project/workspace switcher, a grid of cards with row actions, KPI tiles, and usage sparklines or time-series charts. Every part below already ships in cascivo. This page maps the need to the exact component, in one place, so you don't reach for custom SVG or a separate headless library.

Prerequisite reading: GETTING-STARTED.md for the two install paths (copy-paste vs. @cascivo/react). Everything below works with either.

The Channel column says how you get each entry: a package name means you can import { X } from '<package>', and copy-paste means it has no npm export — run npx cascivo add <registry id> to own the source, or compose it from exported primitives. The column is generated from registry.json and checked by scripts/checks/recipe-channels.test.ts, so it cannot drift.

Component map

NeedUseRegistry idChannelNotes
Workspace/project switcher — collapsed trigger (the usual console shape)Dropdowndropdown@cascivo/reactWhat Vercel, Linear and GitHub all ship: one row showing the current workspace, a menu on click. Put an Avatar + name in the trigger.
Workspace list — always visible in the sidebarSwitcherswitcher@cascivo/reactRenders every entry, permanently, at full height — budget the vertical space. Despite the name it does not collapse; an adopter picked it from the name and got both teams stacked in the rail (2026-08-21 report item 5).
Command palette (⌘K)CommandMenucommand-menu@cascivo/reactFull keyboard nav, fuzzy search, page-stack; native <dialog> focus trap built in.
Right-click / row actionsContextMenu, or OverflowMenu for a visible "⋯" triggercontext-menu, overflow-menu@cascivo/reactRight-click is undiscoverable on touch — pair ContextMenu with a visible OverflowMenu/Dropdown for the same actions, don't ship it as the only path.
Page structure (shell content, toolbars, card grids)Grid/GridItem, AutoGrid, Flexlayout/grid, layout/auto-grid, layout/flex@cascivo/reactGrid/GridItem take responsive object props (cols={{ base: 1, md: 2, lg: 3 }}); AutoGrid fills columns by width with no media queries; Flex is the gap-based flex container. Reach for these before writing custom layout CSS.
Project-card gridCard + Badge (framework/status) + RelativeTime (last deploy), laid out in AutoGrid/Gridcard, badge, relative-time, layout/auto-grid@cascivo/reactAutoGrid min="16rem" gives a responsive card grid with no media queries; use Grid cols={{…}} for an explicit responsive column count. Under SSR pass RelativeTime's now (a serialized server timestamp) for byte-identical server/client text — every deploy console has a "3 minutes ago" column. CardHeader actions={…} gives the title-left / menu-right header.
KPI / usage numbersStat, or Kpi for a chart-library tilestat, chart/kpi@cascivo/react / @cascivo/chartsStat is layout-only (label/value/delta/trend); Kpi (from @cascivo/charts) bundles a trailing sparkline — see below. ⚠ Pick one per app. <Stat card> matches Kpi's chrome (surface/border/radius/padding) but not its layout — Kpi puts value and delta on one line with the sparkline below, Stat stacks value → delta → help text with visual trailing — so the two rows still read as different tile designs. Use Kpi when you have a numeric delta it should format and a sparkline; Stat otherwise.
Usage sparklines (inline, no axes)Sparklinechart/sparkline@cascivo/chartsnpm: @cascivo/charts. Token-scaled via --cascivo-chart-*. ⚠ Fixed-width (120×32 by default) — unlike every other chart it does not track its container, so in a tight card header it pushes siblings onto the next line. Give it a smaller width, or protect the text and let the chart keep its intrinsic size: white-space: nowrap on the text and flex: 0 0 auto on the chart. ⚠ Do not put min-width: 0 on the sparkline's flex item — the SVG has a fixed width attribute so it will not shrink, and the text becomes the flexible item and wraps instead (2026-08-22 report item 18).
Time-series usage charts (with axes, zoom, live data)LineChart / AreaChartchart/line-chart, chart/area-chart@cascivo/chartsBoth support time scales — return a Date from x to get one. Returning a number gives a linear axis labelled with the raw value, so Date.now()-shaped data renders as 1,787,250,000,000 (it warns in dev). Add format for sub-day buckets, whose built-in time format repeats the same label. Multi-series, brush/zoom. For live-updating usage graphs, feed them with useStreamSeries (@cascivo/charts). Multi-series colours are automatic: the Nth series takes --cascivo-chart-N (eight distinct hues per theme, light and dark), so a two-series chart differentiates itself with no color prop. Set color on a series only to override — e.g. to make "errors" red regardless of position.
Data table of deployments/rowsDataTabledata-table@cascivo/reactSorting/pagination/search built in. Set Column.width (any CSS length) on identifier-shaped columns — default sizing doesn't consider content shape, so a commit hash wraps mid-hash. Size SOME columns, not all: sizing every one flips the table to table-layout: fixed, which can overflow its container (the far columns are then reachable by horizontal scroll, not dropped). Leave at least one free-form column unsized to absorb the remaining width. Sized and unsized columns alike have a content floor, so minWidth is only for raising it.
Page header (title + description + breadcrumb + actions)PageHeaderlayout/page-header@cascivo/reactEvery routed page needs one. Now exported — do not hand-compose it from Heading/Text/Flex, and don't npx cascivo add it just for this (that mixes consumption paths). Pair breadcrumb={<Breadcrumb …/>} with actions={<Button …/>}.
Empty state before first deploy/projecta dedicated empty-state blockblock/empty-dashboardcopy-pasteFull page: empty illustration/copy + CTA, ready to adapt.

Whole-page starting points

Don't build from the component list above if one of these already matches — start from the block/template and adapt it, which is faster and more consistent than composing from scratch:

Block/templateRegistry idWhat it gives you
Full KPI-tiles + charts dashboardblock/dashboard-chartsKPI tiles, line chart, bar chart, and pie chart wired together over sample data.
KPI stat-card grid onlyblock/stats-cardsGrid of Stat cards with trend badges — the "four numbers at the top" row.
Welcome header + KPI cardsdashboard-overviewHeader + four KPI stat cards (revenue, users, orders, …).
Searchable/sortable/paginated table pagedashboard-table, block/users-table-pageDataTable wired with search, sort, pagination, export/invite actions.
App shell with collapsible sidebarapp-shell, block/sidebar-appSidebar + topbar + content area chrome to host any of the above.
Carbon-parity console shell (icon-rail side nav)block/console-appShellHeader + icon-rail SideNav + content area — denser, IDE-like console chrome.

Before scaffolding by hand, check list_templates (MCP) or /docs/marketplace for a whole-page dashboard template — see the cascivo-design-page skill's step 0.

Reference implementations

Five full example apps compose exactly this component set into real consoles — read one end-to-end rather than starting from a blank file:

Bundle size, and the 500 KB warning on your first build

A six-route console drawing on a few dozen of the catalog's components plus three chart types measures roughly 540 KB JS / 177 KB gzip and 166 KB CSS / 21 KB gzip in production. Vite prints its default chunk-size-limit warning at 500 KB, so a stock cascivo dashboard trips that warning on the first build. That is alarming and worth explaining: it is one eagerly-loaded chunk containing every route, not a signal that something is wrong.

The fix is ordinary route-level code splitting, which every router supports — including the index route. That last clause is the whole trick, and omitting it is what has cost two adopters a build cycle each:

// React Router — lazy route modules. Note `/` is lazy too, not just the "big" routes.
{ index: true, lazy: () => import('./routes/overview') },
{ path: 'analytics', lazy: () => import('./routes/analytics') },

// TanStack Router — the same idea
createFileRoute('/analytics')({ component: lazyRouteComponent(() => import('./analytics')) })

Charts are the single biggest win: @cascivo/charts is a real charting engine, and a console typically renders charts on one or two routes out of six. Splitting those routes keeps the engine out of the initial chunk entirely.

⚠ Split the index route too, or the chart engine lands in your entry chunk anyway

"Split the chart routes" reads as not including the landing page, and an adopter took it that way (2026-08-08 report B). They split /analytics exactly as above, left / eager, and measured:

dist/assets/index-*.js     524.70 kB   ← still over the limit
dist/assets/analytics-*.js   2.88 kB   ← the "big" chart route

The analytics chunk was 2.9 kB because @cascivo/charts was already in the entry chunk: the Overview page uses Sparkline in its KPI tiles and project cards — which is what "Composing a KPI tile with a sparkline" below recommends. One Sparkline on an eagerly-loaded route pulls the engine into the entry chunk, and route-splitting the chart pages then buys almost nothing.

Making / lazy like every other route fixes it, at no cost. A later adopter did exactly that on the same shape of app and measured (2026-08-21 report):

dist/assets/index-*.js     413.07 kB / 133.25 kB gzip   ← no warning
dist/assets/dist-*.js       44.87 kB /  14.84 kB gzip   ← the chart engine, shared

Sparklines on the landing page and an entry chunk under the limit. The engine moves to a shared chunk that every chart route reuses; the landing page fetches it in parallel with its own chunk rather than serialised behind the entry.

One sparkline and no engine at all. If a page wants a trend line but draws no real charts — a marketing page, a KPI strip — import from the engine-free subpath instead:

import { Sparkline } from '@cascivo/charts/sparkline'   // ~3.5 kB gzip, no chart engine

Same chart, same props, same markup; the one difference is that it has no hover tooltip, because the tooltip is what requires the engine. Use the main entry when the page draws other charts anyway — the engine is then already paid for and the subpath saves nothing.

Two footnotes:

  • Do not pre-emptively raise build.chunkSizeWarningLimit. The 2026-08-21 adopter set it to 700 on the strength of an earlier version of this box, measured, and deleted it again — it was never needed. Raise it only after you have measured and decided the number is fine.
  • Expect a HydrateFallback warning once your routes are lazy — it is React Router's, not cascivo's, and USING-WITH-A-ROUTER.md has the two-line fix.
  • Verify rather than assume. grep the built bundles for a chart-engine symbol to confirm which chunk it landed in. Chunking is a property of your import graph, not of cascivo.

The CSS number behaves differently and needs no action — per-component tree-shaking already dropped ~40% of the aggregate sheet (166 KB of 273 KB) — except under SSR, where the aggregate import is required; see USING-WITH-VITE-SSR.md.

Composing a KPI tile with a sparkline

The canonical dashboard tile — a number, a trend, and a tiny chart — is either:

// Layout-only Stat, with a sparkline in its trailing `visual` slot
import { Stat } from '@cascivo/react'
import { Sparkline } from '@cascivo/charts'

<Stat
  label="Requests / min"
  value="1.2k"
  delta="+4.3%"
  trend="up"
  visual={<Sparkline data={requestsPerMinute} label="Requests per minute trend" />}
/>
// Or the charts package's own KPI tile, sparkline built in
import { Kpi } from '@cascivo/charts'

<Kpi
  label="Requests / min"
  value={1200}
  delta={4.3}
  deltaFormat="percent"   // renders +4.3% — `delta` is a number, Kpi owns the formatting
  sparkline={requestsPerMinute}
/>

Use Stat when the tile is pure layout and you want full control of the trailing visual; use Kpi when you want the chart-library tile with the sparkline baked in. The two disagree deliberately about who formats: Stat takes delta as a pre-formatted string (you own it), Kpi takes a number and owns the sign, arrow, colour and unit — pass deltaFormat="percent" for +4.3%, or a function for anything else.

Sizing charts — omit width

Every chart in @cascivo/charts is responsive by default. Omit width and it fills its container, tracking resizes through a ResizeObserver. There is no config, no wrapper and no container query to write:

<Card>
  <CardHeader>Requests</CardHeader>
  <CardContent>
    <AreaChart title="Requests" series={series} x={(d) => d.t} y={(d) => d.v} height={240} />
  </CardContent>
</Card>

Two axis-chrome details worth knowing, both automatic:

Importing from more than one cascivo package

A dashboard file often imports from @cascivo/react, @cascivo/charts and @cascivo/icons at once. No name clash remains — the last one is fixed rather than documented:

Name@cascivo/react@cascivo/charts
Textthe typography component— renamed to ChartText
import { Text } from '@cascivo/react' // typography
import { ChartText } from '@cascivo/charts' // SVG <text> primitive

@cascivo/charts no longer exports Text — the deprecated alias of ChartText was removed at 1.0, as this page said it would be. It was the name whose wrong resolution was silent: the SVG primitive rendered where a paragraph was meant and nothing errored. If you are upgrading from 0.x, import { Text } from '@cascivo/charts' is now a compile error; rename it to ChartText (and TextProps to ChartTextProps).

Calendar used to clash the same way. The charts heatmap is now CalendarHeatmap, so Calendar unambiguously means @cascivo/react's date picker and no alias is needed:

import { Calendar } from '@cascivo/react' // date picker
import { CalendarHeatmap } from '@cascivo/charts' // activity heatmap

@cascivo/icons shares names with both by nature — an icon set of ~440 nouns contains Search, Filter, Grid, User, BarChart, PieChart. The convention is the same one every icon library uses: import { Search as SearchIcon } from '@cascivo/icons'. scripts/checks/export-collisions.test.ts fails CI on a new react↔charts collision, and caps the icons overlap below so it cannot grow unnoticed.

These 20 names exist in both @cascivo/icons and a component package (@cascivo/react or @cascivo/charts). Both are valid JSX in the same position, so a wrong import resolves silently instead of failing to compile — alias the icon:

NameAlias the icon as
BarChartimport { BarChart as BarChartIcon } from '@cascivo/icons'
Calendarimport { Calendar as CalendarIcon } from '@cascivo/icons'
Codeimport { Code as CodeIcon } from '@cascivo/icons'
Columnimport { Column as ColumnIcon } from '@cascivo/icons'
Filterimport { Filter as FilterIcon } from '@cascivo/icons'
Gaugeimport { Gauge as GaugeIcon } from '@cascivo/icons'
Glyphimport { Glyph as GlyphIcon } from '@cascivo/icons'
Gridimport { Grid as GridIcon } from '@cascivo/icons'
Heatmapimport { Heatmap as HeatmapIcon } from '@cascivo/icons'
Imageimport { Image as ImageIcon } from '@cascivo/icons'
Linkimport { Link as LinkIcon } from '@cascivo/icons'
Listimport { List as ListIcon } from '@cascivo/icons'
Menuimport { Menu as MenuIcon } from '@cascivo/icons'
PieChartimport { PieChart as PieChartIcon } from '@cascivo/icons'
Radarimport { Radar as RadarIcon } from '@cascivo/icons'
Radioimport { Radio as RadioIcon } from '@cascivo/icons'
Searchimport { Search as SearchIcon } from '@cascivo/icons'
Stepsimport { Steps as StepsIcon } from '@cascivo/icons'
Tagimport { Tag as TagIcon } from '@cascivo/icons'
Userimport { User as UserIcon } from '@cascivo/icons'

Guessing a familiar name is fine. @cascivo/icons exports the common aliases from other sets as real names, so Rocket (→ Spaceship), LayoutDashboard (→ Dashboard), MagnifyingGlass, Gear, Bolt and ~35 more resolve without a lookup. The exceptions are names already claimed elsewhere — Kbd is a component, LineChart is a chart, Box, Delete and Trash2 are icons in their own right — which stay searchable in the icon gallery but are deliberately not exported as aliases.

Don't hand-roll the behavior layer

Dropdown, Menu, ContextMenu, Combobox, CommandMenu, MultiSelect, and Tabs ship with keyboard navigation, focus management, and outside-click dismissal already implemented (native <dialog>/Popover APIs plus @cascivo/core primitives). If you find yourself writing a document .addEventListener('mousedown', ...) outside-click handler or your own arrow-key switch statement for a menu, stop — the component you're wrapping already does it.

As Markdown: /docs/recipe-dashboard.md

← All guides