Building a SaaS Dashboard with Tailwind: Layout, Sidebar, Charts
Build a production-ready SaaS dashboard with Tailwind CSS v4 — collapsible sidebar, responsive grid layout, and chart integration without the bloat.
Why Most Dashboard Tutorials Get the Layout Wrong
Honestly, the average "build a dashboard" tutorial skips the hard parts. You get a static screenshot, a CodeSandbox with no real data, and three npm packages you didn't ask for. Then you're left figuring out why your sidebar collapses the wrong way on mobile.
This article skips the fluff. We're building a real SaaS dashboard shell — fixed sidebar, scrollable main content, a top nav bar, responsive stat cards, and a chart area — using Tailwind CSS v4.0.2. No extra UI kit required, though you'll probably want Tailwind component patterns once you've got the structure down.
The layout model we're using is a classic three-zone split: a fixed-width sidebar on the left, a top header that stays sticky, and a main content region that owns the rest of the viewport. Everything is wired with CSS Grid at the top level. That's it. No position:absolute hacks.
Top-Level Grid Layout with Tailwind v4
Tailwind v4.0.2 ships with native CSS variable support and a new @theme directive that makes layout tokens feel first-class. We'll define our sidebar width as a theme value so it stays consistent across components without magic numbers scattered through the codebase.
Here's the root layout shell. Notice we're using grid-cols-[--sidebar-width,1fr] with a CSS custom property. Tailwind v4 supports arbitrary values that reference CSS variables directly — no JIT tricks needed.
// app/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div
className="grid min-h-screen"
style={{ gridTemplateColumns: 'var(--sidebar-width, 240px) 1fr' }}
>
<Sidebar />
<div className="flex flex-col overflow-hidden">
<TopBar />
<main className="flex-1 overflow-y-auto p-6 bg-zinc-950">
{children}
</main>
</div>
</div>
);
}The sidebar gets sticky top-0 h-screen — it doesn't scroll with the page. The main area scrolls independently. This pattern avoids the classic bug where your sidebar jumps when content is long.
Building the Collapsible Sidebar
The sidebar is where most dashboards break. You need it to collapse to an icon-only rail on mobile, but stay expanded at lg: breakpoints. And you probably want a toggle button that animates the width smoothly.
We'll store the collapsed state in a React context so any component can read it — the main content area needs to adjust its padding, chart labels might truncate differently, etc. Don't just hide the text, actually narrow the sidebar.
// components/Sidebar.tsx
'use client';
import { useState } from 'react';
import { ChevronLeft, LayoutDashboard, BarChart2, Settings } from 'lucide-react';
export function Sidebar() {
const [collapsed, setCollapsed] = useState(false);
const width = collapsed ? '64px' : '240px';
return (
<aside
className="flex flex-col bg-zinc-900 border-r border-zinc-800 transition-all duration-200"
style={{ width }}
>
<div className="flex items-center justify-between h-16 px-4 border-b border-zinc-800">
{!collapsed && (
<span className="text-white font-semibold text-sm tracking-wide">Acme SaaS</span>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="p-1.5 rounded-md text-zinc-400 hover:text-white hover:bg-zinc-800 transition-colors"
>
<ChevronLeft
size={16}
className={`transition-transform duration-200 ${collapsed ? 'rotate-180' : ''}`}
/>
</button>
</div>
<nav className="flex-1 px-2 py-4 space-y-1">
{[
{ icon: LayoutDashboard, label: 'Overview', href: '/dashboard' },
{ icon: BarChart2, label: 'Analytics', href: '/dashboard/analytics' },
{ icon: Settings, label: 'Settings', href: '/dashboard/settings' },
].map(({ icon: Icon, label, href }) => (
<a
key={href}
href={href}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800 text-sm transition-colors"
>
<Icon size={18} className="shrink-0" />
{!collapsed && <span>{label}</span>}
</a>
))}
</nav>
</aside>
);
}The shrink-0 on the icon prevents it from squishing when the sidebar animates. The duration-200 on the aside itself means the width transition runs at 200ms — fast enough to feel snappy, slow enough to not be jarring.
Responsive KPI Stat Cards
The top of most dashboards shows four or five KPI cards: MRR, active users, churn, conversion rate. You've seen them a hundred times. The trick is making them actually responsive — not just shrinking, but reflowing from a 4-column grid down to 2 on tablet and 1 on phone.
With Tailwind v4.0.2 and container queries, you can make the card grid respond to the content area's width rather than the viewport. That matters on a dashboard because the sidebar is eating real estate. A viewport-based breakpoint will fire at the wrong time.
// components/KpiGrid.tsx
const stats = [
{ label: 'Monthly Revenue', value: '$48,290', delta: '+12.4%', up: true },
{ label: 'Active Users', value: '3,841', delta: '+8.1%', up: true },
{ label: 'Churn Rate', value: '2.3%', delta: '-0.4%', up: false },
{ label: 'Avg Session', value: '4m 12s', delta: '+0.8%', up: true },
];
export function KpiGrid() {
return (
<div className="@container">
<div className="grid grid-cols-1 @sm:grid-cols-2 @xl:grid-cols-4 gap-4 mb-6">
{stats.map((s) => (
<div
key={s.label}
className="bg-zinc-900 border border-zinc-800 rounded-xl p-5"
>
<p className="text-xs text-zinc-500 uppercase tracking-widest mb-1">{s.label}</p>
<p className="text-2xl font-bold text-white">{s.value}</p>
<p className={`text-xs mt-1 ${s.up ? 'text-emerald-400' : 'text-red-400'}`}>
{s.delta} vs last month
</p>
</div>
))}
</div>
</div>
);
}The @container wrapper plus @sm: and @xl: breakpoints means these cards reflow based on the .@container element's width. No viewport queries, no breakpoint math to adjust for sidebar width. It just works.
Integrating Charts Without a Heavy Library
Charts are where dashboard projects balloon. Recharts alone is ~400KB minified. If you only need one or two chart types, consider lighter alternatives. For this build we're using Recharts anyway — it's the most React-idiomatic option and tree-shakes reasonably well — but we're keeping it lean.
The key integration point with Tailwind is theming. Recharts uses inline SVG styles, so you can't apply Tailwind classes to line colors or tooltip backgrounds directly. You pull values from your CSS variables at runtime. With Tailwind v4's @theme directive, your color tokens are real CSS custom properties on :root, which means you can read them in JavaScript.
// components/RevenueChart.tsx
'use client';
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
const data = [
{ month: 'Jun', mrr: 32000 },
{ month: 'Jul', mrr: 35800 },
{ month: 'Aug', mrr: 34200 },
{ month: 'Sep', mrr: 39100 },
{ month: 'Oct', mrr: 43500 },
{ month: 'Nov', mrr: 48290 },
];
export function RevenueChart() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h2 className="text-sm font-medium text-zinc-400 mb-4">Monthly Recurring Revenue</h2>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={data}>
<XAxis
dataKey="month"
tick={{ fill: '#71717a', fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tick={{ fill: '#71717a', fontSize: 12 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`}
/>
<Tooltip
contentStyle={{
background: '#18181b',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '8px',
color: '#f4f4f5',
fontSize: 12,
}}
/>
<Line
type="monotone"
dataKey="mrr"
stroke="#6366f1"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}The tooltip background: '#18181b' matches zinc-900 and border: '1px solid rgba(255,255,255,0.08)' gives that frosted-glass border without pulling in glassmorphism effects that would look out of place in a dense data UI.
Dark Mode and Theme Toggling
SaaS dashboards almost always ship dark-first. Users stare at these screens for hours. If you're supporting a light mode too, the cleanest approach in Tailwind v4 is the class strategy — add a dark class to <html> and let the cascade do the work.
You can wire this to a toggle component that persists the preference in localStorage. Check out the theme toggle pattern for React for a drop-in implementation that handles SSR hydration mismatches without a flash of the wrong theme.
For the dashboard specifically, the color tokens that matter most are your card backgrounds, border colors, and chart axis labels. With Tailwind v4's @theme block you can define --color-surface and --color-border as semantic tokens that flip between light and dark values, keeping your component markup clean.
Color System: OKLCH Tokens for Dashboard UI
Most Tailwind dashboard tutorials use hardcoded zinc/slate values. That's fine for a prototype. But if you're handing this to a design team or supporting white-labeling, you want a proper token layer. Tailwind v4 has first-class support for OKLCH colors in the @theme directive.
Why does it matter for dashboards specifically? Chart colors, status badges (green for healthy, red for alerts, amber for warnings), and accent colors for active nav items all need to be perceptually consistent. OKLCH gives you uniform lightness across hues, so your "success" green and "error" red don't feel like they have different visual weights. If you want to go deep on this, Tailwind OKLCH colors covers the full token setup.
For a dashboard, define at minimum: --color-accent for the primary action color (nav active state, primary button), --color-success, --color-warning, --color-danger for status indicators, and --color-surface-1 / --color-surface-2 for the background layers. That's six tokens. You don't need more than that to cover 90% of your dashboard UI.
Production Checklist Before You Ship
So you've got the sidebar, the KPI grid, the charts. What's left? A few things that bite people in production that tutorials never mention.
First, virtualize long data tables. If your dashboard has a transactions list or an event log, don't render 10,000 rows into the DOM. Use @tanstack/react-virtual — it's 5KB and drops render time from seconds to milliseconds. Second, lazy-load your chart library. Recharts is big enough that it warrants a dynamic import with a loading skeleton. Third, make sure your sidebar has will-change: width during the collapse animation — without it, Chrome will composite-layer the entire page on every frame.
And please add keyboard navigation to your sidebar. Tab order, focus rings, ARIA roles. The focus-visible:ring-2 focus-visible:ring-indigo-500 Tailwind pattern handles the visual side in two classes. It's genuinely not that much work. Also worth checking out Tailwind v4 features if you haven't migrated yet — the new CSS-first config reduces your bundle by dropping the JS config file entirely.
FAQ
Use CSS transition-all duration-200 on the aside element with an inline style={{ width }} driven by state. Avoid toggling display:none — it skips the transition. Set overflow: hidden on the sidebar so text doesn't spill during the animation.
Yes, and it's the right call. Add @tailwindcss/container-queries (built into Tailwind v4) and wrap your grid in a @container div. Then use @sm:grid-cols-2 and @xl:grid-cols-4 instead of sm: and xl:. The grid responds to its container width, not the viewport — so the sidebar doesn't mess up your breakpoints.
Pass a contentStyle object to the <Tooltip> component. Use hardcoded hex values that match your Tailwind tokens, e.g., background: '#18181b' for zinc-900 and border: '1px solid rgba(255,255,255,0.08)' for a subtle border. You can't use Tailwind classes here because Recharts renders inside SVG.
Create a (dashboard) route group with its own layout.tsx that includes the sidebar and top bar. Protect it with middleware — check for a session cookie and redirect to /login if absent. Don't gate individual pages; gate the layout. This way unauthenticated users never get a flash of the dashboard shell.
CSS Grid. The sidebar-plus-main layout is a two-column grid, full stop. Flexbox gets messy when you need the sidebar to stay fixed height while the main area scrolls. grid-template-columns: 240px 1fr is cleaner and easier to read than nested flex containers with min-width hacks.
Wrap it in <ResponsiveContainer width="100%" height={220}>. Never set a fixed pixel width on the chart directly. Also make sure the parent div has min-w-0 applied — flex children don't constrain by default, and without it the chart will blow out its container on narrow screens.