snow-uiProhome

Analytics

Whether the product is healthier than last period and why: retention cohorts, funnels and acquisition.

Product managers and growth analysts. Shell: top navigation, full-width charts. Open the live preview (on the mock backend). Set up snow-ui and the registries first (installation); the template guides cover other routers than Next.js.

What's included

Pages (registry:page)

Each page is a client component that you mount from a route of your app with the data the server rendered for the URL; it reads the adapter, the routes and the formatting from AnalyticsTemplateProvider, and re-queries the adapter itself when a filter changes.

#ItemComponent and its dataSuggested route
A1overview-pageOverviewPage (filters, metric, initial: summary, activeUsers, movers, platforms, topEvents)/analytics?range=&compare=&segment=&platform=&metric=
A2retention-pageRetentionPage (filters, period, returnEvent, display, initial)/analytics/retention?…&period=&return=&display=
A3funnels-pageFunnelsPage (filters, funnel, savedId, initial: funnel, funnels, catalogue)/analytics/funnels?…&funnel= or &steps=&window=&breakdown=
A4acquisition-pageAcquisitionPage (filters, utm, initial)/analytics/acquisition?…&utm=
A5reports-pageReportsPage (query: search, view, owner, layout; initial), ReportNotFoundPage (kind: report or funnel, description)/analytics/reports?q=&view=&owner=&layout=
A6report-builder-pageReportBuilderPage (report, or draft for a new one; initial: result, catalogue)/analytics/reports/[id] (new, ?template= or ?event=)
—analytics-templateAnalyticsTemplateProvider, AnalyticsShell, AnalyticsFilterBar, useAnalyticsTemplate, useAnalyticsResource, useAnalyticsFilters, useAnalyticsState, analyticsRoutes, withSearch, the labels and formatters, copyLink, exportCsvthe analytics routes' layout

The four shared filters (range, compare, segment, platform) live in the URL; analyticsFiltersFromParams / analyticsFiltersToParams read and write them, and the shell's links keep them from page to page. The URL leaves out what matches the defaults (DEFAULT_ANALYTICS_FILTERS): to change them, give the provider defaultFilters ({ compare: true } compares unless the URL says compare=0) and pass the same defaults to analyticsFiltersFromParams(params, defaults) on the server.

The pages follow the URL: after Back, Forward or a link, the filters and each page's own choices (the metric, the cohorts, the funnel, the UTM dimension, the reports' search) say what the URL says. Give the provider your router's search: a router may restore a page the way it first rendered it (Next.js does on Back and Forward), and the URL then wins over the page's first props. Without search the pages follow the browser's Back and Forward (popstate) between their own URLs only. A funnel's URL reads the same on the server and in the page with funnelFromParams.

Blocks (registry:block)

New with this template:

ItemWhat it is
app-shell-topnavThe top-navigation shell: a skip link, the sticky top bar (header) with the brand (or a product switcher), the pages as links (nav “Primary”, aria-current="page") and your actions; below 1024px the links move into a start-side sheet behind “Open the menu” and the current page's name shows by the brand; the page is the main, start-aligned at 1440px (maxWidth="standard": 1200px). embedded for previews.
cohort-tableRetention by cohort as a real table: cohorts are row headers (with their size), periods column headers; every cell prints its value (% or users) and a five-step tint of the blue token repeats it, with a legend; strong tints keep black text, so the contrast holds in both themes; periods a cohort hasn't reached are blank, not 0; cohorts under minCohortSize are muted and marked “Small cohort”; row checkboxes pick up to maxSelected cohorts for a curve (the rest say why they are disabled); the cohort column stays pinned at the start while the grid scrolls sideways.
funnel-chartConversion through steps: each step's bar grows from the start edge, its people and its share of the first step in text, the drop-off between steps in text and, with onDropOffSelect, as a button (“35.6% dropped off (4,420) after Visited pricing: see who”); onStepSelect makes the steps toggle buttons; the previous period as a dashed marker and text; the menu's “Show as a table” lists step, users, conversion from the first and previous step, drop-off and median time.

Shared with the other templates (one implementation each):

ItemHow Analytics uses it
insight-calloutThe overview's headline: the metric's name, the value, its change against the previous period and the sentence on its drivers; “See the drivers” is a fragment link that moves to “What changed”.
kpi-ribbonThe overview's five metrics (each label links to its report) and the retention milestones (vertical).
chart-cardEvery chart: a render function for the chart, menuActions (“Download CSV”, “Copy link”), notice (data still arriving), the period in the description.
ranked-list“What changed”, ranked by how much each metric moved, with the change as a chip.
data-tableTop events, the funnel breakdown, channels (with a totals row), UTM roll-ups, the reports list and the builder's results (sortable, a totals row).
filter-barThe shared filters (period, compare, segment, platform) and the reports' search.
page-header, empty-state, sticky-save-bar, command-menu, user-menuAs in the other templates; the builder's save bar saves with ⌘S / Ctrl+S and guards leaving.

Extended (additively; existing callers render as before):

  • filter-bar: a date-range field (presets and a custom range with the library's DateRangePicker, stored as from..to; it stays in the row at every width and never counts as a filter) and a toggle field (a Switch).
  • chart-card: menuActions and notice; “Show as a table” now shows the chart's own data table in place of the plot (it used to toggle a visually hidden one). Charts keep their hidden table on by default, so screen readers always have it.
  • data-table: actions beside the title and a totals row in the table's footer.
  • insight-callout: showLabel and change; a fragment action.href is a plain anchor.
  • kpi-ribbon: a metric's href (its label links to its report) with linkAs.
  • ranked-list: an item's change, direction and goodDirection (a chip); an empty value leaves the value column out.

Shared items

analytics-types (AnalyticsAdapter, the queries and their results, the filters and their URL params, resolveRange), analytics-params (the routes, withSearch, funnelFromParams), use-follow-search (the pages following the URL), analytics-math (below), csv (toCsv, csvFileName, downloadCsv), format (with points and duration) and density. The mock data is mock-analytics (createMockAnalyticsAdapter, mockAnalyticsDataset(scenario), resolveAnalyticsQuery, runMockQuery, the clock and the scenarios). No page or block imports it: install it only to click through the pages before your backend is wired (a registry test checks that no installable item reaches it, even indirectly).

The whole template: analytics

@snow-ui-pro/analytics is a registry:item without files of its own: its registryDependencies are every page and block above.

Install

Terminal
npx shadcn@latest add @snow-ui-pro/analytics
# Optional: the mock backend, to click through the pages first.
npx shadcn@latest add @snow-ui-pro/mock-analytics

npm dependencies: react-hook-form, @phosphor-icons/react and @holakirr/snow-ui-charts (with its stylesheet, from the free snow-ui-charts item).

Wire it up (Next.js App Router)

tsx
// app/analytics/analytics-provider.tsx
'use client'

import type { Route } from 'next'
import NextLink from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import type { ReactNode } from 'react'
import {
  AnalyticsTemplateProvider,
  analyticsRoutes,
} from '@/components/snow-ui-pro/templates/analytics/AnalyticsTemplate'
import type { LinkComponent } from '@/components/snow-ui-pro/lib/navigation'
import { analyticsAdapter } from '@/lib/analytics' // your AnalyticsAdapter

const Link: LinkComponent = ({ href, ...props }) => (
  <NextLink href={href as Route} {...props} />
)

export function AnalyticsProvider({
  now,
  children,
}: {
  now: string
  children: ReactNode
}) {
  const router = useRouter()
  const search = useSearchParams()
  return (
    <AnalyticsTemplateProvider
      adapter={analyticsAdapter}
      navigate={(href) => router.push(href as Route)}
      // Filters: a new URL without a server round trip, which Next.js
      // syncs with useSearchParams.
      replace={(href) => window.history.replaceState(null, '', href)}
      // The pages follow it: Back and Forward restore a page as it first
      // rendered, and its filters from here.
      search={search.toString()}
      routes={analyticsRoutes('/analytics')}
      linkAs={Link}
      now={now}
      timeZone="Europe/Lisbon" // the project's
      earliest="2025-01-01" // the first day with data
      productName="Acme"
      products={[{ id: 'crm', label: 'CRM', href: '/crm' }]}
    >
      {children}
    </AnalyticsTemplateProvider>
  )
}
tsx
// app/analytics/layout.tsx — "now" and the URL's search, per request
import { AnalyticsProvider } from './analytics-provider'

export const dynamic = 'force-dynamic'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <AnalyticsProvider now={new Date().toISOString()}>{children}</AnalyticsProvider>
  )
}
tsx
// app/analytics/page.tsx — the filters live in the URL
import { OverviewPage } from '@/components/snow-ui-pro/templates/analytics/OverviewPage'
import { analyticsFiltersFromParams } from '@/components/snow-ui-pro/lib/analytics-types'
import { analytics } from '@/lib/analytics' // server-side queries

type Props = { searchParams: Promise<Record<string, string | undefined>> }

export default async function Page({ searchParams }: Props) {
  const params = await searchParams
  const filters = analyticsFiltersFromParams(params)
  const [summary, activeUsers, movers, platforms, topEvents] = await Promise.all([
    analytics.load({ resource: 'summary', filters }),
    analytics.load({ resource: 'active-users', filters }),
    analytics.load({ resource: 'movers', filters }),
    analytics.load({ resource: 'platforms', filters }),
    analytics.load({ resource: 'top-events', filters }),
  ])
  // Filters that arrive by a link start the page over; the page's own
  // filter changes only replace the URL.
  return (
    <OverviewPage
      key={String(new URLSearchParams(params as Record<string, string>))}
      filters={filters}
      initial={{ summary, activeUsers, movers, platforms, topEvents }}
    />
  )
}

The other pages follow the same pattern. A report ID your backend doesn't know is a 404 with ReportNotFoundPage: call notFound() in the route and render it from the segment's not-found.tsx. Read the funnels route's URL with funnelFromParams(params, { funnels, catalogue }), as the page does after Back and Forward; when it says missing (a saved funnel, ?funnel=, that isn't there), render <ReportNotFoundPage kind="funnel" /> from the page itself: its way back is the same path, and Next.js keeps a not-found boundary until the path changes. After a change (markChanged(): a pin, a saved report or funnel), a page mounted later reloads its data once.

The adapter

Reject a promise when a request fails: that block shows its error with “Try again” and the other blocks keep working; a pin rolls back with a toast.

MethodUsed byResult
load({ resource: 'summary', filters })A1 headline and KPIsOverviewSummary: the range and the previous one, the headline with its drivers, five KPIs with sparklines and targets, empty for a new project
load({ resource: 'active-users' | 'movers' | 'platforms' | 'top-events', filters })A1DAU / WAU / MAU per day (and the previous period's when comparing) with completeThrough and delayed; movers; WAU by platform; the top 10 events
load({ resource: 'retention', filters, period, returnEvent })A2the cohorts (oldest first; retained shorter for young cohorts) and the ones before them
load({ resource: 'funnel', filters, funnel }), load({ resource: 'funnels' }), saveFunnel(funnel), dropOffSample(funnel, step, filters)A3users per step with median times, the previous period's, the breakdown; saved funnels; people who dropped off
load({ resource: 'acquisition', filters })A4new users per day by channel; channels with quality rates and campaigns (UTM source, medium); the weighted average; delayed
load({ resource: 'reports' }), pinReport(id, pinned)A5the saved reports and who “Mine” is; the changed report
load({ resource: 'report-result', query }), load({ resource: 'catalogue' }), saveReport(draft)A6the series and rows (up to five series and “Other”), totals, or invalid (which control, and why); the events and properties; the saved report (a new one gets its ID)

useAnalyticsResource(query, initial, { debounceMs }) is the pages' data hook: it starts from the server's data, keeps it on screen as refreshing while a new query loads (the report builder debounces by 300ms), drops answers to queries no longer shown, and offers retry.

The arithmetic

analytics-math holds what the pages compute from the adapter's data, unit-tested (lib/analytics-math.test.ts):

  • previousRange, rangeLength, relativeChange (no division by zero: null), pointChange and changeDirection: period comparisons, in percent for counts and in points for rates.
  • cohortAverage: the size-weighted average per period over the cohorts that have reached it (young cohorts don't count as 0); heatStep and HEAT_THRESHOLDS: the five tint steps.
  • funnelConversion: conversion from the first and from the previous step, drop-off and its rate; zero people never divides by zero.
  • limitSeries and withOther (five series at most), downsample (sparklines), utmBreakdown, intervalStart (weeks start on Monday), sortByValue (missing values last both ways).

CSV files are built in the browser from the data on screen (csv): RFC 4180 quoting, CRLF line ends, a UTF-8 byte order mark for spreadsheets, and text starting with =, +, -, @ or a tab gets an apostrophe so an event or campaign name can't run as a formula.

The mock rules

createMockAnalyticsAdapter({ latencyMs, scenario, failOn }) answers after a fixed delay (0 in tests, 400 on the site). Every number comes from a seeded hash of its name: 430 days of metrics up to 16 June 2026, 09:16 in Lisbon (MOCK_ANALYTICS_NOW; ranges can start on MOCK_ANALYTICS_EARLIEST, 400 days back); today is partly in, so the overview projects it (dashed).

InputResult
failOn: ['platforms', 'saveReport', …] (?fail= on the site)those resources or calls fail once; “Try again” succeeds
A breakdown or filter property the event doesn't carry (app_opened by Browser)invalid on that control
Platform is Web for a mobile-only event (app_opened, push_enabled)no rows: the no-results state
A range before 1 July 2025Paid search has no data (“—”)
Daily cohorts in Asia-Pacific on the websmall cohorts, muted

Scenarios (?scenario= on the site): default, new-project (no events: the first-use states and the reports' starters), incomplete-today (yesterday is late too, with warnings on the charts and the attribution), compare-on (defaultFilters with compare: true: the pages compare unless the URL says compare=0).

Accessibility

  • One h1 per page, which takes the focus after a client-side navigation; a skip link, the top bar (header), the pages (nav “Primary”, the current one aria-current="page", in a sheet below 1024px).
  • Every chart has a title that says what it answers and its period, its data as a hidden table (always) that “Show as a table” brings into view, keyboard navigation of the data points (the library's), and colour as a supplement: the previous period and projections are dashed, changes are signed with arrows, the funnel prints every number, the cohort grid prints every value and its legend names the steps.
  • Filters re-query every block without moving the focus; once the blocks have their new data a polite live region says what is shown (“Showing Last 7 days, Europe, All platforms.”). Blocks that load or fail say so inside themselves (aria-busy, an error with “Try again”).
  • The report builder is a form (react-hook-form): labelled controls, errors under their fields, a query error under the control it is about (aria-invalid and aria-describedby); after saving, the focus moves to the title (the save bar is gone); leaving with unsaved changes asks first.
  • Funnel steps move and are removed from each step's menu (no dragging needed); the focus stays on the moved step's menu or moves to the next step after a removal; sheets return the focus to what opened them.
  • Right to left: the layouts use logical properties, charts mirror their axes, the funnel's bars grow from the start edge, the cohort column is pinned at the start, and UTM values read left to right (<bdi>).
  • Checked in the site's end-to-end tests: every page at 320, 768 and 1280px in the light and dark themes with axe (WCAG 2.2 AA), no sideways scrolling, the scenario states, right to left, and each page's flows with the keyboard only — filters, CSV, cohorts, building and saving a funnel, a channel's campaigns, pinning, and building and saving a report.

Not yet

  • A7 Events (an event's definition, properties and trend) is designed and comes in a later release; the overview's events open the report builder with that event instead.
  • Funnel steps reorder from their menus, not by dragging.
  • A report's chart series are switched from the legend of the acquisition chart only; the builder shows every series it returns (five at most).