Build a Next.js Interactive Web-Based Drawing App

Written by

in

Ever wished you could sketch out ideas, doodle during calls, or just unwind with some digital art, all directly in your browser? In this tutorial, we’ll build a fully functional, interactive drawing application using Next.js. This project is perfect for beginners and intermediate developers looking to expand their skillset by working with dynamic user interfaces, event handling, and canvas manipulation. We’ll explore how to handle user input, draw shapes, change colors, and even offer a basic erase function. By the end, you’ll have a practical, web-based drawing tool you can use and expand upon.

Why Build a Drawing App?

Building a drawing app might seem like a niche project, but it offers several advantages for learning and practicing web development:

  • Interactive User Experience: Drawing apps are inherently interactive, requiring you to handle mouse or touch events extensively. This is excellent practice for creating dynamic and responsive web applications.
  • Canvas Manipulation: You’ll learn how to work with the HTML5 canvas element, a powerful tool for creating graphics and animations directly in the browser.
  • State Management: Managing the state of the drawing (current color, brush size, drawn shapes) will provide valuable experience with state management principles, even if it’s a simple implementation.
  • Frontend Fundamentals: You’ll reinforce your understanding of JavaScript, HTML, and CSS, as these are the core technologies for any frontend project.

This project is also a great way to experiment with Next.js features like server-side rendering (SSR), and static site generation (SSG), although we’ll focus on the client-side interactivity for this specific application.

Prerequisites

Before we dive in, make sure you have the following:

  • Node.js and npm (or yarn) installed: You’ll need these to run Next.js and manage project dependencies.
  • Basic understanding of JavaScript, HTML, and CSS: Familiarity with these languages is essential to understand the code.
  • A code editor: VS Code, Sublime Text, or any editor of your choice.

Step-by-Step Guide

1. Setting Up Your Next.js Project

Let’s start by creating a new Next.js project. Open your terminal and run the following command:

npx create-next-app drawing-app
cd drawing-app

This command creates a new Next.js project named “drawing-app”. The `cd drawing-app` command changes your directory into the newly created project.

2. Project Structure and Initial Setup

Your project structure should look something like this:

drawing-app/
├── node_modules/
├── pages/
│   └── _app.js
│   └── index.js
├── public/
│   └── ...
├── .gitignore
├── next.config.js
├── package-lock.json
├── package.json
└── README.md

The `pages` directory is where we’ll create our application’s routes. `index.js` inside `pages` is our main page. Open `pages/index.js` and replace the default content with the following basic structure:

function HomePage() {
  return (
    <div>
      <h1>Simple Drawing App</h1>
      <canvas id="drawingCanvas" width="800" height="600" style={{ border: '1px solid black' }}></canvas>
      <div>
        <label htmlFor="colorPicker">Color:</label>
        <input type="color" id="colorPicker" defaultValue="#000000" />
      </div>
    </div>
  );
}

export default HomePage;

This sets up a basic page with an `h1` heading, a `canvas` element where we’ll draw, and a color picker. The `canvas` element is given a black border so we can easily see it. The `width` and `height` attributes define the dimensions of our drawing area. The color picker allows the user to select the drawing color. Run your development server using `npm run dev` in your terminal and navigate to `http://localhost:3000` to see the basic structure.

3. Implementing the Drawing Functionality

Now, let’s add the core drawing logic. We’ll use JavaScript to interact with the canvas element and handle mouse events.

Add the following JavaScript code inside the `HomePage` component, preferably after the color picker. We will use the `useEffect` hook to ensure this code runs after the component has mounted:

import { useEffect, useRef } from 'react';

function HomePage() {
  const canvasRef = useRef(null);
  const colorPickerRef = useRef(null);
  let isDrawing = false;

  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas.getContext('2d');
    let currentColor = '#000000'; // Default color

    // Function to update the current color
    const updateColor = () => {
      currentColor = colorPickerRef.current.value;
    };

    // Event listeners for color changes
    if (colorPickerRef.current) {
      colorPickerRef.current.addEventListener('change', updateColor);
    }

    // Mouse event handlers
    const startDrawing = (e) => {
      isDrawing = true;
      draw(e);
    };

    const stopDrawing = () => {
      isDrawing = false;
      context.beginPath(); // Reset path when drawing stops
    };

    const draw = (e) => {
      if (!isDrawing) return;

      const rect = canvas.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      context.strokeStyle = currentColor;
      context.lineWidth = 5;
      context.lineCap = 'round'; // Makes the line ends rounded
      context.lineTo(x, y);
      context.stroke();
      context.beginPath(); // Start a new path for each segment
      context.moveTo(x, y);
    };

    // Event listeners for drawing
    canvas.addEventListener('mousedown', startDrawing);
    canvas.addEventListener('mouseup', stopDrawing);
    canvas.addEventListener('mousemove', draw);
    canvas.addEventListener('mouseout', stopDrawing);

    // Clean up event listeners when the component unmounts
    return () => {
      canvas.removeEventListener('mousedown', startDrawing);
      canvas.removeEventListener('mouseup', stopDrawing);
      canvas.removeEventListener('mousemove', draw);
      canvas.removeEventListener('mouseout', stopDrawing);
      if (colorPickerRef.current) {
        colorPickerRef.current.removeEventListener('change', updateColor);
      }
    };
  }, []);

  return (
    <div>
      <h1>Simple Drawing App</h1>
      <canvas id="drawingCanvas" ref={canvasRef} width="800" height="600" style={{ border: '1px solid black' }}></canvas>
      <div>
        <label htmlFor="colorPicker">Color:</label>
        <input type="color" id="colorPicker" ref={colorPickerRef} defaultValue="#000000" />
      </div>
    </div>
  );
}

export default HomePage;

Let’s break down this code:

  • `useRef` Hooks: We use `useRef` to get a reference to the `canvas` and `colorPicker` elements. This allows us to access and manipulate the DOM elements directly within our React component.
  • `isDrawing` State: This boolean variable tracks whether the mouse button is pressed and drawing is active.
  • `useEffect` Hook: This hook ensures our drawing logic runs after the component has mounted. We’ve included an empty dependency array (`[]`) to ensure this code only runs once, when the component mounts. This is where we set up the canvas context and attach event listeners.
  • `getContext(‘2d’)`: We get the 2D rendering context of the canvas, which allows us to draw shapes, lines, etc.
  • `currentColor`: Stores the currently selected color from the color picker.
  • `updateColor()`: Function that updates the `currentColor` when the color picker’s value changes.
  • Event Listeners: We add event listeners for `mousedown`, `mouseup`, `mousemove`, `mouseout` on the canvas. These trigger the `startDrawing`, `stopDrawing`, and `draw` functions, respectively. We also add an event listener for `change` on the color picker.
  • `startDrawing(e)`: Sets `isDrawing` to `true` and calls the `draw` function to start drawing.
  • `stopDrawing()`: Sets `isDrawing` to `false` and resets the current path.
  • `draw(e)`: This is the core drawing function. It checks if `isDrawing` is true. If so, it calculates the mouse position relative to the canvas, sets the `strokeStyle`, `lineWidth`, and `lineCap` properties of the context, and draws a line from the previous mouse position to the current one. It also uses `beginPath()` and `moveTo()` to ensure smooth lines.
  • Cleanup: The `useEffect` hook returns a cleanup function. This is critical to remove the event listeners when the component unmounts. This prevents memory leaks and unexpected behavior.

Now, when you interact with the canvas, you should be able to draw lines using the default black color. Try changing the color using the color picker.

4. Adding Color Selection

We’ve already included the color picker in our basic setup. The `updateColor` function in the previous step updates the `currentColor` variable whenever the color picker’s value changes. This is a crucial step in allowing users to choose the color they want to draw with.

The `currentColor` is used when setting the `strokeStyle` in the `draw` function. This allows the user to change colors using the color picker and have their changes reflected on the canvas.

5. Adding an Eraser Function

Let’s add an eraser function to our drawing app. This will allow the user to erase parts of their drawing. We can implement this by changing the `strokeStyle` to the background color of the canvas.

First, add a button to the UI:

<button onClick={erase} style={{ marginLeft: '10px' }}>Erase</button>

Add this button below the color picker. Then, add the `erase` function inside the `HomePage` component:


  const erase = () => {
    const canvas = canvasRef.current;
    const context = canvas.getContext('2d');
    context.strokeStyle = 'white'; // Or the background color of your canvas
  };

This function sets the `strokeStyle` to “white” (assuming your canvas background is white). When the user clicks the “Erase” button, the next lines drawn will appear as white, effectively erasing the drawing. You might want to add a way to change the eraser size as well, similar to how we will change brush size in the next step.

6. Adding Brush Size Control

Let’s add the ability to control the brush size. We can do this by adding a range input and updating the `lineWidth` property of the canvas context.

Add a range input to the UI:

<div>
  <label htmlFor="brushSize">Brush Size:</label>
  <input type="range" id="brushSize" min="1" max="20" defaultValue="5" ref={brushSizeRef} />
</div>

Add this input below the color picker and the erase button. Also, we need a `brushSizeRef` to reference the range input. Add this line at the top of the component:

const brushSizeRef = useRef(null);

Next, inside the `draw` function, get the current brush size from the range input and set the `lineWidth`:


    const brushSize = brushSizeRef.current ? parseInt(brushSizeRef.current.value, 10) : 5;
    context.lineWidth = brushSize;

The code gets the value from the brush size input, converts it to an integer, and sets the `lineWidth` of the context to the selected size. The `parseInt` function converts the string value from the input to an integer. The `10` specifies that the number should be parsed in base 10.

7. Improving the User Experience

While the app is functional, we can make it more user-friendly with a few improvements:

  • Clear Canvas Button: Add a button to clear the entire canvas.
  • Better UI Styling: Improve the appearance of the app with CSS.
  • Touch Support: Add support for touch events on mobile devices.

Adding a Clear Canvas Button

Add a button to clear the canvas:

<button onClick={clearCanvas} style={{ marginLeft: '10px' }}>Clear Canvas</button>

Place this button next to the Erase button. Then, add the `clearCanvas` function inside the `HomePage` component:


  const clearCanvas = () => {
    const canvas = canvasRef.current;
    const context = canvas.getContext('2d');
    context.clearRect(0, 0, canvas.width, canvas.height);
  };

This function clears the entire canvas by using the `clearRect()` method. It takes four arguments: the x and y coordinates of the top-left corner of the rectangle to clear, and the width and height of the rectangle.

Styling the App with CSS

To improve the look and feel, add some basic CSS. You can either add a `style` attribute to each element or create a separate CSS file and import it into your component.

Here’s an example of adding basic CSS inline:


<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '20px' }}>
  <h1 style={{ marginBottom: '20px' }}>Simple Drawing App</h1>
  <canvas ref={canvasRef} width="800" height="600" style={{ border: '1px solid black', backgroundColor: 'white' }}></canvas>
  <div style={{ marginTop: '10px' }}>
    <label htmlFor="colorPicker" style={{ marginRight: '5px' }}>Color:</label>
    <input type="color" id="colorPicker" ref={colorPickerRef} defaultValue="#000000" />
    <button onClick={erase} style={{ marginLeft: '10px' }}>Erase</button>
    <button onClick={clearCanvas} style={{ marginLeft: '10px' }}>Clear Canvas</button>
    <div style={{ marginTop: '10px' }}>
      <label htmlFor="brushSize" style={{ marginRight: '5px' }}>Brush Size:</label>
      <input type="range" id="brushSize" min="1" max="20" defaultValue="5" ref={brushSizeRef} />
    </div>
  </div>
</div>

This example sets the layout to a column, centers the content, and adds some padding. It also adds margins to the heading and the controls. You can customize the styles to your liking.

Adding Touch Support

To support touch events, we need to modify the event listeners. Instead of using `mousedown`, `mouseup`, `mousemove`, and `mouseout`, we’ll use `touchstart`, `touchend`, `touchmove`, and `touchcancel`.

Replace the existing event listeners in your `useEffect` hook with the following:


    // Touch event handlers
    const startDrawing = (e) => {
      isDrawing = true;
      draw(e);
    };

    const stopDrawing = () => {
      isDrawing = false;
      context.beginPath(); // Reset path when drawing stops
    };

    const draw = (e) => {
      if (!isDrawing) return;
      e.preventDefault(); // Prevent scrolling on touch devices
      const rect = canvas.getBoundingClientRect();
      const touch = e.touches[0]; // Get the first touch point
      const x = touch.clientX - rect.left;
      const y = touch.clientY - rect.top;

      context.strokeStyle = currentColor;
      context.lineWidth = brushSize;
      context.lineCap = 'round';
      context.lineTo(x, y);
      context.stroke();
      context.beginPath();
      context.moveTo(x, y);
    };

    // Event listeners for drawing
    canvas.addEventListener('touchstart', startDrawing);
    canvas.addEventListener('touchend', stopDrawing);
    canvas.addEventListener('touchmove', draw);
    canvas.addEventListener('touchcancel', stopDrawing);

Key changes:

  • `e.preventDefault()`: This is crucial to prevent the default touch behavior, such as scrolling, on touch devices.
  • `e.touches[0]`: We access the first touch point from the `touches` property of the event object.
  • Event Listeners: We use `touchstart`, `touchend`, `touchmove`, and `touchcancel` event listeners.

8. Deploying Your App

Once you’re satisfied with your drawing app, you can deploy it to a platform like Vercel or Netlify. These platforms offer free hosting for Next.js applications.

  • Vercel: Vercel is the easiest option, as it’s specifically designed for Next.js. You can deploy your app directly from your Git repository (e.g., GitHub, GitLab, Bitbucket).
  • Netlify: Netlify is another popular choice. You can also deploy from a Git repository or by uploading your project files.

Here’s how to deploy to Vercel:

  1. Create a Vercel Account: If you don’t have one, sign up for a free Vercel account.
  2. Connect Your Git Repository: In your Vercel dashboard, click “Import Project” and connect your Git repository.
  3. Configure Deployment Settings: Vercel will automatically detect that it’s a Next.js project. You might need to specify the build command (`npm run build`) and the output directory (`.next`). Most of the time, the defaults are fine.
  4. Deploy: Click “Deploy” and Vercel will build and deploy your application.
  5. Access Your App: Once the deployment is complete, Vercel will provide a URL where you can access your drawing app.

The process for Netlify is similar. You’ll need to connect your Git repository and configure the build settings.

Common Mistakes and Troubleshooting

Here are some common mistakes and how to fix them:

  • Lines Not Appearing:
    • Problem: The lines you draw are not visible.
    • Solution: Double-check the following:
    • Make sure you’ve set the `strokeStyle` to a valid color.
    • Ensure the canvas background is not the same color as the `strokeStyle`.
    • Verify that `isDrawing` is correctly set to `true` when the mouse is down and `false` when it’s up.
  • Lines are Jagged:
    • Problem: The lines appear jagged or pixelated.
    • Solution: Make sure you’re using `lineCap = ’round’` to smooth the line ends. Consider increasing the `lineWidth`.
  • Event Listeners Not Working:
    • Problem: The event listeners (e.g., `mousedown`, `mousemove`) are not triggering.
    • Solution:
    • Make sure you’ve attached the event listeners to the correct element (the `canvas` element).
    • Check for any JavaScript errors in your browser’s developer console.
    • Ensure that the event listeners are correctly removed in the cleanup function of your `useEffect` hook to prevent conflicts.
  • Canvas Not Responding to Touch:
    • Problem: The app doesn’t work on a touch device.
    • Solution:
    • Make sure you’ve implemented the touch event listeners (`touchstart`, `touchend`, `touchmove`, `touchcancel`).
    • Add `e.preventDefault()` inside the `touchmove` event handler to prevent scrolling.
  • Color Picker Not Working:
    • Problem: The color picker doesn’t change the drawing color.
    • Solution:
    • Verify that you’re correctly referencing the color picker using `useRef`.
    • Make sure the `updateColor` function is correctly updating the `currentColor` variable.
    • Double-check that you’re using the `currentColor` variable to set the `strokeStyle` of the canvas context.

Summary / Key Takeaways

We’ve successfully built a basic but functional drawing app using Next.js. You’ve learned how to:

  • Set up a Next.js project.
  • Use the HTML5 canvas element.
  • Handle mouse and touch events.
  • Manipulate the canvas context to draw lines, change colors, and control brush size.
  • Implement an eraser function.
  • Improve the user experience with additional features.
  • Deploy your app to a hosting platform.

This project provides a solid foundation for understanding frontend development principles and the capabilities of the canvas API. You can now expand on this project by adding more features like saving and loading drawings, different brush styles, and more advanced drawing tools. Consider exploring libraries like Fabric.js or Konva.js for more complex drawing functionalities.

FAQ

  1. Can I use this app on mobile devices? Yes, with the implementation of touch event listeners, the app is compatible with mobile devices.
  2. How do I save my drawings? Currently, there’s no save functionality. You could implement this by converting the canvas content to a data URL and allowing the user to download the image. You could also explore saving to local storage or a backend database.
  3. Can I add different brush styles? Yes, you can extend the `draw` function to support different brush styles by changing the `lineCap`, `lineJoin`, and other properties of the canvas context. You could also draw images or patterns instead of simple lines.
  4. How can I improve the performance? For more complex drawings or animations, you might consider techniques like:

    • Using requestAnimationFrame for smoother updates.
    • Optimizing the drawing logic to minimize calculations.
    • Using a virtual DOM to reduce re-renders (although this might be overkill for this particular project).

Building this drawing application is just the beginning. The skills you’ve gained in this project, from understanding event handling to manipulating the canvas, are directly applicable to a wide range of web development tasks. You are now equipped to tackle more complex projects that require dynamic user interfaces and creative visual elements. Continue experimenting, and remember that the best way to learn is by building and exploring. The world of frontend development is vast and exciting, and with each project, you’re building a stronger foundation for your future endeavors. Keep coding, keep learning, and keep creating!