DinoDreams — complex coloring in React Native with Skia

DinoDreams is a coloring app for little kids. I built it solo in five weeks: the app, the backend, and an admin panel. It’s out on iOS, and Android is in testing.

Little kids don’t color inside the outlines. They scribble across the whole page, and the app has to make the result look neat anyway. That turned out to be the most interesting code in the project, so this post is about the coloring engine, not the UI around it.

What’s in the app

Most of the app is the usual work around a canvas:

  • Ready-made coloring pages, plus new ones generated with the Gemini API when a kid asks for something.
  • Subscriptions, in-app purchases, and virtual currency on RevenueCat.
  • Parental controls.
  • A FastAPI backend for the currency, analytics, and page generation.
  • An admin panel with analytics on React and Vite.

How a page gets colored

From the outside it’s simple: pick a page, pick a color, scribble. Inside, it takes five steps:

  1. The menu opens /coloring/[id].
  2. The page’s PNG is split into regions.
  3. Each region gets its own small canvas in a layer stack.
  4. A gesture paints into one region.
  5. Lifting the finger commits the stroke.

Splitting a page into regions

Segmentation is the unglamorous part, and everything else depends on it. Before a kid can paint, the app has to know which pixels belong to the same area.

Each page is a PNG of line art: dark outlines (RGB below 50) and light areas between them. segmentImage() reads the pixels, treats the dark ones as walls, and flood-fills every enclosed light area with its own ID. Each area becomes a Region with:

  • a cropped mask SkImage, saved as a transparent PNG;
  • a frame on screen, scaled with the same fit="contain" math as the base image (useImageRect, useCanvasSize);
  • a growing paths[] array for the strokes.

It runs once per page, after layout, while a loader hides the canvas until segmentationReady. On the next visit, saved strokes turn back into SkPath.

The layer stack

The screen isn’t one big canvas. It’s a stack of views, bottom to top:

  1. Base: the full line art. No mask covers the outlines, so they always stay visible.
  2. RegionCanvas × N: one Skia view per region, sized to its bounding box, with overflow: hidden.
  3. Active stroke: a shared SkPath in a Reanimated shared value while the finger is down.
  4. Bubbles: touch feedback.
  5. GestureDetector: a touch catcher over the whole area.

Small canvases keep draw calls local and hit-testing cheap. The paint uses BlendMode.SrcATop, so color lands only where the region’s mask already has alpha. A kid can scribble as hard as they want; the shape wins.

Gestures

useDrawingGesture owns the whole touch:

  • Down: findRegionAtPoint(x, y), a worklet, checks bounding boxes first, then samples pixels on the masks that overlap. Wherever the finger lands picks the region for the whole stroke.
  • Move: lineTo once the finger moves past a ~10 px threshold, with a scratch sound and bubbles.
  • Up: copy the path, append PathData, push undo, and merge compatible paths later.

Touches arrive in screen space, but stored paths are region-local, so every point needs frame.x/y subtracted. That mismatch is where most of the bugs would live: get it wrong once and every stroke lands in the wrong place.

Brushes

Brushes are the fun part, and all of them live in Skia. makePaint builds solid, tube, crayon (SkSL shaders), rainbow (RainbowPath), glitter (a clock quantized to ~30 Hz), eraser, and sticker image patterns.

While the finger is down, the active stroke renders as children of the active RegionCanvas. After commit it becomes a normal RegionPathItem. Undo is a shallow stack of 3 strokes, and clean wipes everything. Leaving the screen saves a JPEG preview and the path data through a small progress store.

Where things live

Piece Role
app/coloring/[id].tsx Route, mounts CanvasView
drawing/canvas.tsx Layers, state, panel
utils/utils-skia.tsx segmentImage, masks
useDrawingGesture Touch → path → commit
findRegionAtPoint Worklet hit test

What I’d keep

If you’re looking at Skia for this kind of drawing, precomputed region masks, SrcATop, and one view per region have been solid on real devices. The expensive part, segmentation, is paid once per page instead of once per frame, and the outlines sit outside every mask, so paint can never cover them.