Inspiration

Excalidraw is the diagramming tool developers actually like, and on iPad you are stuck in a browser tab. The web version fights the Apple Pencil: your palm leaves marks, undo hides, panning smears the canvas. The hand-drawn look is the whole appeal, and a Pencil is the right tool for it, and the two never met.

Xcalidraw started from one refusal: no WebView. If the answer were a wrapper around the web app, it would already exist. The hand-drawn rendering had to be rebuilt natively, or the Pencil would keep losing.

What it does

Xcalidraw is a native iPad canvas for Excalidraw's hand-drawn diagramming. It opens the .excalidraw files you already have, draws them with a real Pencil, and imports Mermaid flowcharts and sequence diagrams as editable elements rather than pictures.

Palm rejection is on by default, and panning and zooming never leave a mark. That is the product, not a setting.

Nothing needs an account. Files are local, offline, and yours. Export All Data hands every canvas back as .excalidraw at any time, on every tier, free.

The web version does Xcalidraw does
Marks the canvas when your palm lands Rejects the palm; only the Pencil draws
Renders strokes in a WebView Renders them in CoreGraphics, natively
Treats a file as an export target Treats .excalidraw as the storage model, 1:1
Pastes Mermaid as a diagram you cannot edit Imports Mermaid as native, editable elements

A .excalidraw file round-trips: unknown and newer fields are preserved, so a canvas saved by a future web build survives a trip through Xcalidraw untouched. It is a compatible client, not a lossy importer.

How we built it

Three pure-Swift packages, Foundation and CoreGraphics only, no SwiftUI or UIKit inside them, so the drawing logic is testable on the host in seconds instead of a simulator:

  • ExcalidrawScene: the element model and the .excalidraw codec.
  • SketchyGeometry: from-scratch ports of Rough.js's hand-drawn geometry and perfect-freehand's stroke outlines. A golden-fixture suite pins the output against the reference so a rendering regression fails a fast host test, not a store review.
  • CanvasEngine: hit-testing, selection, transform, snapping, and binding geometry.

The app target renders one scene through two chromes: iPad is the full drawing surface; iPhone is view and light edit of the same scene, not a second render path. Both light and dark are hand-mapped palettes, built independently rather than one filtered from the other.

RevenueCat holds the Pro entitlement behind a bespoke SwiftUI paywall. The paywall renders Apple's displayPrice, never a hard-coded number, so a price change in App Store Connect needs no build. The SDK reads the entitlement; it never becomes the source of truth for what a user drew.

SwiftUI on iOS 18, Swift 6 with strict concurrency, Swift Testing for logic and XCTest for the UI. Six locales, each joining the device language picker only once it clears native review.

Challenges we ran into

The hand-drawn look is deterministic pseudo-randomness, and "close enough" is visible. Rough.js and perfect-freehand had to match the reference, not merely resemble it, or a .excalidraw file would look wrong the moment it crossed between the web app and ours. The golden-fixture suite exists because eyeballing strokes does not scale.

Palm rejection was the fiddliest thing in the app. A Pencil-first canvas that drops one stray mark per session is not Pencil-first. Getting pan and zoom to never leave a mark, while a real drawing stroke always does, is the difference between the wedge and a gimmick.

Mermaid forced a gating decision. Import is wholly Pro, but locking the parse would make it read as broken. So a free user gets the real parse, layout, and live preview. Only Insert is locked. Gating reads as locked, never as broken. We support flowchart and sequence only, and say "Mermaid import," never "support," because ER, gantt, state, and class are not offered at all.

What we learned

The compatible-client discipline is the whole product. The shortcuts were always there to take: rasterize a Mermaid import, drop an unknown field on save, filter the dark theme instead of mapping it. Taking any one of them turns Xcalidraw into a worse Excalidraw instead of the native one. Every refusal that made the build harder is a reason the file you already have still opens.

Built With

  • apple-pencil
  • cloudkit
  • coregraphics
  • ios
  • ipados
  • revenuecat
  • storekit
  • swift
  • swiftui
  • xcode
Share this project:

Updates

Submission history