Skip to content

Basic Paint Application

Abstract

A paint program is the perfect mid-level project because it stretches every part of GUI programming: drawing on a canvas, tracking mouse motion, handling many simultaneous widgets, and saving the result to a file. In this project you will build a Tkinter-based paint application with a color palette, an adjustable brush, an eraser, a canvas-color picker, a clear button, and a save-to-JPEG feature.

Along the way you will learn:

  • How to capture continuous mouse-drag events with Tkinter bindings.
  • Why drawing a stream of small ovals looks like a smooth brush stroke.
  • How to lay out widgets with frames, grids, and packs.
  • How to capture the canvas as an image via screen grab.
  • How to organize a non-trivial GUI program with a class.

Prerequisites

  • Python 3.6 or above.
  • A text editor or IDE.
  • Comfort with classes (or a willingness to learn — we use them here).
  • Familiarity with Tkinter basics. If you have built the Basic Music Player you already know enough.

Install Pillow

The save-as-image feature uses Pillow (the fork of the classic PIL):

install
pip install pillow
install
pip install pillow

Tkinter itself ships with Python — no install.

Getting Started

Create the project

  1. Create a folder named paint-apppaint-app.
  2. Inside it, create paint.pypaint.py.

Write the code

Paint ApplicationSource
Paint Application
# Basic Paint Application in Python
 
from tkinter import *
from tkinter import ttk
from tkinter import colorchooser
from tkinter import messagebox
from tkinter import filedialog
import PIL
from PIL import ImageGrab
import os
 
class Paint:
    def __init__(self, root):
        self.root = root
        self.root.title("Paint")
        self.root.geometry("800x520")
        self.root.configure(background="white")
        self.root.resizable(0,0)
        self.root.bind("<B1-Motion>", self.paint)
        self.root.bind("<ButtonRelease-1>", self.reset)
        
        self.pen_color = "black"
        self.eraser_color = "white"
        
        self.color_frame = LabelFrame(self.root, text="Color", font=("arial", 15), bd=5, relief=RIDGE, bg="white")
        self.color_frame.place(x=0, y=0, width=70, height=185)
        
        colors = ["#ff0000", "#ff4000", "#ff8000", "#ffbf00", "#ffff00", "#bfff00", "#80ff00", "#40ff00", "#00ff00", "#00ff40", "#00ff80", "#00ffbf", "#00ffff", "#00bfff", "#0080ff", "#0040ff", "#0000ff", "#4000ff", "#8000ff", "#bf00ff", "#ff00ff", "#ff00bf", "#ff0080", "#ff0040", "#ff0000", "#000000", "#ffffff"]
        
        i=j=0
        for color in colors:
            Button(self.color_frame, bg=color, bd=2, relief=RIDGE, width=3, command=lambda col=color:self.select_color(col)).grid(row=i, column=j)
            i+=1
            if i==23:
                i=0
                j=1
                
        self.eraser_button = Button(self.root, text="Eraser", bd=4, bg="white", command=self.eraser, width=8, relief=RIDGE)
        self.eraser_button.place(x=0, y=187)
        
        self.clear_button = Button(self.root, text="Clear", bd=4, bg="white", command=lambda :self.canvas.delete("all"), width=8, relief=RIDGE)
        self.clear_button.place(x=0, y=217)
        
        self.save_button = Button(self.root, text="Save", bd=4, bg="white", command=self.save_paint, width=8, relief=RIDGE)
        self.save_button.place(x=0, y=247)
        
        self.canvas_color_button = Button(self.root, text="Canvas", bd=4, bg="white", command=self.canvas_color, width=8, relief=RIDGE)
        
        self.canvas_color_button.place(x=0, y=277)
        
        self.canvas_width = Scale(self.root, from_=1, to=100, orient=VERTICAL)
        self.canvas_width.set(1)
        
        self.canvas_width.place(x=0, y=330)
        
        self.canvas = Canvas(self.root, bg="white", bd=5, relief=GROOVE, height=500, width=700)
        self.canvas.place(x=80, y=0)
        
    def paint(self, event):
        x1, y1 = (event.x-2), (event.y-2)
        x2, y2 = (event.x+2), (event.y+2)
        
        self.canvas.create_oval(x1, y1, x2, y2, fill=self.pen_color, outline=self.pen_color, width=self.canvas_width.get())
        
    def select_color(self, col):
        self.pen_color = col
        
    def eraser(self):
        self.pen_color = self.eraser_color
        
    def reset(self, event):
        self.old_x = None
        self.old_y = None
        
    def canvas_color(self):
        color = colorchooser.askcolor(title="Select Color")
        self.canvas.configure(background=color[1])
        self.eraser_color = color[1]
        
    def save_paint(self):
        try:
            filename = filedialog.asksaveasfilename(defaultextension=".jpg")
            x = self.root.winfo_rootx() + self.canvas.winfo_x()
            y = self.root.winfo_rooty() + self.canvas.winfo_y()
            x1 = x + self.canvas.winfo_width()
            y1 = y + self.canvas.winfo_height()
            PIL.ImageGrab.grab().crop((x,y,x1,y1)).save(filename)
            messagebox.showinfo("Paint says", "Image is saved as " + str(os.path.basename(filename)) + " successfully")
        except:
            messagebox.showerror("Paint says", "Unable to save image")
            
if __name__ == "__main__":
    root = Tk()
    p = Paint(root)
    root.mainloop()
    
Paint Application
# Basic Paint Application in Python
 
from tkinter import *
from tkinter import ttk
from tkinter import colorchooser
from tkinter import messagebox
from tkinter import filedialog
import PIL
from PIL import ImageGrab
import os
 
class Paint:
    def __init__(self, root):
        self.root = root
        self.root.title("Paint")
        self.root.geometry("800x520")
        self.root.configure(background="white")
        self.root.resizable(0,0)
        self.root.bind("<B1-Motion>", self.paint)
        self.root.bind("<ButtonRelease-1>", self.reset)
        
        self.pen_color = "black"
        self.eraser_color = "white"
        
        self.color_frame = LabelFrame(self.root, text="Color", font=("arial", 15), bd=5, relief=RIDGE, bg="white")
        self.color_frame.place(x=0, y=0, width=70, height=185)
        
        colors = ["#ff0000", "#ff4000", "#ff8000", "#ffbf00", "#ffff00", "#bfff00", "#80ff00", "#40ff00", "#00ff00", "#00ff40", "#00ff80", "#00ffbf", "#00ffff", "#00bfff", "#0080ff", "#0040ff", "#0000ff", "#4000ff", "#8000ff", "#bf00ff", "#ff00ff", "#ff00bf", "#ff0080", "#ff0040", "#ff0000", "#000000", "#ffffff"]
        
        i=j=0
        for color in colors:
            Button(self.color_frame, bg=color, bd=2, relief=RIDGE, width=3, command=lambda col=color:self.select_color(col)).grid(row=i, column=j)
            i+=1
            if i==23:
                i=0
                j=1
                
        self.eraser_button = Button(self.root, text="Eraser", bd=4, bg="white", command=self.eraser, width=8, relief=RIDGE)
        self.eraser_button.place(x=0, y=187)
        
        self.clear_button = Button(self.root, text="Clear", bd=4, bg="white", command=lambda :self.canvas.delete("all"), width=8, relief=RIDGE)
        self.clear_button.place(x=0, y=217)
        
        self.save_button = Button(self.root, text="Save", bd=4, bg="white", command=self.save_paint, width=8, relief=RIDGE)
        self.save_button.place(x=0, y=247)
        
        self.canvas_color_button = Button(self.root, text="Canvas", bd=4, bg="white", command=self.canvas_color, width=8, relief=RIDGE)
        
        self.canvas_color_button.place(x=0, y=277)
        
        self.canvas_width = Scale(self.root, from_=1, to=100, orient=VERTICAL)
        self.canvas_width.set(1)
        
        self.canvas_width.place(x=0, y=330)
        
        self.canvas = Canvas(self.root, bg="white", bd=5, relief=GROOVE, height=500, width=700)
        self.canvas.place(x=80, y=0)
        
    def paint(self, event):
        x1, y1 = (event.x-2), (event.y-2)
        x2, y2 = (event.x+2), (event.y+2)
        
        self.canvas.create_oval(x1, y1, x2, y2, fill=self.pen_color, outline=self.pen_color, width=self.canvas_width.get())
        
    def select_color(self, col):
        self.pen_color = col
        
    def eraser(self):
        self.pen_color = self.eraser_color
        
    def reset(self, event):
        self.old_x = None
        self.old_y = None
        
    def canvas_color(self):
        color = colorchooser.askcolor(title="Select Color")
        self.canvas.configure(background=color[1])
        self.eraser_color = color[1]
        
    def save_paint(self):
        try:
            filename = filedialog.asksaveasfilename(defaultextension=".jpg")
            x = self.root.winfo_rootx() + self.canvas.winfo_x()
            y = self.root.winfo_rooty() + self.canvas.winfo_y()
            x1 = x + self.canvas.winfo_width()
            y1 = y + self.canvas.winfo_height()
            PIL.ImageGrab.grab().crop((x,y,x1,y1)).save(filename)
            messagebox.showinfo("Paint says", "Image is saved as " + str(os.path.basename(filename)) + " successfully")
        except:
            messagebox.showerror("Paint says", "Unable to save image")
            
if __name__ == "__main__":
    root = Tk()
    p = Paint(root)
    root.mainloop()
    

Run it

run
python paint.py
run
python paint.py

A window appears with a color palette on the left, a brush-size slider on the right, a clear button at the top, and a large white canvas in the middle. Click and drag to draw.

Step-by-Step Explanation

1. Class skeleton

paint.py
class Paint:
    def __init__(self, root):
        self.root = root
        self.root.title("Paint")
        self.root.geometry("800x520")
        self.pen_color = "black"
        self.eraser_color = "white"
        self.build_ui()
        self.bind_events()
paint.py
class Paint:
    def __init__(self, root):
        self.root = root
        self.root.title("Paint")
        self.root.geometry("800x520")
        self.pen_color = "black"
        self.eraser_color = "white"
        self.build_ui()
        self.bind_events()

A class lets us stash state (pen color, brush size, canvas reference) on selfself without sprinkling globals throughout the code.

2. Build the layout

paint.py
def build_ui(self):
    self.color_frame = LabelFrame(self.root, text="Colors", padx=5, pady=5)
    self.color_frame.pack(side=LEFT, fill=Y)
 
    colors = ["#000000", "#ff0000", "#ff8000", "#ffff00",
              "#00ff00", "#0080ff", "#0000ff", "#8000ff"]
    for i, color in enumerate(colors):
        Button(self.color_frame, bg=color, width=3, bd=2,
               command=lambda c=color: self.set_pen_color(c)).grid(row=i//2, column=i%2)
 
    self.canvas_width = Scale(self.root, from_=1, to=100, orient=VERTICAL, label="Size")
    self.canvas_width.set(5)
    self.canvas_width.pack(side=RIGHT, fill=Y)
 
    self.canvas = Canvas(self.root, bg="white")
    self.canvas.pack(fill=BOTH, expand=True)
paint.py
def build_ui(self):
    self.color_frame = LabelFrame(self.root, text="Colors", padx=5, pady=5)
    self.color_frame.pack(side=LEFT, fill=Y)
 
    colors = ["#000000", "#ff0000", "#ff8000", "#ffff00",
              "#00ff00", "#0080ff", "#0000ff", "#8000ff"]
    for i, color in enumerate(colors):
        Button(self.color_frame, bg=color, width=3, bd=2,
               command=lambda c=color: self.set_pen_color(c)).grid(row=i//2, column=i%2)
 
    self.canvas_width = Scale(self.root, from_=1, to=100, orient=VERTICAL, label="Size")
    self.canvas_width.set(5)
    self.canvas_width.pack(side=RIGHT, fill=Y)
 
    self.canvas = Canvas(self.root, bg="white")
    self.canvas.pack(fill=BOTH, expand=True)
  • FrameFrame / LabelFrameLabelFrame groups related widgets.
  • The command=lambda c=color: ...command=lambda c=color: ... trick is essential: without the default-argument capture, all buttons would end up using the last color in the loop.
  • ScaleScale is a slider; we use it for brush size.
  • CanvasCanvas is the drawing surface.

3. Bind mouse events

paint.py
def bind_events(self):
    self.canvas.bind("<B1-Motion>", self.paint)
    self.canvas.bind("<ButtonRelease-1>", self.reset)
paint.py
def bind_events(self):
    self.canvas.bind("<B1-Motion>", self.paint)
    self.canvas.bind("<ButtonRelease-1>", self.reset)
  • <B1-Motion><B1-Motion> fires repeatedly while the left mouse button is held and the cursor moves.
  • <ButtonRelease-1><ButtonRelease-1> fires once when the button is released.

4. Paint with overlapping ovals

paint.py
def paint(self, event):
    size = self.canvas_width.get()
    x1, y1 = event.x - size, event.y - size
    x2, y2 = event.x + size, event.y + size
    self.canvas.create_oval(x1, y1, x2, y2,
                            fill=self.pen_color,
                            outline=self.pen_color)
paint.py
def paint(self, event):
    size = self.canvas_width.get()
    x1, y1 = event.x - size, event.y - size
    x2, y2 = event.x + size, event.y + size
    self.canvas.create_oval(x1, y1, x2, y2,
                            fill=self.pen_color,
                            outline=self.pen_color)

Every mouse-move event draws a small filled oval at the cursor. Because the events fire many times per second, the result looks like a continuous brush stroke. With a bigger brush size, the ovals are larger and the stroke gets wider.

💡 For a truly continuous line, use create_linecreate_line between the previous and current mouse positions. Track the last point in self.old_x, self.old_yself.old_x, self.old_y and reset them in reset()reset().

5. Color and tool switching

paint.py
def set_pen_color(self, color):
    self.pen_color = color
 
def use_eraser(self):
    self.pen_color = self.eraser_color    # erase = paint with bg color
 
def pick_canvas_color(self):
    color = colorchooser.askcolor(title="Canvas color")[1]
    if color:
        self.canvas.configure(background=color)
        self.eraser_color = color
paint.py
def set_pen_color(self, color):
    self.pen_color = color
 
def use_eraser(self):
    self.pen_color = self.eraser_color    # erase = paint with bg color
 
def pick_canvas_color(self):
    color = colorchooser.askcolor(title="Canvas color")[1]
    if color:
        self.canvas.configure(background=color)
        self.eraser_color = color

An eraser is just paint that matches the background. Picking a new canvas color updates the eraser to match.

6. Clear and save

paint.py
def clear_canvas(self):
    self.canvas.delete("all")
 
def save_canvas(self):
    filename = filedialog.asksaveasfilename(defaultextension=".jpg")
    if not filename: return
    self.root.update()                     # ensure window is fully drawn
    x = self.root.winfo_rootx() + self.canvas.winfo_x()
    y = self.root.winfo_rooty() + self.canvas.winfo_y()
    x1 = x + self.canvas.winfo_width()
    y1 = y + self.canvas.winfo_height()
    ImageGrab.grab(bbox=(x, y, x1, y1)).save(filename)
paint.py
def clear_canvas(self):
    self.canvas.delete("all")
 
def save_canvas(self):
    filename = filedialog.asksaveasfilename(defaultextension=".jpg")
    if not filename: return
    self.root.update()                     # ensure window is fully drawn
    x = self.root.winfo_rootx() + self.canvas.winfo_x()
    y = self.root.winfo_rooty() + self.canvas.winfo_y()
    x1 = x + self.canvas.winfo_width()
    y1 = y + self.canvas.winfo_height()
    ImageGrab.grab(bbox=(x, y, x1, y1)).save(filename)
  • delete("all")delete("all") removes every drawn item.
  • Saving uses PIL.ImageGrab.grabPIL.ImageGrab.grab to screen-capture the canvas region.
    • winfo_rootx/ywinfo_rootx/y give the canvas position in screen coordinates (not window coordinates).
  • A more advanced approach maintains a parallel PIL.ImagePIL.Image and draws into it via PIL.ImageDrawPIL.ImageDraw — that way the saved image is identical regardless of what is on screen (no risk of capturing an overlapping window).

Common Mistakes

ProblemCauseFix
All color buttons paint the same colorClosure captured the loop variableUse lambda c=color: ...lambda c=color: ... to bind early
Brush leaves dotted lineEvents fire faster than they can be drawnUse create_linecreate_line between consecutive points
ImageGrab.grab()ImageGrab.grab() returns the wrong regionUsed canvas-local coordinatesUse winfo_rootx/ywinfo_rootx/y for screen coordinates
Eraser leaves black on a colored canvasEraser color hard-coded to whiteUpdate self.eraser_colorself.eraser_color whenever the canvas color changes
Save dialog hangsNo file extensionUse asksaveasfilename(defaultextension=".jpg")asksaveasfilename(defaultextension=".jpg")

Variations to Try

1. Custom color picker

Replace the eight preset buttons with a single “Choose color…” button using colorchooser.askcolorcolorchooser.askcolor.

2. Smooth brush lines

smooth.py
def paint(self, event):
    if self.old_x and self.old_y:
        self.canvas.create_line(self.old_x, self.old_y,
                                event.x, event.y,
                                width=self.canvas_width.get(),
                                fill=self.pen_color,
                                capstyle=ROUND, smooth=True)
    self.old_x, self.old_y = event.x, event.y
 
def reset(self, event):
    self.old_x = self.old_y = None
smooth.py
def paint(self, event):
    if self.old_x and self.old_y:
        self.canvas.create_line(self.old_x, self.old_y,
                                event.x, event.y,
                                width=self.canvas_width.get(),
                                fill=self.pen_color,
                                capstyle=ROUND, smooth=True)
    self.old_x, self.old_y = event.x, event.y
 
def reset(self, event):
    self.old_x = self.old_y = None

A real, smooth brush stroke.

3. Undo / redo

Maintain a stack of canvas-item IDs. On undo, canvas.delete(last_id)canvas.delete(last_id).

4. Shape tools

A toolbar with rectangle, oval, and line tools. On mouse-press record the start point; on release create the shape.

5. Text tool

Click on the canvas → open an EntryEntry → press Enter → place the text there.

6. Spray paint

Each mouse event creates a random pattern of small dots inside a radius. Looks more like spray than a brush.

7. Layers

Use multiple CanvasCanvas widgets stacked together, or maintain an in-memory PIL.ImagePIL.Image per layer and composite on save.

8. Image import

Use PIL.Image.openPIL.Image.openImageTk.PhotoImageImageTk.PhotoImagecanvas.create_imagecanvas.create_image to bring an existing picture onto the canvas, then paint over it.

9. Touch support

On a tablet or touch-screen Tkinter receives events the same way. Try it.

10. Save as PNG with transparency

Drop the screen-grab approach and use a parallel PIL.ImagePIL.Image so the saved PNG truly matches what is drawn — no background bleed.

Real-World Applications

  • Image annotation tools for ML labeling.
  • Whiteboard apps for remote teaching.
  • Quick mockup tools for designers.
  • Signature capture in form-filling apps.
  • Drawing input for ML models that take strokes (handwriting recognition, sketch classification).

Educational Value

This project teaches:

  • Event-driven GUI — bindings, callbacks, the main loop.
  • Canvas programming — creating, deleting, and managing graphic items.
  • Layout managerspackpack, gridgrid, frames, side-by-side regions.
  • State management — what color, what tool, what size, all stored on selfself.
  • Cross-library bridges — Tkinter + Pillow to produce a real file.
  • Class-based design — keeping UI code organized.

Next Steps

  • Add smooth-brush drawing using create_linecreate_line as shown above.
  • Implement undo/redo with a stack of item IDs.
  • Add a toolbar with rectangle, oval, line, and text tools.
  • Replace the screen-grab save with a PIL-backed image for true layered drawing.
  • Port the UI to CustomTkinter for a modern look.
  • Convert it into a collaborative whiteboard with WebSockets — pair the GUI with a Flask backend.

Try it here

Here’s the same idea running in your browser — click and drag to draw, click a colour swatch to switch brushes, and press C to clear:

sketch A mini paint canvas p5.js
Click and drag to draw. Click a swatch to change colour; press C to clear the canvas.

Conclusion

You built a real desktop paint app: a canvas, a palette, brush controls, save-as-image, and a class-based architecture you can keep extending. Tkinter shows its age in styling but its power model — widgets, events, the main loop — is identical to what big frameworks use. Full source on GitHub. Find more GUI projects on Python Central Hub.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did