DEV Community

Cover image for How to build a Pixel Art Drawing App in React
Aleks Popovic
Aleks Popovic

Posted on • Originally published at aleksandarpopovic.com

How to build a Pixel Art Drawing App in React

Pixel art games have been making a huge comeback in recent years, but this time not because we are limited by technology, but because pixel art is really great. Some game developers are adjusting their workflows to transform their end product into a pixel art game even though they might start with 3d modeling at first. Others go the usual way of drawing sprites in a Pixel Art Editor and that is exactly what we are going to build today. We will go over how to generate a dynamic pixel grid, how to set up a color picker, how to actually draw pixels and in the end how to export your finished pixel art into a png image.

If you prefer a video version you can watch me build this on Youtube:

I started a new React app by running create-react-app command in my working folder.

create-react-app pixels
Enter fullscreen mode Exit fullscreen mode

For this project I will focuse more on the React part of the code, but there will be a SandBox link to a full code example that includes the CSS files as well. I mainly used flexbox to center elements, and that's pretty much 80% of the CSS used. The rest is adding custom colors and margins for separation between elements.

To start off - I removed all unnecessary code from App.js and I imported the Editor component, which is going to be the heart of this app.

import "../styles/App.scss"
import Editor from "./Editor"

function App() {
  return (
    <div className="App">
      <Editor />
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Next we need to set up the Editor.

import React, { useState } from "react"
import "../styles/editor.scss"

export default function Editor() {
  return (
    <div id="editor">
      <h1>Pixel Editor</h1>
      <h2>Enter Panel Dimensions</h2>

      <div id="options">
        <div className="option">
          <input type="number" className="panelInput" />
          <span>Width</span>
        </div>
        <div className="option">
          <input type="number" className="panelInput" />
          <span>Height</span>
        </div>
      </div>

      <button className="button">Start Drawing</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

We have a coupled of heading elements and some input fields which we will use to dynamically set the drawing panel's width and height. There is also a button which we will use to hide the options and initialize the drawing panel, but also to reset the pixel grid if the user wants to start over. To do that we need to set up some useState hooks.

import React, { useState } from "react"
import "../styles/editor.scss"

export default function Editor() {
  const [panelWidth, setPanelWidth] = useState(16)
  const [panelHeight, setPanelHeight] = useState(16)
  const [hideOptions, setHideOptions] = useState(false)
  const [hideDrawingPanel, setHideDrawingPanel] = useState(true)
  const [buttonText, setButtonText] = useState("start drawing")
  const [selectedColor, setColor] = useState("#f44336")

  function initializeDrawingPanel() {
    setHideOptions(!hideOptions)
    setHideDrawingPanel(!hideDrawingPanel)

    buttonText === "start drawing"
      ? setButtonText("reset")
      : setButtonText("start drawing")
  }

  return (
    <div id="editor">
      <h1>Pixel Editor</h1>
      {hideDrawingPanel && <h2>Enter Panel Dimensions</h2>}
      {hideDrawingPanel && (
        <div id="options">
          <div className="option">
            <input
              type="number"
              className="panelInput"
              defaultValue={panelWidth}
              onChange={e => {
                setPanelWidth(e.target.value)
              }}
            />
            <span>Width</span>
          </div>
          <div className="option">
            <input
              type="number"
              className="panelInput"
              defaultValue={panelHeight}
              onChange={e => {
                setPanelHeight(e.target.value)
              }}
            />
            <span>Height</span>
          </div>
        </div>
      )}
      <button onClick={initializeDrawingPanel} className="button">
        {buttonText}
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

With useState we are controlling the width and height of the drawing panel. I've also added some properties to control the element visibility. After you set up the dimensions and click the button to start drawing - all of the options will be hidden until we click our repurposed Reset button.

A drawing app wouldn't be very useful without any colorin options. For this project I decided to use a plugin react-color which has a lot of different color picker options. I've chosen their CirclePicker component, but you can find the full list of possibilities on their website, and they all work in a similar way.

You can install it by running

npm install react-color
Enter fullscreen mode Exit fullscreen mode

Now we need to set it up.

import React, { useState } from "react"
import "../styles/editor.scss"
import { CirclePicker } from "react-color"

export default function Editor() {
  const [panelWidth, setPanelWidth] = useState(16)
  const [panelHeight, setPanelHeight] = useState(16)
  const [hideOptions, setHideOptions] = useState(false)
  const [hideDrawingPanel, setHideDrawingPanel] = useState(true)
  const [buttonText, setButtonText] = useState("start drawing")
  const [selectedColor, setColor] = useState("#f44336")

  function initializeDrawingPanel() {
    setHideOptions(!hideOptions)
    setHideDrawingPanel(!hideDrawingPanel)

    buttonText === "start drawing"
      ? setButtonText("reset")
      : setButtonText("start drawing")
  }

  function changeColor(color) {
    setColor(color.hex)
  }

  return (
    <div id="editor">
      <h1>Pixel Editor</h1>
      {hideDrawingPanel && <h2>Enter Panel Dimensions</h2>}
      {hideDrawingPanel && (
        <div id="options">
          <div className="option">
            <input
              type="number"
              className="panelInput"
              defaultValue={panelWidth}
              onChange={e => {
                setPanelWidth(e.target.value)
              }}
            />
            <span>Width</span>
          </div>
          <div className="option">
            <input
              type="number"
              className="panelInput"
              defaultValue={panelHeight}
              onChange={e => {
                setPanelHeight(e.target.value)
              }}
            />
            <span>Height</span>
          </div>
        </div>
      )}
      <button onClick={initializeDrawingPanel} className="button">
        {buttonText}
      </button>
      {hideOptions && (
        <CirclePicker color={selectedColor} onChangeComplete={changeColor} />
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The color propc in the CirclePicker is used to mark which color is currently selected, useChangeComplete is the component's event which you can use to trigger an action. In our case, after selecting a different color from the picker we want to switch our selected color state.

The only thing left to do in the Editor is to add the DrawingPanel component which we yet need need to build out.

We import the component the usual way:

import DrawingPanel from "./DrawingPanel"
Enter fullscreen mode Exit fullscreen mode

We can add it into our code right below the button:

{
  hideOptions && (
    <DrawingPanel
      width={panelWidth}
      height={panelHeight}
      selectedColor={selectedColor}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Here is what the DrawingPanel component looks like:

import React, { useRef } from "react"
import "../styles/drawingPanel.scss"
import Row from "./Row"

export default function DrawingPanel(props) {
  const { width, height, selectedColor } = props

  let rows = []

  for (let i = 0; i < height; i++) {
    rows.push(<Row key={i} width={width} selectedColor={selectedColor} />)
  }

  return (
    <div id="drawingPanel">
      <div id="pixels">{rows}</div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Based on the entered height we generate the same number of rows and we push them to a div container, but we also need to pass in our width to each Row component so we know how many "pixels" per row we need to generate. Next we need to set up the Row component, but we will come back to the DrawingPanel to set up our exporting to PNG.

import React from "react"
import "../styles/row.scss"
import Pixel from "./Pixel"

export default function Row(props) {
  const { width, selectedColor } = props

  let pixels = []

  for (let i = 0; i < width; i++) {
    pixels.push(<Pixel key={i} selectedColor={selectedColor} />)
  }

  return <div className="row">{pixels}</div>
}
Enter fullscreen mode Exit fullscreen mode

As you can see - we are generating pixels per row in the same way we are setting up rows in the drawing panel. Now we need to set up the Pixel component and we are almost done!

import React, { useState } from "react"
import "../styles/pixel.scss"

export default function Pixel(props) {
  const { selectedColor } = props

  const [pixelColor, setPixelColor] = useState("#fff")
  const [oldColor, setOldColor] = useState(pixelColor)
  const [canChangeColor, setCanChangeColor] = useState(true)

  function applyColor() {
    setPixelColor(selectedColor)
    setCanChangeColor(false)
  }

  function changeColorOnHover() {
    setOldColor(pixelColor)
    setPixelColor(selectedColor)
  }

  function resetColor() {
    if (canChangeColor) {
      setPixelColor(oldColor)
    }

    setCanChangeColor(true)
  }

  return (
    <div
      className="pixel"
      onClick={applyColor}
      onMouseEnter={changeColorOnHover}
      onMouseLeave={resetColor}
      style={{ backgroundColor: pixelColor }}
    ></div>
  )
}
Enter fullscreen mode Exit fullscreen mode

I wanted to have a visual indicator for being able to draw instead of just having a cursor change to pointer through CSS. So, I've set up some more useState hooks to help with that. The idea here is that we want to temporarily change the pixel's color when we hover it. If we hover out, we want to return it to its old color, which we store as a separate state. However, if we click on it before hovering out, we want to permanently set that color, and for that reason we have a helper state property canChangeColor to prevent onMouseLeave messing up our color change.

We are done with setting up the drawing functions, which means the only thing left is to set up our exporting. Back to the DrawingPanel component!

First we need to install the exporting plugin:

npm install react-component-export-image
Enter fullscreen mode Exit fullscreen mode

After that we need to set it up with a useRef hook. The plugin needs a reference to a component or element that is being exported to an image.

import React, { useRef } from "react"
import "../styles/drawingPanel.scss"
import Row from "./Row"

import { exportComponentAsPNG } from "react-component-export-image"

export default function DrawingPanel(props) {
  const { width, height, selectedColor } = props

  const panelRef = useRef()

  let rows = []

  for (let i = 0; i < height; i++) {
    rows.push(<Row key={i} width={width} selectedColor={selectedColor} />)
  }

  return (
    <div id="drawingPanel">
      <div id="pixels" ref={panelRef}>
        {rows}
      </div>
      <button onClick={() => exportComponentAsPNG(panelRef)} className="button">
        Export as PNG
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now if you draw something in the panel and click our new export button you should get a new PNG image with your exported component. You can also use this plugin to export components to JPEG and PDF formats.

And that is all of the code I've used for this project, except for the CSS. If you are interested into seeing the full working version you can check out the project on CodeSandbox.

If you have any questions or comments you can reach out to me on Twitter and Instagram, where I also post interesting code tidbits and designs.

I also regularly upload React and web dev tutorials to Youtube, so if that's your cup of tea feel free to support me by subscribing to my channel.

Discussion (16)

Collapse
ciochetta profile image
Luis Felipe Ciochetta

I will be trying this today, seems awsome

Collapse
alekswritescode profile image
Aleks Popovic Author

Thank you ciochetta! I'm glad you liked it!

Collapse
ciochetta profile image
Luis Felipe Ciochetta

I did try it and I've butchered a good chunk of your code, ahaha

if you wanna check it out > dev.to/ciochetta/pixel-art-editor-...

Thread Thread
alekswritescode profile image
Aleks Popovic Author • Edited on

That's awesome! I loved your additions to the app. They are pretty advanced too considering you just started learning React. Good job!

Thread Thread
ciochetta profile image
Luis Felipe Ciochetta

thanks man :)

Collapse
emilioschepis profile image
Emilio Schepis

Very interesting. What's the biggest grid you think we should be able to make without losing performance, considering that each pixel renders a div?

Collapse
alekswritescode profile image
Aleks Popovic Author

Hi Emilio! This is definitely not optimized for large canvas sizes. On my monitor 64x64 looks and works very well. Anything above that and I enter into a horizontal scrolling territory which isn't really great for drawing. There are definitely things that could be added to this concept that would alleviate such issues. For instance - implementing zoom in and zoom out, adjusting the "pixel" size etc.

Based on some articles I've seen - 10,000 divs should be a performance drop point. I've tested 128x128 grid and there is a noticeable delay during the grid generation, but drawing and scrolling after that works as usual.

On 256x256 there is a definitive drop in performance with both scrolling and coloring individual pixels. Definitely not something you would want and like to do using this app. :)

Collapse
emilioschepis profile image
Emilio Schepis

Thank you for the thorough answer, that's exactly what I needed to know.

Thread Thread
alekswritescode profile image
Aleks Popovic Author

No problem! I'm glad you liked the article and the project!

Collapse
dramaticbean profile image
DramaticBean🇵🇹

Just tried this. It was super fun

Collapse
alekswritescode profile image
Aleks Popovic Author

Thank you DramaticBean! I really appreciate that!

Collapse
alekswritescode profile image
Aleks Popovic Author

Let me know if you have any questions or comments! I really loved working on this one.

Collapse
mrzbrz profile image
M

I can't wait to try this. Thank you!

Collapse
alekswritescode profile image
Aleks Popovic Author

Thank you CiaraMaria! I'm glad you liked it!

Collapse
lcscou profile image
lcscou

Absolutely amazing!! For sure i'll do it... Thanks for sharing!!

Collapse
alekswritescode profile image
Aleks Popovic Author

I'm glad you liked it Icscou! Thank you very much!