* feat(editor): blit cached element bitmaps on whole device pixels Elements are painted from cached bitmaps, blitted 1:1 with smoothing disabled for unrotated and right-angle elements. Nearest-neighbor at a fractional offset is then a pixel-exact copy shifted to the nearest pixel — except at an exact half pixel, where a GPU-accelerated canvas decides the rounding per scanline by float precision: a few rows sample the neighboring row and glyph strokes come out doubled or broken, varying with scroll and position. A centered label lands on a half pixel routinely (odd free space ÷ 2), which is how it showed up on sticky notes. Measured in Chrome: a label blitted at y = 639.5 had 3 of 82 scanlines taken from the adjacent row; rounded to 640 it was clean. Round the blit position to whole device pixels for those elements. The context is scaled at the blit (canvas scale × zoom ÷ devicePixelRatio), so the rounding goes through `context.getTransform()`: rounding the pre-zoom value only lands on device pixels at 100% zoom. Rotated elements are resampled regardless and are left alone; export draws directly and is unaffected. Tests: the static canvas's `drawImage` events, with their CTM, land on whole device pixels for text and a rectangle at zoom 1, 1.5, 0.73 and 2.2 under fractional scroll; a rotated element keeps its offset. Extracted from dwelle/sticky-notes-2 (originally eddb67ca0). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(editor): draw the canvases at a whole-device-pixel scroll Panning writes a fractional scroll (pointer delta ÷ zoom). With every bitmap landing on its nearest device pixel, a fractional scroll means elements with different sub-pixel phases jump on different frames — a one-pixel wobble between neighbors, a label shifting against its box — and one scroll value in every pixel puts every integer-positioned element on the half-pixel boundary at once. Round the scroll to whole device pixels at render time, in all three renderers with the same formula so overlays sit on the content — the way a browser scrolls a page: each element keeps its own constant sub-pixel phase and the scene moves rigidly. The real scroll in state is untouched, so hit-testing and DOM overlays are unaffected; the difference stays under half a device pixel. Export is excluded (it draws vectors). Tests: the helper rounds to device pixels and is identity when already whole; a rectangle at integer coordinates blits on whole device pixels at zoom 1 and 1.5 under fractional scroll, and a scroll a tenth of a pixel further lands on the same pixels. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(editor): draw grid lines on whole device pixels at every zoom The grid was nudged onto the pixel grid only at 100% zoom, and only when the scroll happened to be whole; at any other zoom its one-pixel lines landed on fractional device pixels and rendered as two lighter pixels. Draw each line a whole number of device pixels wide, centered to cover them exactly (on the half pixel when the count is odd), computed from the zoom and the canvas scale — which also makes "one pixel" one device pixel group at devicePixelRatio 2 instead of a fractional pair. Lines thinner than a device pixel (zoomed far out) keep their sub-pixel width: their lightness is the point. The zoom-1-only nudge is subsumed. Test: with grid mode on, every grid path start on the static canvas has its across-axis coordinate on a half device pixel at zoom 1, 1.5 and 2.2 under fractional scroll. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): snap right-angle blits via the transform origin; grid width in device pixels Two review findings. Elements rotated by 90° or 270° enter the snapping branch (smoothing is off for right angles too), but their transform has near-zero `a` and `d` with the scale in `b`/`c`. Dividing by the diagonal sent the bitmap far offscreen — a rectangle at (110.37, 120.61) vanished at both angles. The snap now moves the transform's origin onto the rounded device position of the blit and draws at (0, 0): rotation, mirroring and scale stay in `a`–`d`, no inverse is needed, and the origin is an exact integer even in the canvas's float32 matrix (mapping a rounded position back through user space left it ~1e-5 off). The grid's "at least one device pixel wide" test compared `(1 / zoom) × zoom × scale` against 1 exactly; at 190% that product is 0.9999999999999999, so those lines skipped snapping and stayed blurry while neighboring zooms were crisp. The width is now computed straight in device pixels, `min(scale, max × zoom × scale)`, so there is no round trip to tolerate. Tests: rectangles at π/2 and 3π/2 blit on whole device pixels and stay next to the element; the grid test includes 190%. The blit helper in the test maps positions through the full matrix. The CHANGELOG bullets link the PR. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(editor): one devicePixelRatio read for the blit, no-op padding scaling dropped `drawElementFromCanvas` read `window.devicePixelRatio` eleven times; it reads it once. That read stays the global one on purpose: the cached bitmap was generated with it (`generateElementCanvas`) and the blit's size math needs the same value, so switching this path to the owner window's ratio is a separate change that has to include bitmap generation. `(padding × scale) / scale` is `padding`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): snap blits only where smoothing is off, through one predicate The snap ran during zoom gestures too, where the cached bitmaps stay at the old scale and are resampled with smoothing on: rounding there could only make elements twitch as the scale changes. The smoothing switch and the snap now share `canSnapElement` — unrotated or at a right angle, and not mid-gesture — so the two cannot drift apart. It is eligibility, not a guarantee of a 1:1 copy: freedraw is blitted with smoothing on and a size-capped cache is rescaled; snapping is harmless for both. Test: with `shouldCacheIgnoreZoom` set, the blit keeps its fractional position. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(editor): snap a label relative to its container, tie-safe Each bitmap rounding its own origin lets a label shift by a pixel against its box as a drag crosses half pixels (nearest-neighbor sampling did the same implicitly before the explicit snap). A label under an unrotated container now anchors to the container's rounded origin and rounds its offset from it separately, so both land on whole pixels and their distance never changes; every other element anchors to itself with a zero offset, through the same code path. The offset is formed from scene coordinates before the scroll is added, and a tiny bias breaks an exact half-pixel tie the same way every frame. Ties are common — any odd scene offset at 150% — and the float noise of a fractional drag otherwise flips `Math.round` between the two neighbors: a 200-wide box with a centered label went 92 → 91 → 92 device pixels across drags of 0, 0.3 and 0.4. Tests: a plain fractional offset and the half-pixel tie keep a constant device offset across five fractional drags at 150%. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Excalidraw Editor | Blog | Documentation | Excalidraw+
An open source virtual hand-drawn style whiteboard.
Collaborative and end-to-end encrypted.
Features
The Excalidraw editor (npm package) supports:
- 💯 Free & open-source.
- 🎨 Infinite, canvas-based whiteboard.
- ✍️ Hand-drawn like style.
- 🌓 Dark mode.
- 🏗️ Customizable.
- 📷 Image support.
- 😀 Shape libraries support.
- 🌐 Localization (i18n) support.
- 🖼️ Export to PNG, SVG & clipboard.
- 💾 Open format - export drawings as an
.excalidrawjson file. - ⚒️ Wide range of tools - rectangle, circle, diamond, arrow, line, free-draw, eraser...
- ➡️ Arrow-binding & labeled arrows.
- 🔙 Undo / Redo.
- 🔍 Zoom and panning support.
Excalidraw.com
The app hosted at excalidraw.com is a minimal showcase of what you can build with Excalidraw. Its source code is part of this repository as well, and the app features:
- 📡 PWA support (works offline).
- 🤼 Real-time collaboration.
- 🔒 End-to-end encryption.
- 💾 Local-first support (autosaves to the browser).
- 🔗 Shareable links (export to a readonly link you can share with others).
We'll be adding these features as drop-in plugins for the npm package in the future.
Quick start
Note: following instructions are for installing the Excalidraw npm package when integrating Excalidraw into your own app. To run the repository locally for development, please refer to our Development Guide.
Use npm or yarn to install the package.
npm install react react-dom @excalidraw/excalidraw
# or
yarn add react react-dom @excalidraw/excalidraw
Check out our documentation for more details!
Contributing
- Missing something or found a bug? Report here.
- Want to contribute? Check out our contribution guide or let us know on Discord.
- Want to help with translations? See the translation guide.
Integrations
Who's integrating Excalidraw
Google Cloud • Meta • CodeSandbox • Obsidian Excalidraw • Replit • Slite • Notion • HackerRank • and many others
Sponsors & support
If you like the project, you can become a sponsor at Open Collective or use Excalidraw+.
Thank you for supporting Excalidraw
Last but not least, we're thankful to these companies for offering their services for free: