Skip to content

🖼️ CustomTkinter — interfaces gráficas (GUI)

customtkinter es una librería que envuelve a tkinter (la GUI que trae Python por defecto) pero con widgets modernos, con bordes redondeados y soporte de temas claro/oscuro. Se usa para crear ventanas de escritorio sin depender de HTML/CSS.

🟢 Instalación

bash
pip install customtkinter

💡 Se instala una sola vez por entorno (o por venv si usas entornos virtuales). Ver comando en 01-Comandos.

⚠️ En macOS con Python de Homebrew, si al correr la app da ModuleNotFoundError: No module named '_tkinter', falta el paquete de sistema python-tk. Ver la solución completa en 06-Errores: tkinter no encontrado.

🅰️ Estructura mínima de una app

Toda app de customtkinter sigue siempre el mismo esqueleto: crear la ventana, agregar widgets dentro, y mantenerla abierta con un bucle.

┌─────────────────────────────┐
│  ctk.CTk()                  │  ← ventana principal (contenedor raíz)
│  ┌─────────────────────────┐│
│  │ CTkLabel   "Bienvenido" ││  ← widgets van DENTRO de la ventana
│  ├─────────────────────────┤│
│  │ CTkButton  "Presióname" ││
│  └─────────────────────────┘│
└─────────────────────────────┘
        ventana.mainloop()   ← mantiene todo esto visible y "escuchando" clics

Flujo de creación (siempre en este orden):

  1. Crear la ventana → ctk.CTk()
  2. Configurarla → .title(), .geometry()
  3. Crear widgets pasándoles la ventana como "dueño" → ctk.CTkLabel(ventana, ...)
  4. Ubicar cada widget en pantalla → .pack(...) (u otro layout manager)
  5. Arrancar el bucle de eventos → ventana.mainloop() (va siempre al final, es lo último)

⚠️ Si no llamas a .mainloop(), la ventana no se muestra o se cierra de inmediato — es obligatorio y siempre es la última línea.

🔍 Piezas clave

PiezaQué esNotas
ctk.CTk()Crea la ventana principal (raíz)Solo puede haber una ventana raíz por app
.title("texto")Pone el título en la barra de la ventana
.geometry("400x300")Define el tamaño inicial en píxeles: "anchoxalto"Es un string, con la x en minúscula como separador
ctk.CTkLabel(ventana, text="...")Widget de texto (etiqueta) fijoEquivale a un <p> o <label> en HTML
ctk.CTkButton(ventana, text="...", command=funcion)Botón clickeablecommand recibe la función sin paréntesis — si pones funcion() la ejecuta al instante, no al hacer clic
.pack(pady=20)Ubica el widget en la ventana y agrega espaciopady = padding vertical, padx = padding horizontal
.configure(text="nuevo texto")Cambia una propiedad de un widget ya creadoAsí se actualiza un CTkLabel en vivo, sin recrearlo
ventana.mainloop()Bucle que mantiene la ventana abierta escuchando eventos (clics, teclas, etc.)Bloquea el código hasta que se cierra la ventana

📌 command=saludar (sin paréntesis) le pasa la referencia a la función. Con paréntesis (command=saludar()) Python ejecuta saludar() inmediatamente al construir el botón y le pasa el resultado (normalmente None) como command — es un error común.

🧪 Tip de entrevista: ¿Por qué command=funcion y no command=funcion()? Porque command espera un callback (algo que se ejecuta después, al hacer clic), y en Python funcion sin paréntesis es la función misma, mientras que funcion() ya es el resultado de haberla ejecutado.

🔁 Patrón: actualizar un widget desde una función

Para que un botón cambie lo que se ve en pantalla, la función debe tener acceso a la variable del widget (por eso titulo se define antes que la función lo use, y Python la busca en el momento del clic, no al definir la función):

python
import customtkinter as ctk

def saludar():
    titulo.configure(text="Hola, presionaste el botón")  # actualiza el CTkLabel existente

ventana = ctk.CTk()
ventana.title("Mi primera app")
ventana.geometry("400x300")

titulo = ctk.CTkLabel(ventana, text="Bienvenido")
titulo.pack(pady=20)

boton = ctk.CTkButton(ventana, text="Presióname", command=saludar)
boton.pack(pady=10)

ventana.mainloop()

💡 Aunque saludar() está definida antes que titulo exista en el código, funciona porque la función no se ejecuta al definirla, sino al hacer clic — para ese momento titulo ya existe. Esto es un ejemplo de closure: la función "recuerda" el entorno donde fue creada.

🧱 Layout managers: pack vs grid vs place

Un layout manager es el método que decide dónde se dibuja cada widget dentro de su contenedor. .pack() es el más simple (apila widgets uno tras otro), pero para diseños con varias columnas/filas (formularios, calculadoras, etc.) se usa .grid().

Layout managerCómo ubica los widgetsCuándo usarlo
.pack()Apila en un solo eje (arriba→abajo o lado a lado)Diseños simples, una sola columna
.grid()Ubica en una cuadrícula de filas y columnas (row, column)Formularios, calculadoras, cualquier diseño en tabla
.place()Ubica por coordenadas absolutas (x, y) o relativasCasos puntuales; poco usado porque no se adapta bien a cambios de tamaño

⚠️ No mezcles .pack() y .grid() dentro del mismo contenedor (mismo padre). Tkinter lanza error o se traba porque los dos manejan el espacio de formas incompatibles. Sí puedes usar .pack() en un widget y .grid() en otro si tienen distinto padre (ver Frames más abajo).

🔍 .grid() — filas y columnas

        column=0        column=1        column=2
      ┌───────────┬───────────────┬───────────┐
row=0 │  Label    │   Entry (columnspan=2)     │
      ├───────────┼───────────────┼───────────┤
row=1 │  Label    │    Entry      │   Botón   │
      └───────────┴───────────────┴───────────┘
python
etiqueta = ctk.CTkLabel(ventana, text="Nombre:")
etiqueta.grid(row=0, column=0, padx=10, pady=10, sticky="e")

entrada = ctk.CTkEntry(ventana)
entrada.grid(row=0, column=1, padx=10, pady=10, sticky="w")
Parámetro de .grid()Qué hace
row, columnPosición en la cuadrícula (empiezan en 0)
columnspan, rowspanCuántas columnas/filas ocupa el widget (para que abarque varias celdas)
stickyA qué borde de la celda se "pega" el widget: combinación de "n", "s", "e", "o" (norte/sur/este/oeste). sticky="ew" estira el widget para llenar el ancho de la celda
padx, padyEspacio exterior de la celda (igual que en .pack())

📌 sticky usa las iniciales en inglés de los puntos cardinales: n, s, e, w (norte, sur, este, oeste) — en customtkinter/tkinter es "w" (west) y no "o", a diferencia de como se suele escribir "oeste" en español.

Para que las columnas/filas se repartan el espacio sobrante al redimensionar la ventana, hay que decirle al contenedor cómo pesa cada una:

python
ventana.grid_columnconfigure(1, weight=1)  # la columna 1 se estira si la ventana crece
ventana.grid_rowconfigure(0, weight=1)

🧪 Tip de entrevista: ¿Diferencia entre pack y grid? pack apila widgets en un único eje según el orden en que se agregan (flujo tipo pila); grid los posiciona en filas y columnas explícitas, como una tabla — da control más preciso para diseños en 2D, a costa de tener que declarar row/column en cada widget.

🗂️ Frames (CTkFrame) — agrupar y organizar widgets

Un CTkFrame es un contenedor invisible (una "caja") que vive dentro de la ventana y que a su vez puede contener otros widgets. Sirve para:

  • Agrupar visualmente una sección (p. ej. un formulario, una barra de botones).
  • Aplicar un layout distinto en cada sección (un frame con .grid() adentro de una ventana organizada con .pack(), por ejemplo) sin que entren en conflicto.
┌───────────────────────────────────────────┐
│  ventana (ctk.CTk)                         │
│  ┌───────────────────────────────────────┐ │
│  │ frame_formulario (CTkFrame) — .pack()  │ │
│  │  ┌─────────┬─────────┐                 │ │
│  │  │ Label   │ Entry   │  ← acá adentro  │ │
│  │  ├─────────┼─────────┤     se usa      │ │
│  │  │ Label   │ Entry   │     .grid()     │ │
│  │  └─────────┴─────────┘                 │ │
│  └───────────────────────────────────────┘ │
│  ┌───────────────────────────────────────┐ │
│  │ frame_botones (CTkFrame) — .pack()     │ │
│  │   [ Guardar ]   [ Cancelar ]           │ │
│  └───────────────────────────────────────┘ │
└───────────────────────────────────────────┘
python
import customtkinter as ctk

ventana = ctk.CTk()
ventana.title("Formulario")
ventana.geometry("400x300")

# Frame para el formulario, ubicado con pack en la ventana
frame_formulario = ctk.CTkFrame(ventana)
frame_formulario.pack(padx=20, pady=20, fill="x")

# Dentro del frame se usa grid, sin que choque con el pack de afuera
ctk.CTkLabel(frame_formulario, text="Nombre:").grid(row=0, column=0, padx=10, pady=10, sticky="e")
ctk.CTkEntry(frame_formulario).grid(row=0, column=1, padx=10, pady=10, sticky="w")

ctk.CTkLabel(frame_formulario, text="Correo:").grid(row=1, column=0, padx=10, pady=10, sticky="e")
ctk.CTkEntry(frame_formulario).grid(row=1, column=1, padx=10, pady=10, sticky="w")

# Otro frame independiente para los botones, con pack
frame_botones = ctk.CTkFrame(ventana)
frame_botones.pack(pady=10)

ctk.CTkButton(frame_botones, text="Guardar").pack(side="left", padx=5)
ctk.CTkButton(frame_botones, text="Cancelar").pack(side="left", padx=5)

ventana.mainloop()

💡 La regla no es "toda la ventana con un solo layout": es "un solo layout por contenedor". Cada CTkFrame es su propio contenedor, así que puede tener .pack() por fuera y .grid() por dentro (o viceversa) sin problema.

📝 Si venías de tutoriales que meten todo directo en ventana sin frames, funciona para apps chicas, pero en cuanto hay más de una sección (formulario + botones + lista, por ejemplo) conviene usar un CTkFrame por sección — evita pelear con sticky/padx para simular agrupaciones que un frame resuelve solo.

⌨️ CTkEntry — campo de texto que escribe el usuario

Un CTkEntry es una caja donde el usuario escribe. La app lee lo escrito con .get() y puede vaciarlo con .delete(). Es la base de cualquier formulario o campo de entrada.

python
entrada = ctk.CTkEntry(ventana, placeholder_text="Escribe una tarea...")
entrada.pack(side="left", fill="x", expand=True, padx=10)

texto = entrada.get()        # LEE lo que escribió el usuario (devuelve "" si está vacío)
entrada.delete(0, "end")     # VACÍA el campo (borra del carácter 0 hasta el final)
PiezaQué hace
placeholder_text="..."Texto gris de ayuda que se ve cuando el campo está vacío
.get()Devuelve el texto actual como str (vacío = "", que es falsy)
.delete(0, "end")Borra desde la posición 0 hasta el final → deja el campo vacío
fill="x", expand=TrueEn .pack(), hace que el Entry ocupe todo el ancho disponible

💡 Combínalo con truthy/falsy para validar: if not entrada.get(): return corta la función si el campo quedó vacío. → ver truthy/falsy en Conceptos.

📜 CTkScrollableFrame — un frame con barra de scroll automática

Es igual que un CTkFrame (un contenedor), pero le aparece sola una barra de scroll cuando el contenido no cabe. Perfecto para listas que crecen (tareas, contactos, mensajes…).

python
frame_lista = ctk.CTkScrollableFrame(ventana, label_text="Mis tareas")
frame_lista.pack(padx=20, pady=10, fill="both", expand=True)   # ocupa el espacio de abajo

ctk.CTkLabel(frame_lista, text="Comprar pan").pack()           # los hijos van adentro
ParámetroQué hace
label_text="..."Título que se muestra arriba del área con scroll
fill="both", expand=TrueEn .pack(), hace que crezca en ancho y alto con la ventana

📌 Se usa igual que un CTkFrame: los widgets se crean pasándole el frame como padre (ctk.CTkLabel(frame_lista, ...)). La barra aparece sola; no hay que configurarla.

🔁 Patrón: "borrar todo y redibujar desde una lista"

El corazón de apps que muestran una colección (lista de tareas, contactos…). En vez de sincronizar la pantalla a mano cada vez que cambia un dato, se borra todo lo dibujado y se vuelve a dibujar desde la lista (la lista es la "verdad").

python
tareas = []

def refrescar_lista():
    # 1) LIMPIAR: destruir todo lo que ya estaba dibujado en el frame
    for widget in frame_lista.winfo_children():
        widget.destroy()
    # 2) REDIBUJAR: crear un widget por cada elemento de la lista
    for tarea in tareas:
        ctk.CTkLabel(frame_lista, text=tarea).pack()
PiezaQué hace
frame.winfo_children()Devuelve la lista de widgets que están dentro de ese contenedor
widget.destroy()Elimina un widget de la pantalla (lo quita de verdad, no lo oculta)
   tareas = ['Pan', 'Python']        cada cambio (agregar/borrar) llama a refrescar_lista()
        │                                            │
        ▼                                            ▼
  ┌──────────────┐   refrescar_lista()   ┌──────────────────────┐
  │  la LISTA    │ ───────────────────▶  │  1. destruir hijos   │
  │ (la verdad)  │                       │  2. for tarea: dibujar│
  └──────────────┘                       └──────────────────────┘

⚠️ El paso 1 (destruir) es obligatorio: si solo redibujas sin limpiar, las tareas se duplican en pantalla en cada refresco (se apilan las viejas + las nuevas).

💡 Para que cada fila tenga su botón ✕ que borre su tarea, el command del botón usa el truco lambda t=tarea: borrar(t) — sin ese t=tarea todos borrarían la última. → explicado en Closures y late binding.

🧪 Tip de entrevista: ¿Por qué destruir y redibujar todo en vez de actualizar solo lo que cambió? Simplicidad: la pantalla siempre refleja el estado real de la lista sin lógica de sincronización. Para listas chicas es perfecto; con miles de elementos ya conviene actualizar solo lo que cambió (por rendimiento).

📎 Código completo de referencia

🔗 Relacionado