Integrating Excalidraw: Building an Online Diagramming Tool
Integrating Excalidraw: Building an Online Diagramming Tool#
I needed an online diagramming tool recently. After evaluating several open-source options, I chose Excalidraw. It’s free, open-source, powerful, and smooth. Today I’ll share my experience integrating Excalidraw into a Next.js project.
Why Excalidraw#
Excalidraw is an open-source virtual whiteboard with key features:
- Hand-drawn style: Lines have a subtle “wiggle”, more approachable
- Lightweight: Core library is only 280KB gzipped
- Feature-rich: Rectangles, arrows, text, freehand drawing
- Export support: PNG, SVG, JSON formats
- Open source: MIT license, customize freely
Compared to heavyweight tools like draw.io, Excalidraw is lighter and better for quick architecture diagrams and flowcharts.
Dynamic Import: Solving SSR Issues#
Excalidraw depends on browser APIs (localStorage, canvas), so direct import fails during SSR. Dynamic import is required:
'use client'
import { useState, useEffect } from 'react'
import '@excalidraw/excalidraw/index.css'
export default function DiagramTool() {
const [Excalidraw, setExcalidraw] = useState<any>(null)
const [mounted, setMounted] = useState(false)
// Avoid hydration errors
useEffect(() => {
setMounted(true)
}, [])
// Dynamic import Excalidraw
useEffect(() => {
if (!mounted) return
import('@excalidraw/excalidraw')
.then((mod) => {
setExcalidraw(() => mod.Excalidraw)
})
.catch((err) => {
console.error('Failed to load Excalidraw:', err)
})
}, [mounted])
if (!mounted || !Excalidraw) {
return <LoadingSpinner />
}
return <Excalidraw />
}
Key points:
- ‘use client’ directive: Required for Next.js 13+ App Router client components
- mounted state: Ensure client-side only rendering, avoid SSR/CSR mismatch
- Dynamic import: Asynchronous loading prevents SSR errors
- CSS import: Must import
index.css, or styles will be missing
Local Storage: Auto-save Drawings#
User drawings should auto-save to prevent loss on refresh. Excalidraw provides an onChange callback:
const handleChange = useCallback((elements: any[]) => {
try {
localStorage.setItem('excalidraw-elements', JSON.stringify(elements))
} catch (e) {
console.error('Failed to save:', e)
}
}, [])
<Excalidraw onChange={handleChange} />
Loading saved data:
const [initialData, setInitialData] = useState<any>(null)
useEffect(() => {
try {
const saved = localStorage.getItem('excalidraw-elements')
if (saved) {
setInitialData({
elements: JSON.parse(saved)
})
}
} catch (e) {
console.error('Failed to load:', e)
}
}, [])
<Excalidraw initialData={initialData} />
Note: localStorage has a 5MB limit. Complex diagrams may exceed this. For production, consider IndexedDB or cloud storage.
Fullscreen Mode: Immersive Drawing#
Drawing needs space. Fullscreen is essential:
const [isFullscreen, setIsFullscreen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const toggleFullscreen = useCallback(async () => {
if (!containerRef.current) return
try {
if (!document.fullscreenElement) {
await containerRef.current.requestFullscreen()
setIsFullscreen(true)
} else {
await document.exitFullscreen()
setIsFullscreen(false)
}
} catch (err) {
console.error('Fullscreen error:', err)
}
}, [])
// Listen for fullscreen changes (user presses ESC)
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [])
return (
<div
ref={containerRef}
className={isFullscreen ? 'fixed inset-0 z-50' : 'h-[600px]'}
>
<Excalidraw />
<button onClick={toggleFullscreen}>
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
</button>
</div>
)
Key details:
- ref for container: Need DOM element to call
requestFullscreen - Listen to fullscreenchange: Update state when user presses ESC
- fixed inset-0: Cover entire screen when fullscreen
Library: Pre-built Shape Templates#
Excalidraw supports a Library for pre-built shapes. A library is a JSON file:
{
"type": "excalidrawlib",
"version": 1,
"library": [
[
{"type": "rectangle", "x": 0, "y": 0, "width": 100, "height": 60},
{"type": "text", "x": 20, "y": 20, "text": "Server"}
]
]
}
Loading the library:
const [libraryItems, setLibraryItems] = useState<any[]>([])
useEffect(() => {
const loadLibrary = async () => {
try {
const response = await fetch('/library/system-design.excalidrawlib')
const libraryData = await response.json()
if (libraryData.library && Array.isArray(libraryData.library)) {
const items = libraryData.library.map((elements: any[], index: number) => ({
id: `library-item-${index}`,
status: 'published',
elements: elements || [],
created: Date.now(),
}))
setLibraryItems(items)
}
} catch (err) {
console.error('Failed to load library:', err)
}
}
loadLibrary()
}, [])
<Excalidraw initialData={{ libraryItems }} />
Libraries are perfect for teams—pre-built architecture templates, common icons, etc.
Theme Support: Dark Mode#
Excalidraw natively supports dark mode via the theme prop:
import { useTheme } from '@/components/theme/ThemeProvider'
export default function DiagramTool() {
const { theme } = useTheme()
return (
<Excalidraw
theme={theme} // 'light' or 'dark'
langCode={locale === 'zh' ? 'zh-CN' : 'en'}
/>
)
}
Performance: Large Canvases#
When canvas elements exceed 1000, lag may occur. Optimization tips:
1. Virtualized Rendering#
Excalidraw internally implements viewport culling, only rendering visible elements. But for huge canvases (10000+ elements), manually limit:
const handleChange = useCallback((elements: any[]) => {
if (elements.length > 5000) {
alert('Too many elements. Consider splitting into multiple canvases.')
return
}
// Save logic
}, [])
2. Compressed Storage#
Remove unnecessary properties when saving:
const handleChange = useCallback((elements: any[]) => {
const compressed = elements.map(el => ({
id: el.id,
type: el.type,
x: el.x,
y: el.y,
width: el.width,
height: el.height,
strokeColor: el.strokeColor,
// Keep only essential fields
}))
localStorage.setItem('excalidraw-elements', JSON.stringify(compressed))
}, [])
3. Export Optimization#
Specify scale when exporting to PNG:
const exportToPng = async () => {
const blob = await exportToBlob({
elements,
mimeType: 'image/png',
quality: 0.8, // Compression quality
scale: 1 // Scale ratio
})
// Download logic
}
Edge Cases#
1. Mobile Support#
Excalidraw has good touch support, but you may want to disable pinch zoom:
<Excalidraw
UIOptions={{
canvasActions: {
zoom: false // Disable zoom
}
}}
/>
2. Import Compatibility#
Importing from older Excalidraw versions may have compatibility issues:
const loadScene = async (data: string) => {
try {
const elements = JSON.parse(data)
// Version check
if (!Array.isArray(elements)) {
throw new Error('Invalid format')
}
return elements
} catch (e) {
alert('Import failed: Invalid file format')
return []
}
}
3. Concurrent Editing#
Excalidraw is single-user. Multiple users editing simultaneously causes conflicts. For collaboration:
- Use Excalidraw’s official collaboration service
- Implement your own WebSocket sync
The Result#
Based on these ideas, I built: Excalidraw Diagramming Tool
Features:
- Free online diagramming, no registration
- Rectangles, arrows, text, freehand drawing
- Auto-save to local storage
- Fullscreen immersive mode
- Export to PNG, SVG, JSON
- Dark mode support
Integrating Excalidraw isn’t complex. The key is handling dynamic import, local storage, fullscreen, and other details correctly. Hope this helps.
Related: Mermaid Editor | Mindmap