Dashboard layout
Dashboard layout is a ready-made neobrutalist section (block) from Cruise UI, built from its components. A dashboard layout block: a sidebar with logo, icon nav (active item in --primary) with collapsible sub-menus, an optional XP / level card, and a top bar with page title, search, dark-mode toggle and user menu around your content. The sidebar sits on the left or right (side), is the Drawer on phones and collapses to icons on desktop, where sub-menus open as a flyout. Install it with npx shadcn@latest add @cruiseui/cruise-dashboard-layout.
Install
Terminal
npx shadcn@latest add @cruiseui/cruise-dashboard-layoutFirst time? Installation shows the one-off components.json setup (the @cruiseui registry) and how to turn the theme on.
Example
Overview
Your page content goes here.
Transfers
Your page content goes here.
Sidebar position
Transfers
Your page content goes here.
"use client";
import { ArrowLeftRight, BarChart3, Code, CreditCard, Home, Landmark, LayoutDashboard, Settings, ShoppingBag, Users, Wallet } from "lucide-react";
import { useState } from "react";
import { CruiseBadge } from "@/components/cruise/badge";
import { CruiseDashboardLayout, type CruiseDashboardNavItem } from "@/components/cruise/blocks/dashboard-layout";
import { CruiseToggleButtonGroup } from "@/components/cruise/toggle-button-group";
// Sub-menus: `children` turns an item into a parent. Payments has no href
// (the row only opens and closes); Developers has one (the label is a link,
// the chevron toggles). In your app the active item follows the URL; here
// `active` marks Transfers.
const paymentsNav: CruiseDashboardNavItem[] = [
{ label: "Overview", href: "#overview", icon: <LayoutDashboard /> },
{
label: "Payments",
icon: <CreditCard />,
children: [
{ label: "Transfers", href: "#payments/transfers", active: true },
{ label: "Payouts", href: "#payments/payouts" },
{ label: "Refunds", href: "#payments/refunds", badge: "3" },
],
},
{
label: "Accounts",
icon: <Landmark />,
children: [
{ label: "Virtual accounts", href: "#accounts/virtual" },
{ label: "Settlement accounts", href: "#accounts/settlement" },
],
},
{
label: "Developers",
href: "#developers",
icon: <Code />,
children: [
{ label: "API keys", href: "#developers/api-keys" },
{ label: "Webhooks", href: "#developers/webhooks" },
{ label: "Logs", href: "#developers/logs" },
],
},
{ label: "Settings", href: "#settings", icon: <Settings /> },
];
export default function DashboardLayoutExample() {
const [side, setSide] = useState<"left" | "right">("right");
return (
<div className="flex flex-col gap-8">
<div className="brutal brutal-static overflow-hidden">
<CruiseDashboardLayout
brand="Kiosk"
title="Overview"
nav={[
{ label: "Home", href: "#home", icon: <Home />, active: true },
{ label: "Orders", href: "#orders", icon: <ShoppingBag /> },
{ label: "Customers", href: "#customers", icon: <Users /> },
{ label: "Reports", href: "#reports", icon: <BarChart3 /> },
{ label: "Settings", href: "#settings", icon: <Settings /> },
]}
xp={{ level: 4, value: 620, max: 1000, label: "Seller level" }}
user={{ name: "Ada Okafor" }}
search={{ placeholder: "Search orders" }}
// The docs page already has a <main>; in your app leave this out.
mainElement="div"
>
<p>Your page content goes here.</p>
</CruiseDashboardLayout>
</div>
{/* Sub-menus. persist remembers the open ones for this browser session. */}
<div className="brutal brutal-static overflow-hidden">
<CruiseDashboardLayout
brand="Kiosk"
nav={paymentsNav}
persist
collapsedStorageKey={null}
user={{ name: "Ada's Store Ltd" }}
mainElement="div"
>
<p>Your page content goes here.</p>
</CruiseDashboardLayout>
</div>
{/* Sidebar position: side="right" mirrors the layout (the drawer on
phones comes in from the right too). Shadows still point down-right. */}
<section className="flex flex-col gap-4" aria-labelledby="sidebar-position">
<h3 id="sidebar-position" className="text-h3">
Sidebar position
</h3>
<CruiseToggleButtonGroup
label="Sidebar position"
options={[
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
]}
value={side}
onChange={setSide}
/>
<div className="brutal brutal-static overflow-hidden">
<CruiseDashboardLayout
side={side}
brand="Kiosk"
nav={paymentsNav}
collapsedStorageKey={null}
user={{ name: "Ada's Store Ltd" }}
mainElement="div"
>
<p>Your page content goes here.</p>
</CruiseDashboardLayout>
</div>
</section>
{/* KYC tier as the sidebar footer. On phones it sits under the top bar
(sidebarFooterOnMobile="topbar"), in a one-line form. */}
<div className="brutal brutal-static overflow-hidden">
<CruiseDashboardLayout
brand="Kiosk"
title="Accounts"
nav={[
{ label: "Home", href: "#home", icon: <Home /> },
{ label: "Accounts", href: "#accounts", icon: <Wallet />, active: true },
{ label: "Transfers", href: "#transfers", icon: <ArrowLeftRight /> },
{ label: "Settings", href: "#settings", icon: <Settings /> },
]}
sidebarFooterOnMobile="topbar"
// Two layouts on one page: only the first remembers its collapsed state.
collapsedStorageKey={null}
sidebarFooter={(place) =>
place === "topbar" ? (
<p className="flex flex-wrap items-center gap-2 text-small">
<CruiseBadge tone="info">KYC Tier 2</CruiseBadge>
Daily limit ₦200,000 · <a href="#kyc">Upgrade</a>
</p>
) : (
<div className="brutal brutal-static flex flex-col gap-2 p-3 text-small">
<CruiseBadge tone="info" className="self-start">
KYC Tier 2
</CruiseBadge>
<p>Daily limit ₦200,000.</p>
<a href="#kyc">Upgrade to Tier 3</a>
</div>
)
}
user={{ name: "Ada's Store Ltd" }}
mainElement="div"
>
<p>Your page content goes here.</p>
</CruiseDashboardLayout>
</div>
</div>
);
}
When to use it
When to use
Admin panels, dashboards and game-style member areas.
When not to
Marketing pages (use the Navbar and Footer).
Props
CruiseDashboardLayout
| Prop | Type | Default | Description |
|---|---|---|---|
brand? | string | "Your brand" | — |
logo? | ReactNode | — | — |
title? | ReactNode | — | The page title in the top bar (an <h1>). Defaults to the active nav item's label, then "Dashboard". |
nav | CruiseDashboardNavItem[] | — | — |
activeHref? | string | — | Which nav item is active; defaults to the current URL (exact or prefix match). |
xp? | { level: number; value: number; max: number; label?: string | undefined; levelLabel?: string | undefined; unit?: string | undefined; valueText?: string | undefined; } | — | Level card at the bottom of the sidebar. levelLabel ("LV"), unit ("XP") and valueText ("3 of 5 badges") change its text. |
sidebarFooter? | ReactNode | ((place: CruiseSidebarFooterPlace) => ReactNode) | — | Your own sidebar footer (plan card, KYC tier, help link…); replaces the XP card. Or a function of where it's drawn, to give the phone top bar a compact version: (place) => place === "topbar" ? <Short /> : <Full />. |
sidebarFooterOnMobile? | "drawer" | "topbar" | "drawer" | Phones and tablets (below 1024px): "drawer" (default) shows the footer at the bottom of the menu drawer; "topbar" shows it in a strip under the top bar, always in view (for things like a KYC tier or a balance). |
side? | "left" | "right" | "left" | Which side the sidebar sits on: "left" (default) or "right". On the right, the phone drawer slides in from the right and the menu button sits at the end of the top bar. Shadows still point down-right. |
defaultCollapsed? | boolean | false | Desktop: start with the sidebar collapsed to icons. |
collapsedStorageKey? | string | null | "cruise-sidebar-collapsed" | localStorage key that remembers the collapsed state between visits; null to not remember it. |
accordion? | "multiple" | "single" | "multiple" | Sub-menus: "multiple" (default) lets several be open at once; "single" closes the others when one opens. |
persist? | string | boolean | false | Remember which sub-menus are open for the session (sessionStorage). true uses the key "cruise-nav-open"; a string is your own key. |
user? | { name: string; avatar?: string | undefined; initials?: string | undefined; } | { name: "Ada Okafor" } | initials: shown in the avatar when there's no photo; default from the first two words of the name ("Ada's Store Ltd" -> "AS"). |
userMenu? | CruiseMenuEntry[] | — | The user menu; defaults to Profile, Settings and Log out links. |
search? | { placeholder?: string | undefined; label?: string | undefined; onSearch?: ((query: string) => void) | undefined; } | — | Adds a search box; onSearch gets the text on Enter. |
darkModeToggle? | boolean | true | — |
onDarkModeChange? | ((on: boolean) => void) | — | Called with the new state; by default the toggle sets .nepa-mode on <html>. |
mainElement? | "div" | "main" | "main" | "div" when the page already has a <main>. |
children | ReactNode | — | — |
className? | string | — | — |
CruiseDashboardNavItem
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | — |
href? | string | — | Where it goes. A parent may leave it out (it only opens its sub-menu). |
icon? | ReactNode | — | Outline icon; sub-items don't need one. |
badge? | ReactNode | — | A short count or tag after the label, e.g. "3". |
active? | boolean | — | Force this item active (otherwise worked out from activeHref or the URL). |
children? | CruiseDashboardNavItem[] | — | Sub-items: one level deep. |
defaultOpen? | boolean | — | Start with this sub-menu open. |
Usage
- Next.js: put the block in app/<section>/layout.tsx with your nav. The active item comes from the URL (exact or longest prefix match) and the top-bar title from that item's label, so each page only renders its content. Pass `title` on a page that needs another one, or `activeHref` to choose the item yourself.
- Sub-menus: give a nav item `children` (one level deep; sub-items don't need icons) and it becomes a parent button with a chevron that opens and closes them, animated like the Accordion. A parent with no `href` only toggles; a parent with an `href` too has a link (the label navigates) and a separate chevron button (it toggles).
- The parent of the current page opens by itself and gets a --primary bar; the sub-item gets the full active style. `defaultOpen` opens a sub-menu at first; `accordion="single"` keeps one open at a time (default "multiple"); `persist` remembers the open ones for the session (sessionStorage; a string sets the key). `badge` adds a count after a label.
- `side="right"` puts the sidebar on the right (default "left"): the divider moves to its inner edge, the collapse chevron points the right way, collapsed tooltips and flyouts open to the left, and on phones the drawer slides in from the right with the menu button at the end of the top bar. Sub-menus stay left-aligned and hard shadows still point down-right. The remembered collapsed state works the same on both sides.
- Collapsed sidebar: a parent's icon opens its sub-items in a flyout menu beside it, on click or hover, with the parent's label as a heading.
- `sidebarFooter` replaces the XP card with your own content (a plan card, a KYC tier, a help link). It can be a function of where it's drawn ("sidebar", "drawer" or "topbar") to give phones a compact version.
- `sidebarFooterOnMobile`: "drawer" (default) puts the footer at the bottom of the phone menu drawer; "topbar" shows it in a strip under the top bar below 1024px, always in view. E.g. sidebarFooterOnMobile="topbar" sidebarFooter={(place) => place === "topbar" ? <p><CruiseBadge tone="info">KYC Tier 2</CruiseBadge> Daily limit ₦200,000</p> : <KycCard />}.
- The XP card's text is all props: level, value, max, label, levelLabel ("LV"), unit ("XP") or valueText.
- Desktop: the collapse button animates the sidebar to icons (150ms ease-out, like the drawer; instant with reduced motion), with a tooltip on each icon. The state is remembered in localStorage under `collapsedStorageKey` (default "cruise-sidebar-collapsed"; null to not remember) and applied without animating on load. `defaultCollapsed` starts it collapsed.
- `user.initials` sets the avatar letters; without it they come from the first two words of the name ("Ada's Store Ltd" → "AS").
Accessibility
- Landmarks: aside, nav (Main), header and main (or a div when the page already has a <main>).
- The active link has aria-current="page"; collapsed links keep their names.
- A parent is a <button> with aria-expanded and aria-controls pointing at its sub-list (a <ul> inside the parent's <li>); Enter / Space toggle it, and closed sub-items can't be reached with Tab.
- The collapsed flyout is a menu: focus moves in when it opens by click or keyboard, arrow keys move through the links, Esc closes it and focus goes back to the icon.
- The dark-mode toggle is labelled "Toggle dark mode" and reports its state with aria-pressed.