Dashboard Design for SaaS: Layout Patterns That Work
We have built dashboards for over a dozen products at this point. Feedback platforms, freelancer workspaces, creator marketplaces, booking systems — each one different, but the underlying layout patterns repeat. After shipping Trackelio, LancerSpace, and MindHyv, we have strong opinions about what actually works for SaaS dashboards and what just looks good in a Dribbble shot.
This post covers the layout patterns, component structures, and responsive strategies we use across our projects. Not theory — actual patterns we ship.
The Sidebar Plus Main Content Layout
Almost every SaaS dashboard we build starts with a sidebar-plus-main-content layout. The reason is simple: SaaS products tend to have deep navigation trees with multiple top-level sections (dashboard, projects, settings, billing), and a sidebar is the only layout that scales to accommodate them without sacrificing screen real estate for the actual content.
Here is the base structure we use in most of our SvelteKit projects:
<!-- src/routes/(app)/+layout.svelte -->
<script lang="ts">
import Sidebar from '$lib/components/Sidebar.svelte';
import MobileNav from '$lib/components/MobileNav.svelte';
import { page } from '$app/stores';
</script>
<div class="flex h-screen overflow-hidden bg-gray-50">
<!-- Desktop sidebar: fixed width, full height -->
<aside class="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:bg-white">
<Sidebar currentPath={$page.url.pathname} />
</aside>
<!-- Mobile navigation -->
<MobileNav currentPath={$page.url.pathname} />
<!-- Main content area -->
<main class="flex-1 overflow-y-auto">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<slot />
</div>
</main>
</div>
A few things to notice. The sidebar is fixed at 256px (w-64) on desktop and completely hidden on mobile. The main content area uses overflow-y-auto so it scrolls independently of the sidebar. And the content within main gets a max-width so it does not stretch to absurdity on ultrawide monitors.
We learned the max-width lesson the hard way on an early version of LancerSpace. Data tables that stretch to 2560px are unreadable. Nobody wants to track their eyes across four feet of screen to read a row.
The Collapsible Sidebar
For products with dense navigation, we add a collapsible sidebar that shrinks to icon-only mode. This gives users more horizontal space for content-heavy views like data tables and kanban boards while keeping navigation accessible.
<script lang="ts">
let collapsed = $state(false);
</script>
<aside
class="hidden lg:flex lg:flex-col lg:border-r lg:bg-white transition-all duration-200"
class:lg:w-64={!collapsed}
class:lg:w-16={collapsed}
>
<nav class="flex-1 space-y-1 px-2 py-4">
{#each navItems as item}
<a
href={item.href}
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium
text-gray-700 hover:bg-gray-100"
>
<svelte:component this={item.icon} class="h-5 w-5 shrink-0" />
{#if !collapsed}
<span>{item.label}</span>
{/if}
</a>
{/each}
</nav>
<button
onclick={() => collapsed = !collapsed}
class="border-t p-3 text-gray-400 hover:text-gray-600"
>
{collapsed ? 'Expand' : 'Collapse'}
</button>
</aside>
We persist the collapsed state in localStorage so the user’s preference survives page reloads. Small detail, but people notice when their layout keeps resetting.

Card-Based Metrics: The KPI Row
Every dashboard has a row of key metrics at the top. Monthly revenue. Active users. Open tickets. Conversion rate. These cards serve as a health check — a glance that tells the user whether things are normal or whether they need to dig deeper.
Here is the component pattern we use:
<!-- MetricCard.svelte -->
<script lang="ts">
interface Props {
label: string;
value: string;
change?: number;
changeLabel?: string;
}
let { label, value, change, changeLabel }: Props = $props();
</script>
<div class="rounded-lg border bg-white p-6">
<p class="text-sm font-medium text-gray-500">{label}</p>
<p class="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
{#if change !== undefined}
<p class="mt-2 flex items-center text-sm">
<span
class={change >= 0 ? 'text-green-600' : 'text-red-600'}
>
{change >= 0 ? '+' : ''}{change}%
</span>
{#if changeLabel}
<span class="ml-1 text-gray-500">{changeLabel}</span>
{/if}
</p>
{/if}
</div>
And the usage in a dashboard page:
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="Active Users" value="2,847" change={12.5} changeLabel="vs last month" />
<MetricCard label="Revenue" value="$14,290" change={8.2} changeLabel="vs last month" />
<MetricCard label="Open Tickets" value="23" change={-15.3} changeLabel="vs last week" />
<MetricCard label="Avg Response" value="2.4h" change={-22.1} changeLabel="vs last month" />
</div>
The grid is responsive: one column on mobile, two on tablets, four on desktop. We use gap-4 instead of margins so the spacing stays consistent regardless of how many cards are in a row. For Trackelio, these metrics show feedback volume, vote activity, and status distribution. For LancerSpace, it is revenue, active clients, and outstanding invoices.
One important design decision: keep the metric count between three and five. Fewer than three feels empty. More than five means users start ignoring them. If you have ten KPIs, you have a reporting page, not a dashboard.
Chart Placement and Sizing
Charts go below the KPI row. We use a two-column grid for the chart section: a larger chart (spanning two-thirds of the width) on the left and a smaller chart (one-third) on the right.
<div class="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Primary chart: takes 2/3 width -->
<div class="lg:col-span-2 rounded-lg border bg-white p-6">
<h3 class="text-sm font-medium text-gray-500">Revenue Over Time</h3>
<div class="mt-4 h-72">
<LineChart data={revenueData} />
</div>
</div>
<!-- Secondary chart: takes 1/3 width -->
<div class="rounded-lg border bg-white p-6">
<h3 class="text-sm font-medium text-gray-500">Revenue by Source</h3>
<div class="mt-4 h-72">
<DonutChart data={sourceData} />
</div>
</div>
</div>
The primary chart is usually a line or bar chart showing a trend over time. The secondary chart is a donut or pie chart showing distribution. This pattern works because the trend chart needs horizontal space to be readable (more data points, axis labels), while distribution charts are compact by nature.
For the charting library, we have settled on Chart.js wrapped in a thin Svelte component. We tried Recharts, D3 directly, and a few others. Chart.js hits the sweet spot of customization, bundle size, and rendering performance for dashboard use cases. It is not the prettiest out of the box, but it responds well to theming.

Data Tables That Scale
Below the charts, most dashboards have a data table. This is where the actual work happens — users filter, sort, search, and take action on individual records.
The table structure we use across projects:
<div class="mt-6 rounded-lg border bg-white">
<!-- Table header with search and filters -->
<div class="flex flex-col gap-4 border-b p-4 sm:flex-row sm:items-center sm:justify-between">
<div class="relative">
<input
type="text"
placeholder="Search..."
bind:value={searchQuery}
class="w-full rounded-md border py-2 pl-10 pr-4 text-sm sm:w-64"
/>
</div>
<div class="flex gap-2">
<StatusFilter bind:value={statusFilter} />
<DateRangeFilter bind:value={dateRange} />
</div>
</div>
<!-- Responsive table wrapper -->
<div class="overflow-x-auto">
<table class="w-full text-left text-sm">
<thead class="border-b bg-gray-50 text-xs uppercase text-gray-500">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3 hidden md:table-cell">Created</th>
<th class="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
{#each filteredItems as item}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium">{item.name}</td>
<td class="px-4 py-3">
<StatusBadge status={item.status} />
</td>
<td class="px-4 py-3 hidden md:table-cell text-gray-500">
{formatDate(item.createdAt)}
</td>
<td class="px-4 py-3 text-right">
<ActionMenu {item} />
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between border-t px-4 py-3">
<span class="text-sm text-gray-500">
Showing {startIndex + 1}-{endIndex} of {totalCount}
</span>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
</div>
</div>
A few patterns worth highlighting. We hide less-important columns on mobile using hidden md:table-cell instead of forcing horizontal scroll for everything. The search and filter bar stacks vertically on mobile (flex-col) and goes horizontal on desktop (sm:flex-row). And we always include pagination — even if the dataset is small today, it will not be small forever.
For Trackelio, the main table shows feedback items with vote counts, status, and category. For LancerSpace, it is clients, invoices, or proposals depending on the section.
Responsive Strategy: Not Just Smaller
The biggest mistake we see in dashboard design is treating responsiveness as “make the desktop layout smaller.” That does not work. A dashboard on a phone is a fundamentally different use case than a dashboard on a 27-inch monitor.
Our responsive strategy has three tiers:
Desktop (1024px and up): Full sidebar, multi-column grids, expanded data tables. This is the power-user view.
Tablet (640px to 1023px): Sidebar collapses or becomes an overlay. Grids drop to two columns. Tables show fewer columns.
Mobile (below 640px): Bottom tab navigation or hamburger menu. Single-column layout. Tables transform into card lists.
The mobile transformation is the most important part. Instead of cramming a table into 375px, we switch to a card layout:
<!-- Desktop: table layout -->
<div class="hidden sm:block">
<table><!-- full table --></table>
</div>
<!-- Mobile: card layout -->
<div class="sm:hidden space-y-3">
{#each items as item}
<a href="/items/{item.id}" class="block rounded-lg border bg-white p-4">
<div class="flex items-center justify-between">
<span class="font-medium">{item.name}</span>
<StatusBadge status={item.status} />
</div>
<div class="mt-2 flex items-center gap-4 text-sm text-gray-500">
<span>{formatDate(item.createdAt)}</span>
<span>{item.amount}</span>
</div>
</a>
{/each}
</div>
This is more work than a single responsive table, but the result is dramatically better on mobile. We wrote about responsive patterns more broadly in our post on building modern web apps.

Empty States and Loading States
Two states that get neglected in dashboard design: empty and loading.
An empty dashboard — the state a new user sees — is a critical onboarding moment. A blank table with “No results found” is a dead end. Instead, we show contextual empty states that guide the user toward their first action:
{#if items.length === 0}
<div class="py-12 text-center">
<div class="mx-auto h-12 w-12 text-gray-400">
<InboxIcon />
</div>
<h3 class="mt-2 text-sm font-medium text-gray-900">No feedback yet</h3>
<p class="mt-1 text-sm text-gray-500">
Share your feedback board link to start collecting ideas.
</p>
<div class="mt-6">
<button class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white">
Copy Board Link
</button>
</div>
</div>
{/if}
For loading states, we use skeleton screens instead of spinners. A skeleton that matches the shape of the incoming content (card outlines, table row placeholders) feels faster than a spinner, even if the actual load time is the same.
/* Skeleton pulse animation */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
The Component Hierarchy
After building enough dashboards, a clear component hierarchy emerges:
Layout components handle the overall structure: AppShell, Sidebar, MobileNav, PageHeader. These are per-project because navigation structures differ.
Data display components are highly reusable: MetricCard, DataTable, StatusBadge, Pagination, EmptyState, SkeletonLoader. We copy these between projects and customize the styling.
Chart components wrap the charting library: LineChart, BarChart, DonutChart. Thin wrappers that accept data and options, nothing more.
Action components handle user interactions: ActionMenu, FilterBar, SearchInput, BulkActions. These tend to be project-specific because they tie to business logic.
The key insight is that the layout and data display layers are stable across projects. The customization happens in theming (colors, border radius, spacing) and in the action layer where business logic lives. We covered this kind of component architecture in our post on component-driven development.
Common Mistakes We See
A few anti-patterns that come up repeatedly in dashboard projects:
Too many navigation levels. If your sidebar has collapsible sections inside collapsible sections, your information architecture needs work, not more UI. Flatten the hierarchy.
Charts without context. A line going up means nothing without labels, time range, and comparison. Always show the time period and, where possible, a comparison to a previous period.
Real-time everything. Not every metric needs to update in real-time. Most dashboards work perfectly well with data that refreshes on page load or at five-minute intervals. Real-time is expensive and complex — save it for the things that actually need it, like notification counts or live user activity.
Ignoring whitespace. Dense dashboards feel overwhelming. Give sections room to breathe. The space between cards, between sections, and around tables is not wasted — it is what makes the data readable.
What We Would Do Differently
If we rebuilt our earliest dashboards today, we would standardize the layout components earlier and invest more in the empty state experience. We would also build a shared charting configuration so every chart in the product has consistent colors, typography, and interaction behavior instead of configuring each one individually.
The dashboard is the heart of a SaaS product. Users spend most of their time here. Getting the layout patterns right early saves significant design and engineering time downstream.
If you are building a SaaS dashboard and want help getting the architecture right from the start, reach out at hello@threshline.com.