Canvas vs DOM — Decision Criteria from 203 Components with Real Benchmark Data
Go deeper on this topic
Canvas vs DOM & Interactive UI
Canvas vs DOM tradeoffs, CSS interactions, custom physics, and portfolio design
Article 1 of 5 in this series.
Next reads
Top 10 Bugs from 203 Canvas/DOM Components — Patterns Revealed by Real Data
Bugs encountered during the development of 203 interactive components, tallied from git history. The most frequent patterns and their fixes presented in a Top 10 format.
Interactive UI with Pure CSS — Practical Use of transform, transition, and Custom Properties
CSS techniques using transform, transition, and custom properties demonstrated through DOM+CSS-based interactive UI components like FidgetSpinner, InfiniteKnob, and ToggleSwitch.
The Limits of a 180-Line Custom Physics Engine — Why I Dropped Matter.js and What I Couldn't Cover
Built a custom 2D physics engine in 166 lines of physics.ts, reducing Matter.js's 300KB to 4KB. The trade-offs of focusing on gravity, collision, and bounce while giving up constraints, friction, and stacking.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Introduction
This portfolio site has 211 interactive components. Fluid simulation, Pac-Man, piano, Wordle, kaleidoscope — the genres are all over the place, but they all share the same first design decision.
Canvas or DOM?
After making this decision 211 times, the distribution looks like this:
- Canvas: 129 (63%)
- DOM: 68 (33%)
- Hybrid: 14 (4%)
This ratio wasn't designed intentionally but emerged naturally from individual decisions. In retrospect, the decision criteria boil down to four axes. This article explains those criteria with reference to actual components.
Decision Criteria: Four Axes
The Canvas vs. DOM decision comes down to "simulation presence," "entity count," "pixel precision," and "accessibility requirements."
1. Presence of Numerical Simulation
The clearest branching point. If there's continuous physics calculation or grid-based simulation, Canvas is the only option.
The reason is simple: DOM isn't designed for "directly manipulating coordinates." You can move positions with transform: translate(x, y), but updating coordinates of hundreds to thousands of entities every frame and reflecting those changes goes against DOM's design philosophy.
Canvas API's fillRect and arc render immediately with just coordinates and dimensions. They bypass DOM layout calculation (reflow) entirely. This difference becomes decisive in simulations that need to maintain 60FPS.
2. Number of Drawing Entities
If more than 50 entities exist simultaneously on screen, use Canvas. This is a rule of thumb, but a highly reliable threshold.
With around 50 DOM nodes, browser layout engines handle things fine. But beyond 100, transform updates on each node start eating into the frame budget (16.67ms). This is especially pronounced on mobile, where updating transform on 200 nodes can take over 10ms.
With Canvas, the clearRect → draw loop scales almost linearly with entity count. It doesn't go through the DOM pipeline of "style recalculation → layout → paint → composite" for each node.
3. Pixel Precision
If you need image processing, custom filters, or pixel-level manipulation, use Canvas. Directly manipulating pixel buffers via ImageData is a Canvas-exclusive capability with no DOM alternative.
4. Accessibility Requirements
This is the one area where DOM wins.
If text labels need to be read by screen readers, if keyboard-only operation is required, if focus management is needed — DOM should be the choice.
You can add aria-label to a Canvas element, but you can't focus on individual elements inside Canvas. Interactions like "pressed this button" or "selected this option" can only be properly expressed with native <button> and <input> elements.
Representative Canvas Examples
The 129 Canvas components all share one of "large entity count," "physics simulation," or "pixel manipulation."
BubbleWrap — 768 Simultaneous Entities
A bubble-popping interaction. 12×8 = 96 bubbles, and when each bubble pops, 8 particles scatter. Popping all bubbles at once means 768 entities exist simultaneously.
Updating transform on 768 <div> elements every frame via DOM isn't realistic. With Canvas, coordinate updates and arc rendering for each particle takes about 2ms per frame.
96 bubbles × 8 particles = 768 entities
Canvas: ~2ms/frame
DOM estimated: ~18ms/frame (guaranteed frame drops)FluidSimulation — Pixel Manipulation with ImageData
A simplified Navier-Stokes fluid simulation on a 100×60 grid. Velocity and density fields are stored in Float32Array, and computation results are written directly to ImageData's pixel buffer.
Updating color information for 6,000 cells every frame would require changing backgroundColor on 6,000 <div> elements in DOM. Direct writing to ImageData.data requires only buffer operations.
Grid: 100×60 = 6,000 cells
Data structures: Float32Array (velocity field ×2 + density field)
Rendering: ImageData → putImageData (single API call)Boids — O(n²) Neighbor Search with 175 Agents
Craig Reynolds' boids algorithm. 175 agents form flocks following three rules: separation, alignment, and cohesion.
Each agent calculates distance to every other agent each frame. With 175 agents, that's 175×174 = 30,450 distance calculations per frame. These results are rendered as 175 triangles.
Triangle rendering on Canvas is beginPath → moveTo → lineTo ×2 → fill. Expressing triangles in DOM requires clip-path or SVG, and updating 175 of them gets even heavier.
Pacman — 441-Cell Maze and Ghost AI
A 21×21 = 441-cell maze. Pac-Man, 4 ghosts, and up to about 200 dots. Roughly 650 entities exist simultaneously.
Ghost AI (Blinky: chase, Pinky: ambush, Inky: pincer, Clyde: whimsical) calculates paths every frame. These results are rendered on Canvas. Maze walls, dots, Pac-Man's animated mouth, ghost undulating skirts — all drawn with Canvas paths and arcs.
ParticleField — 4,950 Connection Lines
100 floating particles with lines drawn between nearby pairs. Choosing 2 from 100 gives 100×99÷2 = 4,950 combinations. Every frame performs 4,950 distance calculations, drawing lines for pairs within the threshold.
The actually drawn lines number in the hundreds after distance filtering, but the check itself requires all 4,950. Updating this many <line> SVG elements every frame is not DOM's strong suit.
Representative DOM Examples
All 68 DOM components are the "user selects, inputs, or operates" type, where keyboard operation and accessibility are essential.
FidgetZone — 4 Keyboard-Operable Widgets
Fidget spinner, toggle switch, slider, and clicker arranged together. Each widget is independent, and all are keyboard-operable.
Spinner rotates with Space, toggle switches with Enter, slider moves with arrow keys, clicker clicks with Space. Implementing these in Canvas would require building keyboard focus management from scratch. With DOM, <button> and tabIndex achieve this naturally.
Entity count is 4. No performance concerns whatsoever. DOM requires less code and provides better accessibility.
MemoryCards — CSS 3D Transform
A memory matching game. Card flip animation uses rotateY(180deg). CSS's 3D transform benefits from GPU acceleration, achieving 60FPS card flips with a single transition line.
Each card is implemented as a <button>, navigable with Tab and selectable with Enter or Space. Matched cards get aria-disabled="true".
Implementing card flips in Canvas would require calculating perspective transformation manually and rendering front and back textures with coordinate transforms. There's no reason to reimplement what CSS's perspective and transform-style: preserve-3d provide for free.
GravityBadges — Text Label Readability
Technology stack badges like "React," "TypeScript," "Tailwind CSS" that fall with gravity and bounce. Physics calculations use the custom implementation in src/utils/physics.ts.
The decisive reason for choosing DOM here is text readability. The technology names written on each badge should be readable by screen readers and indexable by search engines. Drawing text with Canvas's fillText removes it from the DOM tree and thus from the accessibility tree.
At most 30 badges. At this count, DOM transform updates easily maintain 60FPS.
Wordle — Virtual Keyboard and ARIA
A Wordle clone. 5-character × 6-row input grid and a QWERTY virtual keyboard.
Each keyboard key is a <button>, with used letters colored (green: correct position, yellow: contained, gray: unused). This coloring is supplemented with aria-label like "A: correct position." Completed rows get aria-disabled="true".
The game interactions are "select a letter," "submit," "view result" — all naturally expressible with DOM form elements. Implementing a virtual keyboard in Canvas would only increase the implementation cost of keyboard navigation.
Sokoban — Emoji Grid and Semantics
A Sokoban clone. Player (emoji), boxes (emoji), goals (emoji), walls (emoji) — all represented with emoji.
Each cell has role="gridcell" and the entire grid has role="grid" markup. The player's current position is indicated with aria-current="true".
This semantic structure cannot be reproduced in Canvas. Emoji drawn on Canvas look the same visually but are nothing more than bitmaps to assistive technologies.
The Accessibility Gap
Accessibility support for Canvas components is limited.
<canvas
role="img"
aria-label="Fluid simulation. Use mouse or touch to interact with the fluid"
tabindex="0"
/>That's effectively the ceiling. Individual elements inside Canvas (particles, cells) can't receive focus, and state changes can't be communicated to screen readers.
DOM components, on the other hand, can leverage native HTML element semantics directly.
<button
aria-pressed="true"
aria-label="C4 key"
onKeyDown={handleKeyDown}
>
C4
</button>This gap can't be bridged. UI that users "operate" should be DOM; visuals that users "observe" should be Canvas — the simplest criterion from an accessibility perspective.
Looking back at 211 components, all 68 that chose DOM were "user selects, inputs, or operates" types. The 129 that chose Canvas were predominantly "watch, touch, play" types.
Performance Strategy Differences
Canvas and DOM have fundamentally different approaches to performance optimization.
Canvas: Stopping rAF When Off-Viewport
The biggest performance risk for Canvas components is animation loops running while off-screen. Of 211 components, 129 are Canvas, each with its own requestAnimationFrame loop. A component that continues consuming CPU while scrolled off screen is unacceptable.
The solution is IntersectionObserver. When a component leaves the viewport, call cancelAnimationFrame; when it re-enters, resume.
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
startAnimation()
} else {
stopAnimation()
}
},
{ threshold: 0.1 }
)
if (canvasRef.current) observer.observe(canvasRef.current)
return () => observer.disconnect()
}, [])This optimization is applied to all 129 Canvas components.
DOM: Leveraging GPU Layers
For DOM components, limiting animations to transform and opacity gets GPU acceleration. These two properties are processed in the composite layer, so no reflow (layout recalculation) occurs.
/* Good: composite only */
.badge { transform: translate(var(--x), var(--y)); }
/* Bad: triggers reflow */
.badge { left: var(--x); top: var(--y); }In GravityBadges, 30 badges move according to physics simulation, all positioned with transform, maintaining a stable 60FPS. The initial version using left/top dropped to about 35FPS on mobile.
Shared: Avoiding React Re-renders with useRef
Whether Canvas or DOM, managing animation state with React's state should be avoided. setState triggers re-renders, meaning 60 re-renders per second for 60FPS animation.
Use useRef instead. The Snake game implementation is a typical example.
const snakeRef = useRef<Point[]>([{ x: 10, y: 10 }])
const scoreRef = useRef(0)
const dirRef = useRef<Direction>('right')Snake coordinates, score, direction — all managed with useRef. React only re-renders at moments that need to be shown to the user, like "game over" and "score display update."
This strategy is unified across all 211 components. Internal animation state goes in refs, only state reflected in UI goes in state. Maintaining this boundary keeps React's rendering cost near zero.
What About Hybrid?
Of 211 components, only 14 (about 4%) use both Canvas and DOM.
Representative examples include Terrarium and SolarSystem. In Terrarium, the ecosystem simulation (insect/plant movement and growth) is rendered in Canvas while temperature/humidity controls are implemented with DOM sliders. In SolarSystem, orbital calculations and rendering use Canvas, while planet name labels and click info panels use DOM overlays.
The reason hybrids are rare is the cost of complexity. Synchronizing Canvas coordinate systems with DOM layout is tedious. Canvas resize requires recalculating DOM overlay positions, and devicePixelRatio handling becomes dual-layered.
When in doubt, commit to one or the other. This is the most practical rule learned from 211 decisions.
Decision Flowchart
Finally, a flowchart distilled from 211 decisions. When creating a new component, work through these questions top to bottom.
Q1: Continuous physics simulation?
→ Yes → Canvas
→ No → Q2
Q2: 50+ simultaneous drawing entities?
→ Yes → Canvas
→ No → Q3
Q3: Pixel-level manipulation (ImageData, etc.) needed?
→ Yes → Canvas
→ No → Q4
Q4: Text/labels need to be readable?
→ Yes → DOM
→ No → Q5
Q5: Keyboard accessibility required?
→ Yes → DOM
→ No → Q6
Q6: CSS Transform/Transition sufficient?
→ Yes → DOM (lower implementation cost)
→ No → CanvasIf any of Q1-Q3 is Yes, use Canvas. If any of Q4-Q5 is Yes, use DOM. If neither applies, decide based on whether CSS can handle it. If CSS transition or animation suffices, there's no need to write a Canvas requestAnimationFrame loop.
Following this flowchart reaches the same conclusion for about 95% of the 211 decisions. The remaining 5% become hybrid cases, but as mentioned, leaning toward one side with the "when in doubt, commit" approach keeps maintenance costs lower.
Summary: How to Choose Between Canvas and DOM
The biggest lesson from building 211 components is that Canvas and DOM are not competing technologies — they have different strengths.
Canvas is a device that converts oceans of numbers into visuals, and DOM is a device that structures dialogue with users. The choice is determined not by which is superior, but by what you're trying to build.
The 63% vs. 33% distribution also reflects that this portfolio leans toward "watch and enjoy" type interactions. If I were building form-heavy applications, the ratio would be reversed.
There's no single right answer in technology selection, but articulating your decision criteria means the 211th decision happens just as fast as the first. I hope this flowchart helps anyone facing the same decision.
FAQ
- Q. Should I choose Canvas API or DOM?
- Use Canvas for continuous physics simulations, 50+ simultaneous drawing entities, or pixel manipulation. Use DOM when keyboard interaction or screen reader support is needed.
- Q. How should I handle accessibility for Canvas components?
- Adding role="img" and aria-label to the canvas element is practically the upper limit. Individual elements inside Canvas cannot receive focus, so user-interactive UI should be implemented with DOM.
- Q. What are the tips for maintaining 60fps with DOM animations?
- Limit animations to transform and opacity to leverage GPU acceleration, and avoid reflows caused by left/top. Use useRef for state management to bypass React re-renders.