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.
Built with
- NavComponent
- DropdownComponent
- CardComponent
- ChartComponent
- ProgressComponent
- TableComponent
- Form / FormFieldSetComponent
- AvatarComponent
- ChipComponent
- ButtonComponent
- Page TitleComponent
How it’s built
- 01The sidebar is a real
Nav—itemsis a tree of{ key, label, icon, badge, isSection, children },selectedKey/onItemSelectcontrol the active row, andisCollapsedswitches the whole thing to an icon-only rail. The small dot beside the active label is real too, not CSS on[data-active]—Nav'srenderItemprop hands back{ isActive, content, props }for full control per row. - 02
Nav's own CSS sets a fixedwidth: 15remon 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. - 03This is a real app shell, not a page that happens to have a sidebar: the outer wrapper has a bounded height (
h-[46rem]) andoverflow-hidden, the sidebar and header get their ownoverflow-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. - 04Settings / Help desk / Log out, and every kebab ("⋯") menu, and the currency selector are all real
Dropdowns — none of them were wired up in an earlier pass, which meant clicking them did nothing.Dropdown's own wrapper isdisplay: inline-blockby default (shrink-to-fit) —classNames={{ content: "block w-full" }}plusclassName="w-full"onDropdownitself is what makes the account-menu trigger actually span the sidebar's full width instead of hugging its own content. - 05Account balance and My wallet are one card, not two separate ones stacked in different grid rows —
@3xl:row-span-2on 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 noborder-trule between them — a hard divider would visually re-split what's meant to read as one continuous section. - 06Send money / Request money use
flex-1on both buttons for a genuine 50/50 split, instead of each button sizing to its own label's width and ending up visibly uneven. - 07The account avatar is the user's own photo (
src=), and the sidebar/header logo is the real/logo.svgstatic asset — this is the maintainer's own branded copy of the pattern, not stock photography or a fictional placeholder person. - 08The header search is a real
FormFieldSet type="search"withstartIcon/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. - 09Every delta ("+4.8%", "-1.6%") is a real
Chipwith a directional arrow icon inendContent— a sharedDeltaChiphelper keeps the up/down colour and icon in one place. - 10The overflow menus are a shared
MoreButton— a realDropdownbehind aButton 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. - 11Account balance / Total expenses / Total savings sit in one row (
grid-cols-[1.25fr_1fr_1fr]) withitems-start, not the grid defaultalign-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. - 12Wallet status ("Active"/"Inactive") is plain coloured text through
--oks-color-<role>-600, not a filledChip— a deliberately lighter treatment than theChipused for transaction status. - 13Every icon badge is a real
Avatarwithicon=, coloured via thecolorprop — each transaction row uses a different semantic colour so the list reads as varied activity, not one repeated icon. - 14Every heading is a real
PageTitle, always withclassNames={{ base: "flex-col items-start" }}—PageTitle's own wrapper isdisplay: inline-flexwith no set direction, built for a compact inline icon+title, not a stacked heading. - 15The sidebar is hidden below the
@3xlcontainer 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'sclassNames={{ 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 hasoverflow: autobuilt 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. - 17My savings plan carries four goals, each with its own semantic
colordriving both theAvataricon badge and theProgressbar — varied colour reads as varied activity, and keeps the card from looking sparse beside the taller Recent transactions table. - 18Every direct child of the scrollable content body carries
shrink-0. A flex child defaults toflex-shrink: 1, so without it the browser silently compresses these sections to fit the available space instead of letting them overflow — and the parent'soverflow-y-autonever has anything to scroll.
Source
Three files · copy & pasteStyling: this source uses Tailwind utility classes and this site’s colour tokens (for example text-ink, bg-paper and border-line). The oks-ui components render without them; swap those classes for your own.
"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 { AppShell } from "./AppShell";
import {
ArrowDownLeftIcon,
ArrowDownRightIcon,
ArrowsRightLeftIcon,
ArrowUpRightIcon,
BuildingIcon,
CalendarIcon,
ChevronDownIcon,
ClockIcon,
CreditCardIcon,
DownloadIcon,
GlobeIcon,
GridIcon,
HelpCircleIcon,
MailIcon,
MessageCircleIcon,
MoreIcon,
PieChartIcon,
PlusIcon,
ReceiptIcon,
SearchIcon,
SendIcon,
} from "./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",
};
const 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>
);
};
const 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>
);
};
const 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>
);
};
const AnalyticsDashboard = () => {
const [selectedKey, setSelectedKey] = useState("dashboard");
const [currency, setCurrency] = useState("USD");
const activeCurrency = CURRENCIES.find((c) => c.code === currency) ?? CURRENCIES[0];
return (
<AppShell
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>
</AppShell>
);
};
export default AnalyticsDashboard;It imports ./icons, so save this next to it:
export const ArrowDownLeftIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 7 7 17" />
<path d="M17 17H7V7" />
</svg>
);
};
export const ArrowDownRightIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7 7l10 10" />
<path d="M17 7v10H7" />
</svg>
);
};
export const ArrowsRightLeftIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m17 2 4 4-4 4" />
<path d="M3 6h18" />
<path d="m7 22-4-4 4-4" />
<path d="M21 18H3" />
</svg>
);
};
export const ArrowUpRightIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M7 17 17 7" />
<path d="M7 7h10v10" />
</svg>
);
};
export const BuildingIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 22V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v18" />
<path d="M2 22h20" />
<path d="M9 6h1M14 6h1M9 10h1M14 10h1M9 14h1M14 14h1" />
<path d="M10 22v-4h4v4" />
</svg>
);
};
// Icons below this line were added for Nova's rebuild into a fan-community
// events platform -- same "no icon library dependency" convention above.
export const CalendarIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10h18M8 3v4M16 3v4" />
</svg>
);
};
export const ChevronDownIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
);
};
export const ClockIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 3" />
</svg>
);
};
export const CreditCardIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="5" width="20" height="14" rx="2" />
<path d="M2 10h20" />
</svg>
);
};
export const DownloadIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
</svg>
);
};
export const GlobeIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z" />
</svg>
);
};
export const GridIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="3" width="7" height="7" rx="1.5" />
<rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" />
<rect x="14" y="14" width="7" height="7" rx="1.5" />
</svg>
);
};
export const HelpCircleIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path d="M9.5 9a2.5 2.5 0 0 1 4.8 1c0 1.5-2.3 1.8-2.3 3.5" />
<path d="M12 17.5h.01" />
</svg>
);
};
// Icons below this line were added for Horizon's rebuild into a business
// back-office (Finance/Accounts/HR and Payroll/Peoples/Contracts) --
// same "no icon library dependency" convention as everything above.
export const MailIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m2 7 10 6 10-6" />
</svg>
);
};
export const MessageCircleIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z" />
</svg>
);
};
export const MoreIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
);
};
export const PieChartIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
<path d="M22 12A10 10 0 0 0 12 2v10z" />
</svg>
);
};
export const PlusIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 5v14M5 12h14" />
</svg>
);
};
export const ReceiptIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 3v18l2-1.5L9 21l2-1.5L13 21l2-1.5L17 21l2-1.5V3l-2 1.5L15 3l-2 1.5L11 3 9 4.5 7 3 5 4.5Z" />
<path d="M8 8h8M8 12h8" />
</svg>
);
};
export const SearchIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
);
};
export const SendIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
);
};
// Icons below this line were added for Slate's rebuild into a personal
// finance & invoicing app -- same "no icon library dependency" convention
// above.
export const BellIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 8a6 6 0 0 1 12 0c0 4 1.5 5.5 2 6H4c.5-.5 2-2 2-6Z" />
<path d="M10 20a2 2 0 0 0 4 0" />
</svg>
);
};
export const SettingsIcon = ({ size = 18 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9c.26.42.7.7 1.19.83l.32.08c.6.15 1 .68 1 1.3v0" />
</svg>
);
};It imports ./AppShell, so save this next to it:
"use client";
import { useState, type ReactNode } from "react";
import { Avatar } from "oks-ui/avatar";
import { Button } from "oks-ui/button";
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from "oks-ui/dropdown";
import { FormFieldSet } from "oks-ui/form-field-set";
import { Nav } from "oks-ui/nav";
import type { NavItemData } from "oks-ui/nav";
import "oks-ui/avatar.css";
import "oks-ui/button.css";
import "oks-ui/dropdown.css";
import "oks-ui/form-field-set.css";
import "oks-ui/nav.css";
import { BellIcon, HelpCircleIcon, SearchIcon, SettingsIcon } from "./icons";
// Two overlapping chevrons — a standard "collapse sidebar" glyph, not a
// reproduction of any specific icon set's artwork.
const CollapseIcon = () => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true">
<path d="M6 3 2 8l4 5" />
<path d="M12 3 8 8l4 5" />
</svg>
);
};
const LogOutIcon = ({ size = 16 }: { size?: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M6 14H3.5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1H6" />
<path d="M10.5 11 14 8l-3.5-3" />
<path d="M14 8H6" />
</svg>
);
};
export type AppShellProps = {
/** The sidebar tree — differs per pattern to match its app context (a Kanban tool's nav reads differently from an invoicing tool's). */
navItems: NavItemData[];
/** Omitted when the pattern being shown isn't one of the permanent nav rows (e.g. a Settings page reached from the account menu, not the sidebar itself). */
selectedNavKey?: string;
/** Lets a pattern make its own Nav row genuinely clickable (analytics-dashboard highlights whichever item — or account-menu action — was clicked last) instead of a fixed selectedNavKey. */
onSelectedNavKeyChange?: (key: string) => void;
/** Left side of the header, after the collapse toggle. Defaults to a plain search field; command-palette-shell overrides this with its ⌘K trigger button instead. */
headerLeft?: ReactNode;
/** Right side of the header, after the standard bell. Defaults to nothing extra. */
headerExtra?: ReactNode;
/** The scrollable main content (everything below the header). */
children: ReactNode;
/** Extra classes for the scrollable content wrapper — e.g. to drop the default padding for a pattern that wants edge-to-edge content. */
contentClassName?: string;
};
/**
* The one persistent sidebar/header app shell every "App screens" pattern on
* this site shares. Pulled out of each individual pattern file after the
* per-file copies drifted (one ended up with a different account name than
* the rest) — the account identity (Omkar Sahu) and the structural chrome
* (collapse toggle, Nav wiring, account Dropdown, bell) now live in exactly
* one place, while each pattern still supplies its own contextual nav items
* and can override the header's search slot when it needs to (the command
* palette trigger, for instance).
*/
export const AppShell = ({
navItems,
selectedNavKey,
onSelectedNavKeyChange,
headerLeft,
headerExtra,
children,
contentClassName,
}: AppShellProps) => {
const [collapsed, setCollapsed] = useState(false);
return (
// @container has to live on its OWN wrapper, not the element whose
// height is conditioned by @3xl: — an element can't query its own
// container for its own styling (circular per the CSS Containment
// spec), so @3xl:h-[46rem] would silently be evaluated against the
// wrong (ancestor) container and never apply.
<div className="@container w-full">
<div className="flex h-[32rem] w-full overflow-hidden bg-paper @3xl:h-[46rem]">
<aside
role="none"
className={`hidden h-full shrink-0 flex-col gap-6 overflow-y-auto overflow-x-hidden border-r border-line bg-paper-raised p-4 @3xl:flex ${collapsed ? "w-[4.5rem]" : "w-60"}`}
>
<div className="flex items-center px-1">
{/* Two files, swapped by theme — logo.svg carries dark ink and would
vanish against the dark sidebar. eslint-disable: fixed static
assets already sized for this slot; next/image's runtime cost
isn't worth it here. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo.svg" alt="oks-ui" className="oks-logo-for-light h-8 w-8 shrink-0" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/logo-light.svg" alt="oks-ui" className="oks-logo-for-dark h-8 w-8 shrink-0" />
</div>
<Nav
aria-label="Main"
items={navItems}
selectedKey={selectedNavKey}
onItemSelect={onSelectedNavKeyChange ? (item) => onSelectedNavKeyChange(item.key) : undefined}
isCollapsed={collapsed}
classNames={{ base: "w-full", sectionTitle: collapsed ? "sr-only" : undefined }}
renderItem={({ isActive, content, props }) => (
<button type="button" {...props} className={`${props.className} flex items-center gap-1.5`}>
{!collapsed ? (
<span
aria-hidden="true"
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ background: isActive ? "var(--oks-color-primary-600)" : "transparent" }}
/>
) : null}
{content}
</button>
)}
/>
<Dropdown placement="top-start" className="mt-auto w-full" classNames={{ content: "block w-full" }}>
<DropdownTrigger>
<Button
variant="ghost"
fullWidth
className={`gap-2.5 p-2 ${collapsed ? "justify-center" : "justify-start"}`}
>
<Avatar src="/avatar-omkar.jpg" name="Omkar Sahu" size="sm" radius="full" />
{!collapsed ? (
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-[0.85rem] font-medium text-ink">Omkar Sahu</span>
<span className="block truncate text-[0.75rem] text-ink-muted">me@oks-ui.com</span>
</span>
) : null}
</Button>
</DropdownTrigger>
<DropdownMenu
aria-label="Account menu"
onAction={onSelectedNavKeyChange ? (key) => onSelectedNavKeyChange(String(key)) : undefined}
>
<DropdownItem key="settings" startContent={<SettingsIcon size={16} />}>
Settings
</DropdownItem>
<DropdownItem key="help" startContent={<HelpCircleIcon size={16} />} showDivider>
Help desk
</DropdownItem>
<DropdownItem key="logout" startContent={<LogOutIcon size={16} />} classNames={{ title: "text-[color:var(--oks-color-danger-700)]" }}>
Log out
</DropdownItem>
</DropdownMenu>
</Dropdown>
</aside>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-line px-5 py-3.5 @3xl:px-8">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Button
isIconOnly
variant="ghost"
size="sm"
onClick={() => setCollapsed((c) => !c)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="hidden @3xl:flex"
>
<CollapseIcon />
</Button>
{headerLeft ?? (
<FormFieldSet
type="search"
name="headerSearch"
label="Search"
placeholder="Search"
startIcon={<SearchIcon size={16} />}
classNames={{ base: "w-full max-w-xs", label: "sr-only", control: "border-transparent bg-paper-sunken" }}
/>
)}
</div>
<div className="flex items-center gap-3">
{headerExtra}
<Button isIconOnly aria-label="Notifications" variant="ghost" radius="full" size="sm" className="relative">
<BellIcon size={18} />
<span aria-hidden="true" className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full" style={{ background: "var(--oks-color-danger-500)" }} />
</Button>
<Avatar src="/avatar-omkar.jpg" name="Omkar Sahu" size="sm" radius="full" />
</div>
</header>
<div className={contentClassName ?? "flex flex-1 flex-col gap-5 overflow-y-auto p-5 @3xl:p-8"}>{children}</div>
</div>
</div>
</div>
);
};More patterns

Settings panelApp screens
A real settings screen — top tabs, two-column sections, and a sortable, selectable billing history table.

Data table viewApp screens
A full documents list screen — status Tabs, a filter toolbar, and a sortable, selectable Table with real recipient avatars.

Kanban boardApp screens
A full project board — sidebar/header shell, filters, priority cards with subtask checklists, and drag-and-drop with WIP limits.