Customizable dashboards let users arrange widgets the way they want. Here is how to build one with react-grid-layout.
Install Dependencies
pnpm add react-grid-layout
pnpm add -D @types/react-grid-layout
Define Widget Types
// types/dashboard.ts
export interface Widget {
id: string;
type: "metric" | "chart" | "table" | "activity" | "notes";
title: string;
config: Record<string, unknown>;
}
export interface DashboardLayout {
lg: ReactGridLayout.Layout[];
md: ReactGridLayout.Layout[];
sm: ReactGridLayout.Layout[];
}
export interface DashboardConfig {
id: string;
name: string;
widgets: Widget[];
layouts: DashboardLayout;
}
Dashboard Component
"use client";
import { useCallback, useState } from "react";
import { Responsive, WidthProvider } from "react-grid-layout";
import type { Layouts, Layout } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import "react-resizable/css/styles.css";
import type { Widget, DashboardConfig } from "@/types/dashboard";
import { MetricWidget } from "./widgets/MetricWidget";
import { ChartWidget } from "./widgets/ChartWidget";
import { TableWidget } from "./widgets/TableWidget";
import { ActivityWidget } from "./widgets/ActivityWidget";
import { NotesWidget } from "./widgets/NotesWidget";
const ResponsiveGrid = WidthProvider(Responsive);
const WIDGET_COMPONENTS: Record<Widget["type"], React.ComponentType<{ widget: Widget }>> = {
metric: MetricWidget,
chart: ChartWidget,
table: TableWidget,
activity: ActivityWidget,
notes: NotesWidget,
};
interface DashboardProps {
config: DashboardConfig;
onSave: (config: DashboardConfig) => void;
}
export function CustomizableDashboard({ config, onSave }: DashboardProps) {
const [widgets, setWidgets] = useState(config.widgets);
const [layouts, setLayouts] = useState<Layouts>(config.layouts);
const [isEditing, setIsEditing] = useState(false);
const handleLayoutChange = useCallback(
(_current: Layout[], allLayouts: Layouts) => {
setLayouts(allLayouts);
},
[]
);
const handleSave = () => {
const updated = { ...config, widgets, layouts: layouts as DashboardConfig["layouts"] };
onSave(updated);
setIsEditing(false);
};
const addWidget = (type: Widget["type"]) => {
const id = `widget-${Date.now()}`;
const newWidget: Widget = {
id,
type,
title: `New ${type} widget`,
config: {},
};
setWidgets((prev) => [...prev, newWidget]);
// Add to layouts at bottom
const newLayoutItem: Layout = {
i: id,
x: 0,
y: Infinity, // react-grid-layout places at bottom
w: type === "metric" ? 3 : 6,
h: type === "metric" ? 2 : 4,
minW: 2,
minH: 2,
};
setLayouts((prev) => ({
lg: [...(prev.lg || []), newLayoutItem],
md: [...(prev.md || []), { ...newLayoutItem, w: Math.min(newLayoutItem.w, 4) }],
sm: [...(prev.sm || []), { ...newLayoutItem, w: 2 }],
}));
};
const removeWidget = (id: string) => {
setWidgets((prev) => prev.filter((w) => w.id !== id));
setLayouts((prev) => ({
lg: (prev.lg || []).filter((l) => l.i !== id),
md: (prev.md || []).filter((l) => l.i !== id),
sm: (prev.sm || []).filter((l) => l.i !== id),
}));
};
return (
<div>
{/* Toolbar */}
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold">{config.name}</h1>
<div className="flex gap-2">
{isEditing && (
<>
<WidgetPicker onAdd={addWidget} />
<button
onClick={handleSave}
className="px-3 py-1.5 bg-primary text-primary-foreground text-sm rounded"
>
Save layout
</button>
</>
)}
<button
onClick={() => (isEditing ? handleSave() : setIsEditing(true))}
className="px-3 py-1.5 border text-sm rounded hover:bg-muted"
>
{isEditing ? "Done" : "Customize"}
</button>
</div>
</div>
{/* Grid */}
<ResponsiveGrid
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 768, sm: 0 }}
cols={{ lg: 12, md: 8, sm: 2 }}
rowHeight={80}
isDraggable={isEditing}
isResizable={isEditing}
onLayoutChange={handleLayoutChange}
draggableHandle=".widget-handle"
compactType="vertical"
margin={[16, 16]}
>
{widgets.map((widget) => {
const WidgetComponent = WIDGET_COMPONENTS[widget.type];
return (
<div key={widget.id} className="bg-card border rounded-lg overflow-hidden">
{/* Widget Header */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span
className={`text-sm font-medium ${isEditing ? "widget-handle cursor-grab" : ""}`}
>
{isEditing && (
<span className="inline-block mr-1.5 text-muted-foreground">
<svg className="w-4 h-4 inline" fill="currentColor" viewBox="0 0 24 24">
<circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
<circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
</svg>
</span>
)}
{widget.title}
</span>
{isEditing && (
<button
onClick={() => removeWidget(widget.id)}
className="text-muted-foreground hover:text-destructive text-xs"
aria-label="Remove widget"
>
Remove
</button>
)}
</div>
{/* Widget Content */}
<div className="p-3 h-[calc(100%-41px)] overflow-auto">
<WidgetComponent widget={widget} />
</div>
</div>
);
})}
</ResponsiveGrid>
</div>
);
}
Widget Picker
function WidgetPicker({ onAdd }: { onAdd: (type: Widget["type"]) => void }) {
const [open, setOpen] = useState(false);
const widgetTypes: { type: Widget["type"]; label: string; description: string }[] = [
{ type: "metric", label: "Metric Card", description: "Single KPI with trend" },
{ type: "chart", label: "Chart", description: "Line, bar, or area chart" },
{ type: "table", label: "Data Table", description: "Tabular data view" },
{ type: "activity", label: "Activity Feed", description: "Recent events log" },
{ type: "notes", label: "Notes", description: "Editable text notes" },
];
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="px-3 py-1.5 border text-sm rounded hover:bg-muted"
>
+ Add widget
</button>
{open && (
<div className="absolute right-0 top-full mt-1 w-64 bg-popover border rounded-lg shadow-lg z-10 p-2">
{widgetTypes.map(({ type, label, description }) => (
<button
key={type}
onClick={() => { onAdd(type); setOpen(false); }}
className="w-full text-left px-3 py-2 rounded hover:bg-muted"
>
<div className="text-sm font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{description}</div>
</button>
))}
</div>
)}
</div>
);
}
Sample Metric Widget
// widgets/MetricWidget.tsx
import type { Widget } from "@/types/dashboard";
export function MetricWidget({ widget }: { widget: Widget }) {
const value = (widget.config.value as number) ?? 1234;
const change = (widget.config.change as number) ?? 12.5;
const isPositive = change >= 0;
return (
<div className="flex flex-col justify-center h-full">
<div className="text-3xl font-bold">
{typeof value === "number" ? value.toLocaleString() : value}
</div>
<div className={`text-sm mt-1 ${isPositive ? "text-green-600" : "text-red-600"}`}>
{isPositive ? "+" : ""}
{change}% from last period
</div>
</div>
);
}
Persist Layout
// lib/dashboard-storage.ts
import type { DashboardConfig } from "@/types/dashboard";
const STORAGE_KEY = "dashboard-config";
export function saveDashboardConfig(config: DashboardConfig): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
}
export function loadDashboardConfig(): DashboardConfig | null {
const stored = localStorage.getItem(STORAGE_KEY);
if (!stored) return null;
try {
return JSON.parse(stored) as DashboardConfig;
} catch {
return null;
}
}
Need a Custom Dashboard?
We build data-driven dashboards for businesses. Contact us to get started.