Skip to main content

Command Palette

Search for a command to run...

How I Built a Figma-Like Canvas Editor in React (and What I Learned)

Updated
18 min read
V
Senior frontend engineer with 5+ years building React systems that go beyond CRUD: real-time visualisation at 1kHz, Electron orchestration around native SDKs, WebGPU rendering, and AI-aware design systems. Conference speaker on React performance and prompt-driven systems. Author of the React Beyond UI series — 16 deep-dive posts on frontend systems engineering.

A few months ago I started building a Figma-like editor inside a React + Electron desktop app. The goal was simple to describe and hard to ship: let users design visual stimuli (shapes, text, images, simple animations) on a canvas, organise them into trials and classes, and play them back as live experiments on connected hardware.

The "Figma-like" part is what made it interesting. A free-form canvas with pan and zoom. Drag-to-move shapes. A right-side properties panel that updates in place. A left-side tree of trials and classes. A floating shape picker. Undo and redo on everything.

This post is a writeup of how that editor came together, the libraries I chose and rejected, the design decisions that survived a few months of iteration, and the bits I wish I had done differently from the start.

Table of contents

Prerequisites

This article assumes you're comfortable with the following before reading along:

  • React and TypeScript at an intermediate level. Hooks, refs, generics.
  • A basic understanding of the HTML5 Canvas API. You don't need to be a games engineer; the libraries handle the low-level work. But knowing that canvas is an immediate-mode pixel buffer (vs. the DOM's retained-mode tree) helps.
  • Familiarity with state management in React. I use Zustand here, but any equivalent (Jotai, Valtio, Redux Toolkit) maps to the same patterns.
  • An understanding of monorepo conventions. The code lives across several packages (primitives, components, plugins). Knowing how internal package imports work helps you follow the file paths.

You don't need any prior experience with Konva, XYFlow, or canvas libraries in general. I'll explain those decisions along the way.

What I was building

The product context: a desktop research application that drives connected hardware and visualises real-time data. One of the features it needed was a visual experiment editor where users compose stimuli (shapes, text, images, animations) into trials and classes, then play them back to a participant while the hardware records.

Concretely, the editor had to:

  • Render a free-form canvas where users can place shapes, drag them around, and resize them.
  • Support 8 to 10 distinct shape kinds (rectangle, circle, polygon, lines, arrow, star, text, image).
  • Group stimuli into "phases" (baseline, cue, stimulus, rest, onboarding).
  • Group those into "classes" (e.g., "left arrow", "right arrow") and "trials" above that.
  • Let the user configure a flicker (square-wave opacity) or move animation per stimulus.
  • Persist the whole document so users don't lose work.
  • Undo and redo everything, instantly.

If you imagine Figma stripped down to one screen, plus a sidebar that organises canvases into a tree, that's the rough mental model.

Why Konva, not XYFlow or SVG

The first non-trivial decision: what's actually drawing the pixels?

I evaluated three options:

  • XYFlow (formerly React Flow). Excellent for node-based editors: directed graphs, flowcharts, ETL pipelines. Each "node" is a React component absolutely positioned by the library. Edges between nodes are first-class.
  • Raw SVG. Render shapes as <rect>, <circle>, etc. inside an <svg> root. Familiar, declarative, accessible. Performance drops once you go past a few hundred elements.
  • Konva (via react-konva). A canvas-based 2D rendering library. Stage and Layer abstractions, built-in drag, native Transformer for resize/rotate. Each shape is a Konva node, not a DOM node.

I picked Konva. The reasoning:

XYFlow is for graphs, not free-form canvases. I wasn't building a node-based editor. I was building a layout-style design tool where shapes can sit anywhere, overlap, rotate, and animate. XYFlow's edge model and grid-style layout would have fought me at every turn.

SVG would have run out of headroom. A research experiment might have 50 shapes per phase, 5 phases, 5 classes, on screen at once. That's 1,250 DOM nodes for one scene, plus a Transformer overlay, plus selection rectangles. Pan and zoom on that many SVG nodes drops below 30 fps on a real laptop. Canvas keeps the same scene at a solid 60 fps because the GPU rasterises once per frame, not per element.

Konva gives you the heavy lifting for free. Transformer is a real Konva primitive: select a shape, get resize handles and rotation, no work. Drag is a draggable prop on every node. Hit testing, layer ordering, caching for performance: all there.

The tradeoff is that Konva's React layer (react-konva) is less idiomatic than vanilla React. Some Konva props don't reconcile cleanly across renders. You'll occasionally need to call node.cache() or layer.draw() imperatively. But the wins on performance and built-in interactions made it worth it.

The three-pane layout

The shell of the editor is a three-pane layout you've seen in every design tool. The diagram below shows the three regions and the data flowing between them:

The three-pane layout diagram

The left rail tracks the document structure: a tree of trials, each containing classes, each containing stimuli. Clicking a class focuses it; clicking a stimulus selects it on the canvas.

The center is the Konva Stage. It pans (middle mouse, or space + left drag), zooms (wheel), and renders the active phase's stimuli for the active class. A floating toolbar adds shapes; phase tabs at the bottom switch between baseline, cue, stimulus, rest, and onboarding.

The right rail is the inspector. When a stimulus is selected, it shows Position, Size, Rotation, kind-specific properties (e.g., Corner Radius for a rectangle), and an Animation editor. Each section is collapsible.

That's the surface. The interesting work was below it.

Scene-local coordinates, not object scale

Here's the single most important design decision I made on this project, and I want to explain it in detail because it's the one most teams get wrong.

When the user zooms in, you have two choices for what zoom means:

  1. Scale every object. Each shape's width, height, and position get multiplied by the zoom factor.
  2. Scale the stage. The shapes keep their stored coordinates; the Konva Stage as a whole gets a scaleX and scaleY transform.

Option 1 feels intuitive but it's a trap. Once you do this, every operation that touches a shape (copy, paste, undo, save to disk) has to know what zoom level the user was at when it happened. Coordinates leak the view state into the data model. You'll have bugs forever.

Option 2 is the right answer. The Stage scales as a whole; the shapes' stored coordinates never change.

Scene-local coordinates, not object scale diagram

The diagram shows how the math flows. Scene coordinates are stored at the document level. The Stage transform turns them into screen coordinates on render. When the user drags a shape, the screen-space delta is divided by the current scale, then applied to the scene coordinate.

In code, the Stage looks like this:

<Stage
  width={size.width}
  height={size.height}
  scaleX={scale}
  scaleY={scale}
  x={offset.x}
  y={offset.y}
  onWheel={handleWheel}
  onMouseDown={handleMouseDown}
>
  <Layer>{/* grid lines, back-projected through scale */}</Layer>
  <Layer>{stimuli.map(renderStimulus)}</Layer>
</Stage>

The shapes inside the layer have x={shape.x} and y={shape.y} as stored. They don't know about the zoom. Konva applies the Stage transform on render.

This decision pays off everywhere downstream. Save to disk: just serialise the stimulus list. Copy and paste: just clone with crypto.randomUUID(). Undo: snapshot the document, push it onto a history stack. None of these operations need to think about zoom because zoom is a view concern, not a model concern.

State management with Zustand

For the document state, I used Zustand. Two reasons:

It's minimal. No providers wrapping the tree (which you can do but don't have to). No reducer boilerplate. The store is a hook you call from wherever, and the slice it returns triggers re-renders only when that specific slice changes.

The persist middleware is built in. Wrap the store in persist({ storage: createJSONStorage(() => localStorage) }) and the whole document survives reloads. Survives Electron renderer crashes. Survives me hot-reloading the dev server mid-drag.

The store ended up at around 1,100 lines, covering everything: the document model, undo history, every action a user can take. A simplified slice:

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";

type ExperimentDoc = {
  id: string;
  title: string;
  classes: ExperimentClass[];
  activeClassId: string | null;
  activePhase: "baseline" | "cue" | "stimulus" | "rest" | "onboarding";
  selectedStimulusIds: string[];
};

type Store = {
  experiments: ExperimentDoc[];
  activeExperimentId: string | null;
  history: ExperimentDoc[][];
  future: ExperimentDoc[][];

  addStimulus: (stimulus: BaseStimulus) => void;
  updateStimulus: (id: string, patch: Partial<BaseStimulus>) => void;
  removeStimulus: (id: string) => void;
  setSelection: (ids: string[]) => void;
  undo: () => void;
  redo: () => void;
};

export const useExperimentBuilderStore = create<Store>()(
  persist(
    (set) => ({
      experiments: [],
      activeExperimentId: null,
      history: [],
      future: [],

      addStimulus: (stimulus) => set((s) =>
        withHistory(s, mapActive(s, (doc) =>
          mapScene(doc, (scene) => [...scene, stimulus])
        ))
      ),

      // ... rest of the actions
    }),
    {
      name: "experiment-builder",
      version: 1,
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        experiments: state.experiments,
        activeExperimentId: state.activeExperimentId,
      }),
    },
  ),
);

A few notes on what I learned:

Partialize aggressively. The partialize config tells the persist middleware which slice of the store to write to disk. Undo history doesn't belong there: it's session state, and resurrecting it across reloads makes the app feel haunted.

Helper functions beat deep spreads. The store's data is nested four levels deep (experiments, then classes, then scenes, then stimuli). Writing set(s => ({ ...s, experiments: s.experiments.map(...) })) for every action becomes a four-deep spread pyramid. Helpers like mapActive and mapScene flatten this. No immer, no optics library: just small composable functions.

Bump the persist version on schema changes. When the document shape changes (and it will), the version field prevents the new code from crashing on old persisted state. Zustand will either migrate or discard the old state based on your migrate function.

The flat stimulus shape

Every stimulus, regardless of kind, has the same TypeScript shape with optional fields per kind:

interface BaseStimulus {
  id: string;
  kind: "rectangle" | "circle" | "polygon" | "lines" | "arrow"
      | "star" | "text" | "image";
  x: number;
  y: number;
  width: number;
  height: number;
  rotation: number;
  fill?: string;
  stroke?: string;
  strokeWidth?: number;
  hidden?: boolean;
  locked?: boolean;

  // Per-kind extras (only used for that kind)
  cornerRadius?: number;        // Rectangle
  sides?: number;                // Polygon, Triangle
  starPoints?: number;           // Star
  starInnerRatio?: number;       // Star
  lineCount?: number;            // Lines
  lineGap?: number;              // Lines
  arrowStartCap?: ArrowCap;      // Arrow
  arrowEndCap?: ArrowCap;        // Arrow
  text?: string;                 // Text
  fontSize?: number;             // Text
  textAlign?: "left" | "center" | "right";  // Text
  textColor?: string;            // Text
  src?: string;                  // Image (data URL)
  fileName?: string;             // Image

  animation?: StimulusAnimation;
}

The textbook answer would be a discriminated union: type Stimulus = Rectangle | Circle | Polygon | .... I tried this first, then walked back to the flat shape.

The reason: the properties panel and the store both need to operate on "the selected stimulus" without caring what kind it is. A discriminated union forces narrowing at every site. The flat shape lets shared code (drag handlers, undo, copy/paste) treat all stimuli uniformly. Kind-specific code lives in two places only: the per-kind renderer in Konva, and the kind-specific section of the properties panel.

The wasted memory (a Rectangle carrying an undefined text field) is negligible. The code clarity win is significant.

Undo and redo via snapshot history

Undo and redo are table stakes for any editor. The implementation pattern that survives is the simplest one: snapshot the entire document on every mutation, push onto a history stack, pop on undo.

Undo and redo via snapshot history diagram

In code:

const MAX_HISTORY = 50;

function withHistory(state: Store, patch: Partial<Store>): Partial<Store> {
  return {
    history: [...state.history, state.experiments].slice(-MAX_HISTORY),
    future: [],
    ...patch,
  };
}

const undo = () => set((s) => {
  if (s.history.length === 0) return s;
  const prev = s.history[s.history.length - 1];
  return {
    experiments: prev,
    history: s.history.slice(0, -1),
    future: [s.experiments, ...s.future],
  };
});

const redo = () => set((s) => {
  if (s.future.length === 0) return s;
  const next = s.future[0];
  return {
    experiments: next,
    history: [...s.history, s.experiments],
    future: s.future.slice(1),
  };
});

A few things worth highlighting:

Not everything is undoable. Selection changes, active class changes, active phase changes are deliberately not pushed to history. If you undo and your selection vanishes (because the previous state had a different selection), the user feels disoriented. Keep view state out of history.

Bounded memory. slice(-MAX_HISTORY) caps the history at 50 entries. Each entry is a snapshot of the entire experiments array. For a complex document this is several KB. Fifty entries means at most a few hundred KB of history in memory. Acceptable.

Any new mutation clears redo. If the user undoes to position 5, then makes a fresh edit, the redo stack is wiped. This is the standard expectation; users find branching-history editors confusing.

I considered immer with patches, or mobx-state-tree snapshots, or a CRDT. For this app, plain JSON snapshots were sufficient and easier to debug. If you can read the JSON in DevTools, you can debug the issue.

Animations: flicker and move

The experiment paradigm needed two animation primitives:

  • Flicker. A square-wave opacity toggle at N Hz. Used for SSVEP stimulus (a paradigm where the brain entrains to flickering visual stimuli at known frequencies).
  • Move. Linear tween from one position to another over a duration, optionally looping.

I considered framer-motion but it doesn't integrate cleanly with Konva (you'd be animating React state that drives Konva props, two abstraction layers fighting each other). The right tool was Konva's own animation primitives: Konva.Animation for time-driven updates, Konva.Tween for transitions between two states.

The preview lives in a separate full-screen overlay component. It renders the active scene in a read-only Konva stage and wraps each animated stimulus in an AnimatedStimulusHost that drives the Konva primitive:

function AnimatedStimulusHost({ stimulus }: { stimulus: BaseStimulus }) {
  const ref = useRef<Konva.Node>(null);

  useEffect(() => {
    if (!ref.current || !stimulus.animation) return;

    if (stimulus.animation.kind === "flicker") {
      const anim = new Konva.Animation((frame) => {
        if (!frame) return;
        const period = 1000 / stimulus.animation.frequency;
        const phase = (frame.time + stimulus.animation.phase * period) % period;
        ref.current.opacity(phase < period / 2 ? 1 : 0);
      }, ref.current.getLayer());
      anim.start();
      return () => anim.stop();
    }

    if (stimulus.animation.kind === "move") {
      const tween = new Konva.Tween({
        node: ref.current,
        duration: stimulus.animation.durationMs / 1000,
        x: stimulus.animation.endX,
        y: stimulus.animation.endY,
        onFinish: () => {
          if (stimulus.animation.loop) tween.reverse();
        },
      });
      tween.play();
      return () => tween.destroy();
    }
  }, [stimulus.animation]);

  return <StimulusRenderer stimulus={stimulus} ref={ref} />;
}

The shape of this is worth noting: React mounts the host, the host imperatively drives the Konva animation. React isn't trying to drive 60 fps updates through its reconciler; Konva handles the animation loop natively.

Migrating to a block-based doc model

The editor started with one document shape: a list of classes, each with a set of phases (baseline, cue, stimulus, rest, onboarding). Each phase has a list of stimuli.

That model worked for the initial paradigms (motor imagery, P300, SSVEP). When we started supporting more general experiments, the model started to feel rigid. We wanted blocks that could be reordered (welcome screen, instructions, calibration, multiple epochs of trials, debrief). The phase structure was too narrow.

So the editor now supports two document kinds: legacy (the original class-and-phase shape) and v3.1 (a block-based shape where the document is a sequence of typed blocks):

type ExperimentDoc =
  | { docKind: "legacy"; classes: ExperimentClass[]; activePhase: Phase; ... }
  | { docKind: "v3.1"; blocks: BuilderBlock[]; activeBlockId: string | null; ... };

type BuilderBlock =
  | { kind: "welcome"; id: string; props: WelcomeBlockProps }
  | { kind: "epoch"; id: string; props: EpochBlockProps }
  | { kind: "debrief"; id: string; props: DebriefBlockProps };

The builder reads doc.docKind and renders one of two left rails: the legacy class tree, or the new block list. The center canvas and right inspector are mostly shared.

Migrating to a block-based doc model diagram

This is a forward-compatible migration pattern. Old paradigms don't break. New paradigms use the better model. When all paradigms migrate, we can deprecate the legacy branch.

The lesson I took from this: when you change a foundational document shape, tag the doc with a version (docKind: "v3.1") and let the renderer branch. Don't try to migrate every legacy document in place. Coexistence is cheaper than migration.

ResizeObserver for container-driven sizing

A subtle but important detail: Konva's Stage doesn't auto-size. You pass it explicit width and height props. If the container around it resizes (because the user collapses a sidebar, or resizes the window), the Stage doesn't know.

The wrong fix is to listen to window.resize. That fires on window changes but not on layout changes inside the window (sidebar collapse, parent flex reflow, devtools open).

The right fix is ResizeObserver on the actual container element:

function CanvasShell() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

  useLayoutEffect(() => {
    if (!containerRef.current) return;
    const el = containerRef.current;
    const ro = new ResizeObserver(() => {
      setSize({ width: el.clientWidth, height: el.clientHeight });
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  return (
    <div ref={containerRef} style={{ width: "100%", height: "100%" }}>
      {size.width > 0 && (
        <Stage width={size.width} height={size.height}>
          {/* layers */}
        </Stage>
      )}
    </div>
  );
}

Two details that matter:

  • useLayoutEffect, not useEffect. The observer needs to attach before paint, or you get a flash of zero-sized canvas on mount.
  • Gate the Stage on size.width > 0. Konva has known issues with being constructed at zero size and then resized. Skip the first render until you have real dimensions.

This ResizeObserver pattern is one of those things that takes 30 minutes to discover, 10 lines to implement, and saves you weeks of "why does the canvas look weird when I open devtools" bug reports.

What I would do differently

A few months in, the things I would do differently if I started over:

Pick react-konva v19 from day one. The earlier 18.x branch had some props that didn't reconcile cleanly, and I spent a few days chasing bugs that turned out to be reconciler quirks. The 19.x line is much cleaner.

Build the keyboard shortcut layer earlier. I bolted on Cmd+Z, Cmd+Y, delete, and copy/paste late in the project. By that point the action surface had grown and each shortcut needed careful wiring. If I'd had a small useShortcut hook from week one, the integration would have been trivial.

Plan for multi-select sooner. The store has selectedStimulusIds: string[] (plural), but a lot of action sites assume selectedStimulusIds[0]. Going back and making them all multi-select-aware was tedious. Either commit to single-select and store selectedStimulusId: string | null, or commit to multi-select and make every action shape work on a set.

Use a state machine library for the editor mode. Modes (drawing a shape, panning, transforming, animating-preview) are a finite set with strict transitions. I implemented them as a flat enum and lots of conditionals. A real state machine (XState, Robot, Zag) would have caught a handful of edge cases earlier.

Don't try to be Figma. Be useful. It's tempting to add every Figma feature. Vector pen tools, boolean operations, components and instances, multi-page documents. None of those mattered for the actual use case (designing experiment stimuli). Saying no to features was the highest-leverage decision I made.

Wrapping up

A Figma-like canvas editor is a deep project but the structural decisions are mostly knowable up front. The ones that mattered most for me:

  • Use a canvas library (Konva) for free-form layouts, not a node graph (XYFlow) and not raw SVG.
  • Scale the stage, not the objects. Coordinates stored in scene space, transformed on render.
  • A flat document shape with kind-discriminated optionals beats a discriminated union for shared editor code.
  • Snapshot history is enough for undo and redo if the document is reasonable in size.
  • Two animation primitives (flicker and move) cover more ground than you'd expect; drive them with the canvas library's own animation engine, not React state.
  • ResizeObserver, not window.resize. Always.

If you're starting a similar project, I'd recommend Konva, Zustand, and a strict commitment to the simplest implementation that passes the user test. Reach for fancier abstractions only when the simple one is the bottleneck.

#reactjs #javascript #canvas #electron #design-systems