freeDashboard · Next.js + Tailwind
Cohorta
A dashboard for product analytics: active-user trends, step-by-step funnels with conversion windows, a weekly retention cohort heatmap, an events explorer with property details and saved segments.
loopkitui.com/t/product-analyticsscroll inside ↓
Built from
- Dash kit (shell, charts, tables)in the zip
For: Product and growth teams who want analytics screens in their own product.
prompt
You are building a complete dashboard app screen with Loopkit: the "Cohorta" template.
Goal: A dashboard for product analytics: active-user trends, step-by-step funnels with conversion windows, a weekly retention cohort heatmap, an events explorer with property details and saved segments.
Audience: Product and growth teams who want analytics screens in their own product.
Stack: Next.js App Router, TypeScript, Tailwind CSS v4, framer-motion. Colour tokens are shadcn's plus Loopkit's --signal, --ok, --warn, --info (see https://loopkitui.com/docs/theming). Light theme by default, dark via the .dark class.
1. Install the Loopkit components this page uses:
npx shadcn@latest add https://loopkitui.com/r/dash-kit.json # dash-kit
2. Create app/page.tsx with the source below. Keep the section order and the copy structure; replace the product name, copy and links with mine.
3. Check it at 360, 768, 1440 and 2560 px wide, in light and dark, with reduced motion on and off. The console must be clean.
Page source:
```tsx
"use client";
import * as React from "react";
import {
DashShell,
Panel,
Kpi,
AreaChart,
DataTable,
Heatmap,
RankBars,
Pill,
Seg,
Btn,
Sheet,
Facts,
Dropdown,
Confirm,
Spark,
useToast,
series,
fmt,
type Column,
} from "@/components/loopkit/dash-kit";
/**
* Cohorta — product analytics: an activity overview, a step-by-step funnel, a retention
* cohort heatmap, an events explorer and saved segments.
*/
const DAYS = Array.from({ length: 30 }, (_, i) => new Date(Date.UTC(2026, 9, 17 + i)).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }));
const DAU = series(41, 30, 5200, 420, 22);
const WAU = series(42, 30, 14800, 600, 40);
export default function Page() {
const [view, setView] = React.useState("overview");
const titles: Record<string, [string, string]> = {
overview: ["Overview", "How people use the product, day by day"],
funnels: ["Funnels", "Where people drop off between steps"],
retention: ["Retention", "Share of each signup week still active later"],
events: ["Events", "Every tracked event and its properties"],
segments: ["Segments", "Saved groups of users to filter any chart"],
};
return (
<DashShell
brand="Cohorta"
mark="C"
accent="violet"
variant="top"
nav={[
{ id: "overview", label: "Overview", icon: "home" },
{ id: "funnels", label: "Funnels", icon: "filter" },
{ id: "retention", label: "Retention", icon: "layers" },
{ id: "events", label: "Events", icon: "pulse" },
{ id: "segments", label: "Segments", icon: "users" },
]}
active={view}
onNavigate={setView}
title={titles[view][0]}
subtitle={titles[view][1]}
searchPlaceholder="Search events and segments"
user={{ name: "Mara Lindqvist", role: "Growth" }}
>
{view === "overview" && <Overview />}
{view === "funnels" && <Funnels />}
{view === "retention" && <Retention />}
{view === "events" && <Events />}
{view === "segments" && <Segments />}
</DashShell>
);
}
function Overview() {
const [metric, setMetric] = React.useState<"dau" | "wau">("dau");
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
<Kpi label="Daily active users" value={fmt.num(Math.round(DAU[29]))} delta="+8.2%" hint="vs last month" spark={DAU} />
<Kpi label="Weekly active users" value={fmt.num(Math.round(WAU[29]))} delta="+5.1%" spark={WAU} tone="info" />
<Kpi label="Stickiness (DAU/MAU)" value="31%" delta="+1.4 pt" spark={series(43, 30, 29, 1, 0.05)} tone="ok" />
<Kpi label="Median session" value="6m 40s" delta="-12s" good={false} tone="warn" />
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
<Panel title="Active users" subtitle="Last 30 days" action={<Seg size="sm" label="Metric" options={[{ value: "dau", label: "Daily" }, { value: "wau", label: "Weekly" }]} value={metric} onChange={setMetric} />}>
<AreaChart labels={DAYS} series={[{ name: metric === "dau" ? "Daily active" : "Weekly active", data: metric === "dau" ? DAU : WAU }]} height={240} />
</Panel>
<Panel title="Top features" subtitle="Users who touched each feature this week">
<RankBars
items={[
{ label: "Dashboards", value: 9120 },
{ label: "Search", value: 7480 },
{ label: "Comments", value: 4310 },
{ label: "Exports", value: 2290 },
{ label: "Integrations", value: 1180 },
]}
/>
</Panel>
</div>
</div>
);
}
const FUNNELS = {
onboarding: { name: "Signup to first report", steps: [["Visited signup", 18400], ["Created account", 9100], ["Connected a source", 5600], ["Built a chart", 4100], ["Shared a report", 2300]] as [string, number][] },
upgrade: { name: "Trial to paid", steps: [["Started trial", 2400], ["Invited a teammate", 1300], ["Hit a plan limit", 820], ["Opened pricing", 610], ["Upgraded", 390]] as [string, number][] },
};
function Funnels() {
const [f, setF] = React.useState<keyof typeof FUNNELS>("onboarding");
const [win, setWin] = React.useState<"1d" | "7d" | "30d">("7d");
const fn = FUNNELS[f];
const top = fn.steps[0][1];
return (
<Panel
title={fn.name}
subtitle={`Conversion window: ${win}`}
action={
<div className="flex flex-wrap gap-2">
<Dropdown className="w-52" label="Funnel" value={f} onChange={setF} options={[{ value: "onboarding", label: FUNNELS.onboarding.name }, { value: "upgrade", label: FUNNELS.upgrade.name }]} />
<Seg size="sm" label="Window" options={["1d", "7d", "30d"] as const} value={win} onChange={setWin} />
</div>
}
>
<ol className="space-y-4">
{fn.steps.map(([label, v], i) => {
const scale = win === "1d" ? 0.82 : win === "30d" ? 1.12 : 1;
const val = i === 0 ? v : Math.round(v * scale);
const pct = Math.min(100, (val / top) * 100);
const prev = i === 0 ? val : Math.round(fn.steps[i - 1][1] * (i - 1 === 0 ? 1 : scale));
return (
<li key={label}>
<div className="mb-1.5 flex flex-wrap items-baseline justify-between gap-2 text-sm">
<span className="min-w-0 font-medium">
<span className="mr-2 font-mono text-xs text-muted-foreground">{i + 1}</span>
{label}
</span>
<span className="font-mono text-xs tabular-nums text-muted-foreground">
{fmt.num(val)} · {pct.toFixed(1)}%
{i > 0 && <span className="ml-2 text-destructive">−{(100 - (val / prev) * 100).toFixed(0)}% from step {i}</span>}
</span>
</div>
<div className="h-9 w-full overflow-hidden rounded-lg bg-muted">
<div className="h-full rounded-lg bg-[var(--acc)] transition-[width] duration-500" style={{ width: `${pct}%`, opacity: 1 - i * 0.12 }} />
</div>
</li>
);
})}
</ol>
</Panel>
);
}
const WEEKS = ["Sep 22", "Sep 29", "Oct 6", "Oct 13", "Oct 20", "Oct 27", "Nov 3", "Nov 10"];
const RET = WEEKS.map((_, i) => Array.from({ length: 8 }, (_, j) => (j > 7 - i ? 0 : j === 0 ? 1 : Math.max(0.08, 0.62 * Math.pow(0.84, j - 1) + ((i * 7 + j * 3) % 5) * 0.01))));
function Retention() {
const [kind, setKind] = React.useState<"any" | "core">("any");
const vals = kind === "any" ? RET : RET.map((r) => r.map((v, j) => (j === 0 ? v : v * 0.72)));
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Kpi label="Week 1 retention" value={fmt.pct(vals[0][1] * 100, 0)} delta="+3 pt" hint="vs prior cohorts" />
<Kpi label="Week 4 retention" value={fmt.pct(vals[0][4] * 100, 0)} delta="+1 pt" tone="info" />
<Kpi label="Week 7 retention" value={fmt.pct(vals[0][7] * 100, 0)} delta="flat" tone="muted" />
</div>
<Panel title="Weekly cohorts" subtitle="Rows are signup weeks, columns are weeks since signup" action={<Seg size="sm" label="Returning event" options={[{ value: "any", label: "Any activity" }, { value: "core", label: "Core action" }]} value={kind} onChange={setKind} />}>
<Heatmap rows={WEEKS} cols={["W0", "W1", "W2", "W3", "W4", "W5", "W6", "W7"]} values={vals} format={(v) => (v ? fmt.pct(v * 100, 0) : "—")} />
</Panel>
</div>
);
}
interface Ev {
id: string;
name: string;
volume: number;
users: number;
trend: number[];
props: string[];
last: string;
}
const EVENTS: Ev[] = [
{ id: "e1", name: "page_viewed", volume: 412800, users: 18320, trend: series(51, 14, 30, 4, 0.2), props: ["path", "referrer", "device"], last: "just now" },
{ id: "e2", name: "chart_created", volume: 38900, users: 4120, trend: series(52, 14, 30, 4, 0.5), props: ["chart_type", "source"], last: "12s ago" },
{ id: "e3", name: "report_shared", volume: 9400, users: 2280, trend: series(53, 14, 30, 4, 0.3), props: ["channel", "recipients"], last: "1m ago" },
{ id: "e4", name: "source_connected", volume: 2100, users: 1440, trend: series(54, 14, 30, 5, -0.2), props: ["source_kind"], last: "4m ago" },
{ id: "e5", name: "export_downloaded", volume: 6300, users: 1900, trend: series(55, 14, 30, 4), props: ["format", "rows"], last: "40s ago" },
{ id: "e6", name: "plan_upgraded", volume: 390, users: 390, trend: series(56, 14, 30, 6, 0.4), props: ["from_plan", "to_plan"], last: "18m ago" },
];
function Events() {
const [open, setOpen] = React.useState<Ev | null>(null);
const cols: Column<Ev>[] = [
{ key: "name", label: "Event", sort: (r) => r.name, render: (r) => <span className="font-mono text-xs">{r.name}</span> },
{ key: "trend", label: "14 days", render: (r) => <Spark data={r.trend} className="h-6 w-24" fill={false} /> },
{ key: "volume", label: "Volume", align: "right", sort: (r) => r.volume, render: (r) => fmt.num(r.volume) },
{ key: "users", label: "Users", align: "right", sort: (r) => r.users, render: (r) => fmt.num(r.users) },
{ key: "last", label: "Last seen", align: "right" },
];
return (
<Panel title="Tracked events" subtitle="Sorted by any column; click for properties" pad={false}>
<DataTable columns={cols} rows={EVENTS} onRowClick={setOpen} selected={open?.id} />
<Sheet open={!!open} onClose={() => setOpen(null)} title={open?.name ?? ""}>
{open && (
<div className="space-y-5">
<Facts items={[["Volume (30d)", fmt.num(open.volume)], ["Unique users", fmt.num(open.users)], ["Per user", (open.volume / open.users).toFixed(1)], ["Last seen", open.last]]} />
<div>
<p className="mb-2 text-xs font-medium text-muted-foreground">Properties</p>
<div className="flex flex-wrap gap-2">
{open.props.map((p) => (
<Pill key={p} tone="accent">{p}</Pill>
))}
</div>
</div>
<Spark data={open.trend} className="h-20 w-full" />
</div>
)}
</Sheet>
</Panel>
);
}
function Segments() {
const toast = useToast();
const [segs, setSegs] = React.useState([
{ id: "s1", name: "Power users", rule: "≥ 5 active days in the last 7", size: 2140 },
{ id: "s2", name: "New this week", rule: "Signed up in the last 7 days", size: 1310 },
{ id: "s3", name: "At risk", rule: "Active last month, not this week", size: 860 },
{ id: "s4", name: "Mobile only", rule: "Every session on a phone", size: 3420 },
]);
const [del, setDel] = React.useState<string | null>(null);
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{segs.map((s) => (
<Panel key={s.id} title={s.name} subtitle={s.rule} action={<Btn variant="plain" icon="trash" aria-label={`Delete ${s.name}`} onClick={() => setDel(s.id)} />}>
<p className="font-display text-3xl font-bold tabular-nums">{fmt.num(s.size)}</p>
<p className="text-sm text-muted-foreground">users right now</p>
<div className="mt-4 flex flex-wrap gap-2">
<Btn icon="chart" onClick={() => toast(`Charts filtered to ${s.name}`, "info")}>Apply to charts</Btn>
<Btn icon="download" onClick={() => toast(`${s.name} exported as CSV`, "ok")}>Export</Btn>
</div>
</Panel>
))}
<Confirm
open={!!del}
danger
title="Delete this segment?"
body="Charts filtered by it go back to all users."
confirmLabel="Delete"
onCancel={() => setDel(null)}
onConfirm={() => {
setSegs((s) => s.filter((x) => x.id !== del));
toast("Segment deleted", "warn");
setDel(null);
}}
/>
</div>
);
}
```