Skip to content
oks-ui

App screens

Analytics dashboard

A complete app shell, not just a card grid: a collapsible sidebar with sectioned navigation (Nav) and an account menu (Dropdown), a header with search and a user menu, a merged balance-plus-wallet card beside two stat cards, an earnings chart, savings-goal progress bars, and a transactions table — every one of them a real oks-ui component wired together, not a static mockup.

analytics-dashboard.tsx

How it's built

1

The sidebar is a real `Nav` — `items` is a tree of `{ key, label, icon, badge, isSection, children }`, `selectedKey`/`onItemSelect` control the active row, and `isCollapsed` switches the whole thing to an icon-only rail. The small dot beside the active label is real too, not CSS on `[data-active]` — `Nav`'s `renderItem` prop hands back `{ isActive, content, props }` for full control per row.

2

`Nav`'s own CSS sets a fixed `width: 15rem` on itself regardless of its container (`--oks-nav-width`) — inside a flex column narrower than that, it silently overflows the sidebar sideways. Fixed the same way as any other width override in this project: `classNames={{ base: "w-full" }}`, which wins over the library's own `:where()`-scoped rule on specificity alone.

3

This is a real app shell, not a page that happens to have a sidebar: the outer wrapper has a bounded height (`h-[46rem]`) and `overflow-hidden`, the sidebar and header get their own `overflow-y-auto`/`shrink-0`, and only the middle content column scrolls. Without that, the whole thing — sidebar included — would scroll away as one long document, defeating the point of persistent nav. The sidebar-collapse toggle lives in the header (before the search field), not inside the sidebar itself.

4

Settings / Help desk / Log out, and every kebab ("⋯") menu, and the currency selector are all real `Dropdown`s — none of them were wired up in an earlier pass, which meant clicking them did nothing. `Dropdown`'s own wrapper is `display: inline-block` by default (shrink-to-fit) — `classNames={{ content: "block w-full" }}` plus `className="w-full"` on `Dropdown` itself is what makes the account-menu trigger actually span the sidebar's full width instead of hugging its own content.

5

Account balance and My wallet are **one** card, not two separate ones stacked in different grid rows — `@3xl:row-span-2` on that single card is what lets it sit beside Total expenses/Total savings (row 1) and Overview (row 2). The two subsections are separated by spacing alone, deliberately with no `border-t` rule between them — a hard divider would visually re-split what's meant to read as one continuous section.

6

Send money / Request money use `flex-1` on both buttons for a genuine 50/50 split, instead of each button sizing to its own label's width and ending up visibly uneven.

7

The account avatar is the user's own photo (`src=`), and the sidebar/header logo is the real `/logo.svg` static asset — this is the maintainer's own branded copy of the pattern, not stock photography or a fictional placeholder person.

8

The header search is a real `FormFieldSet type="search"` with `startIcon`/`endIcon` (two separate `<kbd>` chips), not a styled `<input>` — `classNames={{ control: "border-transparent bg-paper-sunken" }}` swaps its default bordered box for a filled, borderless one.

9

Every delta ("+4.8%", "-1.6%") is a real `Chip` with a directional arrow icon in `endContent` — a shared `DeltaChip` helper keeps the up/down colour and icon in one place.

10

The overflow menus are a shared `MoreButton` — a real `Dropdown` behind a `Button isIconOnly variant="ghost"` (no border, matching the reference's minimal treatment) with a few generic actions, reused on both stat cards, each wallet card, the savings plan, the overview chart, and each transaction row.

11

Account balance / Total expenses / Total savings sit in one row (`grid-cols-[1.25fr_1fr_1fr]`) with `items-start`, not the grid default `align-items: stretch` — stretching would force the shorter stat cards (or the merged card) to match a sibling's height instead of each keeping its own natural size, which is exactly what caused dead space in an earlier pass.

12

Wallet status ("Active"/"Inactive") is plain coloured text through `--oks-color-<role>-600`, not a filled `Chip` — a deliberately lighter treatment than the `Chip` used for transaction status.

13

Every icon badge is a real `Avatar` with `icon=`, coloured via the `color` prop — each transaction row uses a different semantic colour so the list reads as varied activity, not one repeated icon.

14

Every heading is a real `PageTitle`, always with `classNames={{ base: "flex-col items-start" }}` — `PageTitle`'s own wrapper is `display: inline-flex` with no set direction, built for a compact inline icon+title, not a stacked heading.

15

The sidebar is hidden below the `@3xl` container width so the pattern still reads correctly scaled down inside a gallery-card thumbnail, rather than squeezing a 240px rail into a few dozen pixels.

16

`Table`'s `classNames={{ cell: "whitespace-nowrap" }}` keeps every cell on one line — without it, "Software license" or a date wraps across two or three lines the moment the column narrows. `Table`'s own container already has `overflow: auto` built in, so once cells can't wrap, a table wider than its card scrolls horizontally instead — the fix is stopping the wrap, not adding scroll that was already there.

17

My savings plan carries four goals, each with its own semantic `color` driving both the `Avatar` icon badge and the `Progress` bar — varied colour reads as varied activity, and keeps the card from looking sparse beside the taller Recent transactions table.

18

Every direct child of the scrollable content body carries `shrink-0`. A flex child defaults to `flex-shrink: 1`, so without it the browser silently compresses these sections to fit the available space instead of letting them overflow — and the parent's `overflow-y-auto` never has anything to scroll.

Source

One file · copy & paste
analytics-dashboard.tsx
"use client";

import { type ReactNode, useState } from "react";
import { Avatar } from "oks-ui/avatar";
import { Button } from "oks-ui/button";
import { Card, CardBody, CardHeader } from "oks-ui/card";
import { Chart } from "oks-ui/chart";
import { Chip } from "oks-ui/chip";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { FormFieldSet } from "oks-ui/form-field-set";
import type { NavItemData } from "oks-ui/nav";
import { PageTitle } from "oks-ui/page-title";
import { Progress } from "oks-ui/progress";
import { Table } from "oks-ui/table";
import "oks-ui/avatar.css";
import "oks-ui/button.css";
import "oks-ui/card.css";
import "oks-ui/chart.css";
import "oks-ui/chip.css";
import "oks-ui/dropdown.css";
import "oks-ui/form-field-set.css";
import "oks-ui/page-title.css";
import "oks-ui/progress.css";
import "oks-ui/table.css";
import { PatternAppShell } from "../PatternAppShell";
import {
  ArrowDownLeftIcon,
  ArrowDownRightIcon,
  ArrowsRightLeftIcon,
  ArrowUpRightIcon,
  BuildingIcon,
  CalendarIcon,
  ChevronDownIcon,
  ClockIcon,
  CreditCardIcon,
  DownloadIcon,
  GlobeIcon,
  GridIcon,
  HelpCircleIcon,
  MailIcon,
  MessageCircleIcon,
  MoreIcon,
  PieChartIcon,
  PlusIcon,
  ReceiptIcon,
  SearchIcon,
  SendIcon,
} from "../../showcase/icons";

const NAV_ITEMS: NavItemData[] = [
  {
    key: "main",
    label: "Main menu",
    isSection: true,
    children: [
      { key: "dashboard", label: "Dashboard", icon: <GridIcon size={18} /> },
      { key: "analytics", label: "Analytics", icon: <PieChartIcon size={18} />, badge: 20 },
      { key: "transactions", label: "Transactions", icon: <ArrowsRightLeftIcon size={18} /> },
      { key: "invoices", label: "Invoices", icon: <ReceiptIcon size={18} /> },
    ],
  },
  {
    key: "features",
    label: "Features",
    isSection: true,
    children: [
      { key: "recurring", label: "Recurring", icon: <ClockIcon size={18} />, badge: 16 },
      { key: "subscriptions", label: "Subscriptions", icon: <CreditCardIcon size={18} /> },
      { key: "feedback", label: "Feedback", icon: <MessageCircleIcon size={18} /> },
    ],
  },
  // Settings / Help desk / Log out live in the account menu at the bottom
  // of the sidebar (opened from the profile footer), not as permanent nav
  // rows — they're account actions, not app destinations.
];

const wallets = [
  { flag: "🇺🇸", code: "USD", balance: "$22,678.00", status: "Active" as const },
  { flag: "🇪🇺", code: "EUR", balance: "€14,320.00", status: "Active" as const },
  { flag: "🇬🇧", code: "GBP", balance: "£9,540.00", status: "Active" as const },
  { flag: "🇯🇵", code: "JPY", balance: "¥1,240,000", status: "Inactive" as const },
];

const CURRENCIES = wallets.map((w) => ({ flag: w.flag, code: w.code }));

const earnings = [
  { month: "Jan", value: 21400 }, { month: "Feb", value: 24800 }, { month: "Mar", value: 19600 },
  { month: "Apr", value: 27200 }, { month: "May", value: 16800 }, { month: "Jun", value: 25400 },
  { month: "Jul", value: 18200 }, { month: "Aug", value: 34900 }, { month: "Sep", value: 23100 },
  { month: "Oct", value: 15600 }, { month: "Nov", value: 29800 }, { month: "Dec", value: 20200 },
];

const savingsGoals = [
  { id: 1, icon: <PieChartIcon size={18} />, label: "Investment goal", saved: "$15,600", target: "$25,000", pct: 62, color: "primary" as const },
  { id: 2, icon: <ReceiptIcon size={18} />, label: "Emergency fund", saved: "$8,400", target: "$12,000", pct: 70, color: "danger" as const },
  { id: 3, icon: <GlobeIcon size={18} />, label: "Vacation fund", saved: "$3,200", target: "$6,000", pct: 53, color: "info" as const },
  { id: 4, icon: <BuildingIcon size={18} />, label: "Home renovation", saved: "$11,400", target: "$20,000", pct: 57, color: "success" as const },
];

type TxnRow = {
  id: number;
  activity: string;
  icon: ReactNode;
  color: "primary" | "secondary" | "info" | "success" | "warning" | "danger" | "default";
  date: string;
  amount: string;
  status: "Success" | "Pending" | "Failed";
} & Record<string, unknown>;

const transactions: TxnRow[] = [
  { id: 1, activity: "Software license", icon: <CreditCardIcon size={16} />, color: "secondary" as const, date: "Wed, 10 Jun-2026", amount: "$89.00", status: "Success" },
  { id: 2, activity: "Mobile app purchase", icon: <GridIcon size={16} />, color: "info" as const, date: "Tue, 09 Jun-2026", amount: "$46.50", status: "Success" },
  { id: 3, activity: "Grocery purchase", icon: <ReceiptIcon size={16} />, color: "warning" as const, date: "Sun, 07 Jun-2026", amount: "$132.00", status: "Success" },
  { id: 4, activity: "Freelance payment", icon: <ArrowDownLeftIcon size={16} />, color: "success" as const, date: "Fri, 05 Jun-2026", amount: "$2,150.00", status: "Success" },
  { id: 5, activity: "Subscription renewal", icon: <ClockIcon size={16} />, color: "default" as const, date: "Wed, 03 Jun-2026", amount: "$14.99", status: "Pending" },
  { id: 6, activity: "Wire transfer", icon: <ArrowsRightLeftIcon size={16} />, color: "danger" as const, date: "Mon, 01 Jun-2026", amount: "$500.00", status: "Failed" },
];

const statusTone: Record<TxnRow["status"], "success" | "warning" | "danger"> = {
  Success: "success",
  Pending: "warning",
  Failed: "danger",
};

function DeltaChip({ delta, up }: { delta: string; up: boolean }) {
  return (
    <Chip
      size="sm"
      variant="soft"
      color={up ? "success" : "danger"}
      endContent={up ? <ArrowUpRightIcon size={12} /> : <ArrowDownRightIcon size={12} />}
    >
      {delta}
    </Chip>
  );
}

function MoreButton({ label, size = "sm" }: { label: string; size?: "xs-sm" | "sm" }) {
  return (
    <Dropdown placement="bottom-end">
      <DropdownTrigger>
        <Button isIconOnly aria-label={label} variant="ghost" radius="full" size={size}>
          <MoreIcon size={size === "xs-sm" ? 14 : 16} />
        </Button>
      </DropdownTrigger>
      <DropdownMenu aria-label={label}>
        <DropdownItem key="view">View details</DropdownItem>
        <DropdownItem key="export">Export</DropdownItem>
        <DropdownItem key="dismiss" classNames={{ title: "text-[color:var(--oks-color-danger-700)]" }}>
          Dismiss
        </DropdownItem>
      </DropdownMenu>
    </Dropdown>
  );
}

function StatCard({
  icon,
  title,
  value,
  delta,
  up,
}: {
  icon: ReactNode;
  title: string;
  value: string;
  delta: string;
  up: boolean;
}) {
  return (
    <Card shadow="sm" className="h-full">
      <CardBody className="flex flex-col gap-4">
        <div className="flex items-center justify-between gap-3">
          <div className="flex items-center gap-2.5">
            <Avatar icon={icon as never} color="primary" radius="md" size="sm" />
            <PageTitle
              as="h3"
              title={title}
              classNames={{ base: "flex-col items-start", title: "text-[0.95rem] font-semibold text-ink" }}
            />
          </div>
          <MoreButton label={`${title} options`} />
        </div>
        <div>
          <p className="text-[1.7rem] font-bold tracking-tight text-ink">{value}</p>
          <div className="mt-2 flex items-center gap-2 text-[0.8rem] text-ink-muted">
            <DeltaChip delta={delta} up={up} />
            from last month
          </div>
        </div>
      </CardBody>
    </Card>
  );
}

export function AnalyticsDashboard() {
  const [selectedKey, setSelectedKey] = useState("dashboard");
  const [currency, setCurrency] = useState("USD");
  const activeCurrency = CURRENCIES.find((c) => c.code === currency) ?? CURRENCIES[0];

  return (
    <PatternAppShell
      navItems={NAV_ITEMS}
      selectedNavKey={selectedKey}
      onSelectedNavKeyChange={setSelectedKey}
      headerLeft={
        <FormFieldSet
          type="search"
          name="search"
          label="Search"
          placeholder="Search"
          startIcon={<SearchIcon size={16} />}
          endIcon={
            <span className="flex items-center gap-1" aria-hidden="true">
              <kbd className="rounded-[var(--r-sm)] bg-paper px-1.5 py-0.5 text-[0.68rem] font-medium text-ink-faint">⌘</kbd>
              <kbd className="rounded-[var(--r-sm)] bg-paper px-1.5 py-0.5 text-[0.68rem] font-medium text-ink-faint">K</kbd>
            </span>
          }
          classNames={{
            base: "w-full max-w-xs",
            label: "sr-only",
            control: "border-transparent bg-paper-sunken",
          }}
        />
      }
      headerExtra={
        <>
          <Button isIconOnly aria-label="Help" variant="ghost" radius="full" size="sm">
            <HelpCircleIcon size={18} />
          </Button>
          <Button isIconOnly aria-label="Messages" variant="ghost" radius="full" size="sm">
            <MailIcon size={18} />
          </Button>
        </>
      }
    >
      {/* welcome banner. shrink-0 on every direct child of this
          scroll body: a flex child defaults to flex-shrink:1 and
          gets silently compressed to fit its allocated space instead
          of overflowing, so without this the parent's overflow-y-auto
          never sees anything to scroll and content gets clipped by
          the outer overflow-hidden shell instead. */}
      <div className="flex shrink-0 flex-wrap items-start justify-between gap-4">
        <PageTitle
          as="h2"
          title="Welcome back, Omkar"
          subtitle="Monitor and control what happens with your money today."
          classNames={{
            base: "flex-col items-start",
            title: "text-2xl font-bold tracking-tight text-ink",
            subtitle: "text-[0.9rem] text-ink-muted",
          }}
        />
        <div className="flex items-center gap-2">
          <Button variant="bordered" radius="full" size="sm" startContent={<CalendarIcon size={14} />}>
            Fri, 12 June 2026
          </Button>
          <Button color="primary" radius="full" size="sm" startContent={<DownloadIcon size={14} />}>
            Export
          </Button>
        </div>
      </div>

      {/* One grid, not two stacked ones: Account balance + My wallet is
          a SINGLE card spanning both rows (@3xl:row-span-2), sitting
          beside Total expenses / Total savings (row 1) and Overview
          (row 2, spanning the same two narrower columns). Splitting
          "Account balance" and "My wallet" into separate cards was the
          actual bug — they're one continuous section in the
          reference, not two. */}
      <div className="grid shrink-0 items-start gap-5 @3xl:grid-cols-[1.25fr_1fr_1fr] @3xl:grid-rows-[auto_auto]">
        <Card shadow="sm" className="@3xl:row-span-2">
          <CardBody className="flex flex-col gap-5">
            <div className="flex items-center justify-between gap-3">
              <div className="flex items-center gap-2.5">
                <Avatar icon={<CreditCardIcon size={18} />} color="primary" radius="md" size="sm" />
                <PageTitle
                  as="h3"
                  title="Account balance"
                  classNames={{ base: "flex-col items-start", title: "text-[0.95rem] font-semibold text-ink" }}
                />
              </div>
              <Dropdown placement="bottom-end">
                <DropdownTrigger>
                  <Button
                    variant="bordered"
                    radius="full"
                    size="xs"
                    startContent={<span aria-hidden="true">{activeCurrency.flag}</span>}
                    endContent={<ChevronDownIcon size={14} />}
                  >
                    {activeCurrency.code}
                  </Button>
                </DropdownTrigger>
                <DropdownMenu
                  aria-label="Choose currency"
                  selectionMode="single"
                  selectedKeys={[currency]}
                  onAction={(key) => setCurrency(String(key))}
                >
                  {CURRENCIES.map((c) => (
                    <DropdownItem key={c.code} startContent={<span aria-hidden="true">{c.flag}</span>}>
                      {c.code}
                    </DropdownItem>
                  ))}
                </DropdownMenu>
              </Dropdown>
            </div>
            <div>
              <p className="text-[2rem] font-bold tracking-tight text-ink">$48,920.15</p>
              <div className="mt-2 flex items-center gap-2 text-[0.8rem] text-ink-muted">
                <DeltaChip delta="+4.8%" up />
                from last month
              </div>
            </div>
            {/* flex-1 on both: a real 50/50 split instead of each
                button hugging its own label width. */}
            <div className="flex gap-2">
              <Button color="primary" radius="full" size="xs" className="flex-1" startContent={<SendIcon size={12} />}>
                Send money
              </Button>
              <Button variant="bordered" radius="full" size="xs" className="flex-1" startContent={<ArrowDownLeftIcon size={12} />}>
                Request money
              </Button>
            </div>

            <div className="mt-1 flex flex-col gap-3">
              <div className="flex items-center justify-between">
                <PageTitle
                  as="h3"
                  title="My wallet"
                  classNames={{ base: "flex-col items-start", title: "text-[0.95rem] font-semibold text-ink" }}
                />
                <Button variant="bordered" radius="full" size="xs" startContent={<PlusIcon size={12} />}>
                  Add new
                </Button>
              </div>
              <div className="grid grid-cols-2 gap-3">
                {wallets.map((w) => (
                  <div key={w.code} className="relative rounded-[var(--r-md)] bg-paper-sunken p-3.5">
                    <div className="absolute right-1.5 top-1.5">
                      <MoreButton label={`${w.code} wallet options`} size="xs-sm" />
                    </div>
                    <div className="flex items-center gap-2 text-[0.85rem] font-medium text-ink">
                      <span aria-hidden="true">{w.flag}</span>
                      {w.code}
                    </div>
                    <p className="mt-2 text-[1.05rem] font-semibold text-ink">{w.balance}</p>
                    <p
                      className="mt-2 text-[0.8rem] font-medium"
                      style={{ color: `var(--oks-color-${w.status === "Active" ? "success" : "warning"}-700)` }}
                    >
                      {w.status}
                    </p>
                  </div>
                ))}
              </div>
            </div>
          </CardBody>
        </Card>

        <StatCard icon={<ArrowsRightLeftIcon size={18} />} title="Total expenses" value="$12,340.60" delta="-1.6%" up={false} />
        <StatCard icon={<PieChartIcon size={18} />} title="Total savings" value="$22,150.40" delta="+6.2%" up />

        <Card shadow="sm" className="@3xl:col-span-2">
          <CardHeader className="flex flex-wrap items-center justify-between gap-2">
            <PageTitle
              as="h3"
              title="Overview"
              classNames={{ base: "flex-col items-start", title: "text-[0.95rem] font-semibold text-ink" }}
            />
            <div className="flex items-center gap-3">
              <span className="flex items-center gap-1.5 text-[0.78rem] text-ink-muted">
                <span
                  aria-hidden="true"
                  className="h-2 w-2 rounded-full"
                  style={{ background: "var(--oks-color-primary-600)" }}
                />
                Earnings
              </span>
              <Button variant="ghost" size="sm" endContent={<ChevronDownIcon size={14} />}>
                This year
              </Button>
              <MoreButton label="Overview options" />
            </div>
          </CardHeader>
          <CardBody>
            <Chart type="column" data={earnings} x="month" series="value" legend={false} height={220} />
          </CardBody>
        </Card>
      </div>

      {/* My savings plan / Recent transactions — same 3-column template
          again, so all three rows share one consistent set of column
          edges down the page. */}
      <div className="grid shrink-0 gap-5 @3xl:grid-cols-[1.25fr_1fr_1fr]">
        <Card shadow="sm">
          <CardHeader className="flex items-center justify-between">
            <PageTitle
              as="h3"
              title="My savings plan"
              classNames={{ base: "flex-col items-start", title: "text-[0.95rem] font-semibold text-ink" }}
            />
            <MoreButton label="Savings plan options" />
          </CardHeader>
          <CardBody className="flex flex-col gap-6">
            {savingsGoals.map((g) => (
              <div key={g.id} className="flex flex-col gap-2.5">
                <div className="flex items-center gap-2.5">
                  <Avatar icon={g.icon} color={g.color} radius="md" size="sm" />
                  <p className="text-[0.85rem] font-medium text-ink">{g.label}</p>
                </div>
                <div className="flex items-center justify-between text-[0.85rem]">
                  <span className="text-ink-muted">
                    {g.saved} / {g.target}
                  </span>
                  <span className="font-semibold text-ink">{g.pct}%</span>
                </div>
                {/* aria-hidden: the label + "$saved / $target" + "N%" above
                    already say everything this bar would announce again. */}
                <Progress value={g.pct} color={g.color} size="sm" aria-hidden="true" />
              </div>
            ))}
          </CardBody>
        </Card>

        <Card shadow="sm" className="@3xl:col-span-2">
          <CardHeader className="flex items-center justify-between">
            <PageTitle
              as="h3"
              title="Recent transactions"
              classNames={{ base: "flex-col items-start", title: "text-[0.95rem] font-semibold text-ink" }}
            />
            <Button variant="bordered" size="sm">Filter</Button>
          </CardHeader>
          <CardBody>
            <Table
              aria-label="Recent transactions"
              classNames={{ cell: "whitespace-nowrap" }}
              columns={[
                {
                  key: "activity",
                  header: "Activity",
                  render: (r: TxnRow) => (
                    <div className="flex items-center gap-2.5">
                      <Avatar icon={r.icon as never} color={r.color} radius="md" size="sm" />
                      <span className="font-medium text-ink">{r.activity}</span>
                    </div>
                  ),
                },
                { key: "date", header: "Date" },
                { key: "amount", header: "Amount", align: "end" },
                {
                  key: "status",
                  header: "Status",
                  render: (r: TxnRow) => (
                    <Chip size="sm" variant="soft" color={statusTone[r.status]}>
                      {r.status}
                    </Chip>
                  ),
                },
                {
                  key: "actions",
                  header: "",
                  ariaLabel: "Actions",
                  align: "end",
                  render: (r: TxnRow) => <MoreButton label={`${r.activity} options`} size="xs-sm" />,
                },
              ]}
              rows={transactions}
              getRowKey={(r) => r.id}
            />
          </CardBody>
        </Card>
      </div>
    </PatternAppShell>
  );
}

More patterns

App screens
Settings panelA real settings screen — top tabs, two-column sections, and a sortable, selectable billing history table.
App screens
Data table viewA full documents list screen — status Tabs, a filter toolbar, and a sortable, selectable Table with real recipient avatars.
App screens
Kanban boardA full project board — sidebar/header shell, filters, priority cards with subtask checklists, and drag-and-drop with WIP limits.