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.
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:
Shared with the other templates (one implementation each):
Extended (additively; existing callers render as before):
filter-bar: adate-rangefield (presets and a custom range with the library'sDateRangePicker, stored asfrom..to; it stays in the row at every width and never counts as a filter) and atogglefield (aSwitch).chart-card:menuActionsandnotice; “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:actionsbeside the title and atotalsrow in the table's footer.insight-callout:showLabelandchange; a fragmentaction.hrefis a plain anchor.kpi-ribbon: a metric'shref(its label links to its report) withlinkAs.ranked-list: an item'schange,directionandgoodDirection(a chip); an emptyvalueleaves 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
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-analyticsnpm 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)
// 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>
)
}// 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>
)
}// 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.
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),pointChangeandchangeDirection: 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);heatStepandHEAT_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.limitSeriesandwithOther(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).
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
h1per page, which takes the focus after a client-side navigation; a skip link, the top bar (header), the pages (nav“Primary”, the current onearia-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-invalidandaria-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).