Navigation

Python

How to Create Custom Widgets

Build reusable custom widgets in 2025: tkinter custom components, widget inheritance, compound widgets, and advanced styling

Table Of Contents

Quick Fix: Basic Custom Widget

import tkinter as tk
from tkinter import ttk

class CustomButton(tk.Frame):
    def __init__(self, parent, text="Click Me", command=None, **kwargs):
        super().__init__(parent, **kwargs)
        
        self.command = command
        self.is_pressed = False
        
        self.create_widget(text)
        self.bind_events()
    
    def create_widget(self, text):
        # Custom button with gradient-like effect
        self.button_frame = tk.Frame(self, relief="raised", bd=2, 
                                   bg="#3498db", cursor="hand2")
        self.button_frame.pack(fill="both", expand=True, padx=2, pady=2)
        
        self.label = tk.Label(self.button_frame, text=text, 
                             bg="#3498db", fg="white", 
                             font=("Arial", 12, "bold"))
        self.label.pack(expand=True)
    
    def bind_events(self):
        # Bind events to both frame and label
        for widget in [self.button_frame, self.label]:
            widget.bind("<Button-1>", self.on_press)
            widget.bind("<ButtonRelease-1>", self.on_release)
            widget.bind("<Enter>", self.on_enter)
            widget.bind("<Leave>", self.on_leave)
    
    def on_press(self, event):
        self.is_pressed = True
        self.button_frame.config(relief="sunken", bg="#2980b9")
        self.label.config(bg="#2980b9")
    
    def on_release(self, event):
        if self.is_pressed:
            self.is_pressed = False
            self.button_frame.config(relief="raised", bg="#3498db")
            self.label.config(bg="#3498db")
            
            if self.command:
                self.command()
    
    def on_enter(self, event):
        if not self.is_pressed:
            self.button_frame.config(bg="#5dade2")
            self.label.config(bg="#5dade2")
    
    def on_leave(self, event):
        if not self.is_pressed:
            self.button_frame.config(bg="#3498db")
            self.label.config(bg="#3498db")
    
    def configure_text(self, text):
        self.label.config(text=text)

class CustomEntry(tk.Frame):
    def __init__(self, parent, placeholder="Enter text...", **kwargs):
        super().__init__(parent, **kwargs)
        
        self.placeholder = placeholder
        self.placeholder_active = True
        
        self.create_widget()
        self.setup_placeholder()
    
    def create_widget(self):
        self.entry = tk.Entry(self, font=("Arial", 12), relief="flat", bd=5)
        self.entry.pack(fill="both", expand=True, padx=5, pady=5)
        
        # Custom border frame
        self.config(relief="solid", bd=1, bg="#bdc3c7")
        
        self.entry.bind("<FocusIn>", self.on_focus_in)
        self.entry.bind("<FocusOut>", self.on_focus_out)
    
    def setup_placeholder(self):
        self.entry.insert(0, self.placeholder)
        self.entry.config(fg="#7f8c8d")
        self.placeholder_active = True
    
    def on_focus_in(self, event):
        if self.placeholder_active:
            self.entry.delete(0, tk.END)
            self.entry.config(fg="black")
            self.placeholder_active = False
        
        self.config(bg="#3498db")
    
    def on_focus_out(self, event):
        if not self.entry.get():
            self.setup_placeholder()
        
        self.config(bg="#bdc3c7")
    
    def get(self):
        if self.placeholder_active:
            return ""
        return self.entry.get()
    
    def set(self, value):
        if self.placeholder_active:
            self.entry.delete(0, tk.END)
            self.placeholder_active = False
            self.entry.config(fg="black")
        else:
            self.entry.delete(0, tk.END)
        self.entry.insert(0, value)

class CustomWidgetDemo:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Custom Widget Demo")
        self.root.geometry("500x400")
        self.root.config(bg="#ecf0f1")
        
        self.create_interface()
    
    def create_interface(self):
        # Title
        title_label = tk.Label(self.root, text="Custom Widget Demo", 
                              font=("Arial", 18, "bold"), 
                              bg="#ecf0f1", fg="#2c3e50")
        title_label.pack(pady=20)
        
        # Custom buttons
        button_frame = tk.Frame(self.root, bg="#ecf0f1")
        button_frame.pack(pady=20)
        
        custom_btn1 = CustomButton(button_frame, text="Custom Button 1", 
                                  command=lambda: self.show_message("Button 1 clicked!"))
        custom_btn1.pack(side="left", padx=10, ipadx=20, ipady=10)
        
        custom_btn2 = CustomButton(button_frame, text="Custom Button 2", 
                                  command=lambda: self.show_message("Button 2 clicked!"))
        custom_btn2.pack(side="left", padx=10, ipadx=20, ipady=10)
        
        # Custom entry
        entry_frame = tk.Frame(self.root, bg="#ecf0f1")
        entry_frame.pack(pady=20, padx=50, fill="x")
        
        tk.Label(entry_frame, text="Custom Entry Field:", 
                bg="#ecf0f1", font=("Arial", 12)).pack(anchor="w")
        
        self.custom_entry = CustomEntry(entry_frame, placeholder="Type something here...")
        self.custom_entry.pack(fill="x", pady=5)
        
        # Get value button
        get_btn = CustomButton(entry_frame, text="Get Value", 
                              command=self.get_entry_value)
        get_btn.pack(pady=10, ipadx=10, ipady=5)
        
        # Result display
        self.result_label = tk.Label(self.root, text="", 
                                   bg="#ecf0f1", fg="#e74c3c", 
                                   font=("Arial", 12))
        self.result_label.pack(pady=10)
    
    def show_message(self, message):
        self.result_label.config(text=message)
    
    def get_entry_value(self):
        value = self.custom_entry.get()
        if value:
            self.result_label.config(text=f"Entry value: '{value}'")
        else:
            self.result_label.config(text="Entry is empty")
    
    def run(self):
        self.root.mainloop()

# Usage
demo = CustomWidgetDemo()
demo.run()

Advanced Custom Widget Components

import tkinter as tk
from tkinter import ttk
import math
from typing import Callable

class CircularProgressBar(tk.Canvas):
    def __init__(self, parent, size=100, thickness=10, progress=0, 
                 bg_color="#ecf0f1", fg_color="#3498db", text_color="#2c3e50", **kwargs):
        super().__init__(parent, width=size, height=size, **kwargs)
        
        self.size = size
        self.thickness = thickness
        self.progress = progress
        self.bg_color = bg_color
        self.fg_color = fg_color
        self.text_color = text_color
        
        self.center = size // 2
        self.radius = (size - thickness) // 2
        
        self.draw()
    
    def draw(self):
        self.delete("all")
        
        # Background circle
        self.create_oval(
            self.center - self.radius, self.center - self.radius,
            self.center + self.radius, self.center + self.radius,
            outline=self.bg_color, width=self.thickness
        )
        
        # Progress arc
        if self.progress > 0:
            extent = (self.progress / 100) * 360
            self.create_arc(
                self.center - self.radius, self.center - self.radius,
                self.center + self.radius, self.center + self.radius,
                start=90, extent=-extent, outline=self.fg_color,
                width=self.thickness, style="arc"
            )
        
        # Center text
        self.create_text(
            self.center, self.center,
            text=f"{self.progress:.1f}%",
            fill=self.text_color, font=("Arial", 12, "bold")
        )
    
    def set_progress(self, value):
        self.progress = max(0, min(100, value))
        self.draw()
    
    def animate_to(self, target_value, duration=1000, callback=None):
        target_value = max(0, min(100, target_value))
        start_value = self.progress
        steps = 50
        step_value = (target_value - start_value) / steps
        step_delay = duration // steps
        
        def animate_step(step):
            if step <= steps:
                new_value = start_value + (step_value * step)
                self.set_progress(new_value)
                self.after(step_delay, lambda: animate_step(step + 1))
            elif callback:
                callback()
        
        animate_step(0)

class ToggleSwitch(tk.Frame):
    def __init__(self, parent, callback=None, initial_state=False, **kwargs):
        super().__init__(parent, **kwargs)
        
        self.callback = callback
        self.state = initial_state
        
        self.width = 60
        self.height = 30
        
        self.create_widget()
        self.update_display()
    
    def create_widget(self):
        self.canvas = tk.Canvas(self, width=self.width, height=self.height, 
                              highlightthickness=0, cursor="hand2")
        self.canvas.pack()
        
        self.canvas.bind("<Button-1>", self.toggle)
    
    def toggle(self, event=None):
        self.state = not self.state
        self.update_display()
        
        if self.callback:
            self.callback(self.state)
    
    def update_display(self):
        self.canvas.delete("all")
        
        # Background
        bg_color = "#2ecc71" if self.state else "#bdc3c7"
        self.canvas.create_rounded_rectangle(
            2, 2, self.width-2, self.height-2, 
            radius=self.height//2, fill=bg_color, outline=""
        )
        
        # Knob
        knob_x = self.width - 18 if self.state else 12
        self.canvas.create_oval(
            knob_x-8, 6, knob_x+8, self.height-6,
            fill="white", outline="#bdc3c7", width=1
        )
    
    def set_state(self, state):
        self.state = state
        self.update_display()

# Add rounded rectangle method to Canvas
def create_rounded_rectangle(self, x1, y1, x2, y2, radius=25, **kwargs):
    points = []
    for x, y in [(x1, y1 + radius), (x1, y1), (x1 + radius, y1),
                 (x2 - radius, y1), (x2, y1), (x2, y1 + radius),
                 (x2, y2 - radius), (x2, y2), (x2 - radius, y2),
                 (x1 + radius, y2), (x1, y2), (x1, y2 - radius)]:
        points.extend([x, y])
    return self.create_polygon(points, smooth=True, **kwargs)

tk.Canvas.create_rounded_rectangle = create_rounded_rectangle

class NumericStepper(tk.Frame):
    def __init__(self, parent, min_value=0, max_value=100, step=1, 
                 initial_value=0, callback=None, **kwargs):
        super().__init__(parent, **kwargs)
        
        self.min_value = min_value
        self.max_value = max_value
        self.step = step
        self.value = initial_value
        self.callback = callback
        
        self.create_widget()
        self.update_display()
    
    def create_widget(self):
        # Decrease button
        self.decrease_btn = tk.Button(self, text="−", font=("Arial", 14, "bold"),
                                    command=self.decrease, width=3)
        self.decrease_btn.pack(side="left")
        
        # Value display
        self.value_var = tk.StringVar()
        self.value_entry = tk.Entry(self, textvariable=self.value_var, 
                                  width=10, justify="center", font=("Arial", 12))
        self.value_entry.pack(side="left", padx=5)
        self.value_entry.bind("<Return>", self.on_entry_change)
        self.value_entry.bind("<FocusOut>", self.on_entry_change)
        
        # Increase button
        self.increase_btn = tk.Button(self, text="+", font=("Arial", 14, "bold"),
                                    command=self.increase, width=3)
        self.increase_btn.pack(side="left")
    
    def decrease(self):
        new_value = max(self.min_value, self.value - self.step)
        self.set_value(new_value)
    
    def increase(self):
        new_value = min(self.max_value, self.value + self.step)
        self.set_value(new_value)
    
    def set_value(self, value):
        old_value = self.value
        self.value = max(self.min_value, min(self.max_value, value))
        self.update_display()
        
        if self.callback and old_value != self.value:
            self.callback(self.value)
    
    def get_value(self):
        return self.value
    
    def update_display(self):
        self.value_var.set(str(self.value))
        
        # Update button states
        self.decrease_btn.config(state="normal" if self.value > self.min_value else "disabled")
        self.increase_btn.config(state="normal" if self.value < self.max_value else "disabled")
    
    def on_entry_change(self, event=None):
        try:
            new_value = float(self.value_var.get())
            self.set_value(new_value)
        except ValueError:
            self.update_display()  # Reset to current value

class ColorPicker(tk.Frame):
    def __init__(self, parent, callback=None, initial_color="#3498db", **kwargs):
        super().__init__(parent, **kwargs)
        
        self.callback = callback
        self.current_color = initial_color
        
        self.create_widget()
    
    def create_widget(self):
        # Color display
        self.color_frame = tk.Frame(self, width=50, height=30, 
                                   bg=self.current_color, relief="raised", bd=2)
        self.color_frame.pack(side="left", padx=5)
        self.color_frame.bind("<Button-1>", self.open_color_dialog)
        
        # Color value
        self.color_label = tk.Label(self, text=self.current_color, 
                                   font=("Arial", 10))
        self.color_label.pack(side="left", padx=5)
        
        # Quick color buttons
        quick_colors = ["#e74c3c", "#f39c12", "#f1c40f", "#2ecc71", 
                       "#3498db", "#9b59b6", "#34495e", "#95a5a6"]
        
        for color in quick_colors:
            btn = tk.Button(self, bg=color, width=2, relief="flat",
                          command=lambda c=color: self.set_color(c))
            btn.pack(side="left", padx=1)
    
    def open_color_dialog(self, event=None):
        from tkinter import colorchooser
        color = colorchooser.askcolor(initialcolor=self.current_color)[1]
        if color:
            self.set_color(color)
    
    def set_color(self, color):
        self.current_color = color
        self.color_frame.config(bg=color)
        self.color_label.config(text=color)
        
        if self.callback:
            self.callback(color)
    
    def get_color(self):
        return self.current_color

class RangeSlider(tk.Frame):
    def __init__(self, parent, min_value=0, max_value=100, 
                 initial_range=(20, 80), callback=None, **kwargs):
        super().__init__(parent, **kwargs)
        
        self.min_value = min_value
        self.max_value = max_value
        self.low_value, self.high_value = initial_range
        self.callback = callback
        
        self.width = 300
        self.height = 40
        
        self.create_widget()
    
    def create_widget(self):
        self.canvas = tk.Canvas(self, width=self.width, height=self.height,
                              highlightthickness=0)
        self.canvas.pack()
        
        self.canvas.bind("<Button-1>", self.on_click)
        self.canvas.bind("<B1-Motion>", self.on_drag)
        
        self.dragging = None
        self.draw()
    
    def draw(self):
        self.canvas.delete("all")
        
        # Track
        track_y = self.height // 2
        track_margin = 20
        
        # Background track
        self.canvas.create_line(
            track_margin, track_y, self.width - track_margin, track_y,
            width=4, fill="#bdc3c7"
        )
        
        # Active range
        low_x = self.value_to_x(self.low_value)
        high_x = self.value_to_x(self.high_value)
        
        self.canvas.create_line(
            low_x, track_y, high_x, track_y,
            width=4, fill="#3498db"
        )
        
        # Handles
        handle_radius = 8
        
        # Low handle
        self.low_handle = self.canvas.create_oval(
            low_x - handle_radius, track_y - handle_radius,
            low_x + handle_radius, track_y + handle_radius,
            fill="#2980b9", outline="white", width=2
        )
        
        # High handle
        self.high_handle = self.canvas.create_oval(
            high_x - handle_radius, track_y - handle_radius,
            high_x + handle_radius, track_y + handle_radius,
            fill="#2980b9", outline="white", width=2
        )
        
        # Value labels
        self.canvas.create_text(
            low_x, track_y - 20, text=str(int(self.low_value)),
            font=("Arial", 10)
        )
        
        self.canvas.create_text(
            high_x, track_y - 20, text=str(int(self.high_value)),
            font=("Arial", 10)
        )
    
    def value_to_x(self, value):
        track_margin = 20
        track_width = self.width - 2 * track_margin
        ratio = (value - self.min_value) / (self.max_value - self.min_value)
        return track_margin + ratio * track_width
    
    def x_to_value(self, x):
        track_margin = 20
        track_width = self.width - 2 * track_margin
        ratio = (x - track_margin) / track_width
        return self.min_value + ratio * (self.max_value - self.min_value)
    
    def on_click(self, event):
        x, y = event.x, event.y
        
        # Check which handle is closest
        low_x = self.value_to_x(self.low_value)
        high_x = self.value_to_x(self.high_value)
        
        low_distance = abs(x - low_x)
        high_distance = abs(x - high_x)
        
        if low_distance < high_distance:
            self.dragging = "low"
        else:
            self.dragging = "high"
        
        self.on_drag(event)
    
    def on_drag(self, event):
        if self.dragging:
            new_value = self.x_to_value(event.x)
            new_value = max(self.min_value, min(self.max_value, new_value))
            
            if self.dragging == "low":
                self.low_value = min(new_value, self.high_value)
            else:
                self.high_value = max(new_value, self.low_value)
            
            self.draw()
            
            if self.callback:
                self.callback((self.low_value, self.high_value))
    
    def get_range(self):
        return (self.low_value, self.high_value)

class AdvancedCustomWidgetDemo:
    def __init__(self):
        self.root = tk.Tk()
        self.root.title("Advanced Custom Widgets")
        self.root.geometry("800x600")
        self.root.config(bg="#ecf0f1")
        
        self.create_interface()
    
    def create_interface(self):
        # Title
        title_label = tk.Label(self.root, text="Advanced Custom Widgets", 
                              font=("Arial", 20, "bold"), 
                              bg="#ecf0f1", fg="#2c3e50")
        title_label.pack(pady=20)
        
        # Main container
        main_frame = tk.Frame(self.root, bg="#ecf0f1")
        main_frame.pack(fill="both", expand=True, padx=20)
        
        # Left column
        left_frame = tk.Frame(main_frame, bg="#ecf0f1")
        left_frame.pack(side="left", fill="both", expand=True, padx=10)
        
        # Circular Progress Bar
        progress_frame = tk.LabelFrame(left_frame, text="Circular Progress Bar", 
                                     bg="#ecf0f1", font=("Arial", 12, "bold"))
        progress_frame.pack(fill="x", pady=10)
        
        self.progress_bar = CircularProgressBar(progress_frame, size=120)
        self.progress_bar.pack(pady=10)
        
        progress_controls = tk.Frame(progress_frame, bg="#ecf0f1")
        progress_controls.pack(pady=5)
        
        tk.Button(progress_controls, text="Animate to 75%", 
                 command=lambda: self.progress_bar.animate_to(75)).pack(side="left", padx=5)
        tk.Button(progress_controls, text="Reset", 
                 command=lambda: self.progress_bar.set_progress(0)).pack(side="left", padx=5)
        
        # Toggle Switch
        toggle_frame = tk.LabelFrame(left_frame, text="Toggle Switch", 
                                   bg="#ecf0f1", font=("Arial", 12, "bold"))
        toggle_frame.pack(fill="x", pady=10)
        
        self.toggle = ToggleSwitch(toggle_frame, callback=self.on_toggle)
        self.toggle.pack(pady=10)
        
        self.toggle_status = tk.Label(toggle_frame, text="OFF", 
                                    bg="#ecf0f1", font=("Arial", 12))
        self.toggle_status.pack()
        
        # Numeric Stepper
        stepper_frame = tk.LabelFrame(left_frame, text="Numeric Stepper", 
                                    bg="#ecf0f1", font=("Arial", 12, "bold"))
        stepper_frame.pack(fill="x", pady=10)
        
        self.stepper = NumericStepper(stepper_frame, min_value=0, max_value=50, 
                                    step=5, callback=self.on_stepper_change)
        self.stepper.pack(pady=10)
        
        # Right column
        right_frame = tk.Frame(main_frame, bg="#ecf0f1")
        right_frame.pack(side="right", fill="both", expand=True, padx=10)
        
        # Color Picker
        color_frame = tk.LabelFrame(right_frame, text="Color Picker", 
                                  bg="#ecf0f1", font=("Arial", 12, "bold"))
        color_frame.pack(fill="x", pady=10)
        
        self.color_picker = ColorPicker(color_frame, callback=self.on_color_change)
        self.color_picker.pack(pady=10)
        
        # Range Slider
        range_frame = tk.LabelFrame(right_frame, text="Range Slider", 
                                  bg="#ecf0f1", font=("Arial", 12, "bold"))
        range_frame.pack(fill="x", pady=10)
        
        self.range_slider = RangeSlider(range_frame, callback=self.on_range_change)
        self.range_slider.pack(pady=10)
        
        self.range_label = tk.Label(range_frame, text="Range: 20 - 80", 
                                  bg="#ecf0f1", font=("Arial", 10))
        self.range_label.pack()
        
        # Status display
        status_frame = tk.LabelFrame(right_frame, text="Widget Status", 
                                   bg="#ecf0f1", font=("Arial", 12, "bold"))
        status_frame.pack(fill="both", expand=True, pady=10)
        
        self.status_text = tk.Text(status_frame, height=10, wrap="word")
        scrollbar = tk.Scrollbar(status_frame, orient="vertical", 
                               command=self.status_text.yview)
        self.status_text.configure(yscrollcommand=scrollbar.set)
        
        self.status_text.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")
    
    def on_toggle(self, state):
        self.toggle_status.config(text="ON" if state else "OFF")
        self.log_status(f"Toggle switched: {'ON' if state else 'OFF'}")
    
    def on_stepper_change(self, value):
        self.log_status(f"Stepper value: {value}")
    
    def on_color_change(self, color):
        self.log_status(f"Color selected: {color}")
    
    def on_range_change(self, range_values):
        low, high = range_values
        self.range_label.config(text=f"Range: {int(low)} - {int(high)}")
        self.log_status(f"Range changed: {int(low)} - {int(high)}")
    
    def log_status(self, message):
        self.status_text.insert(tk.END, f"{message}\n")
        self.status_text.see(tk.END)
    
    def run(self):
        self.root.mainloop()

# Usage
demo = AdvancedCustomWidgetDemo()
demo.run()

Works with tkinter 8.6+ and creates sophisticated custom widgets with advanced functionality.

Share this article

Add Comment

No comments yet. Be the first to comment!

More from Python