Session 5: Layouts and frames
Phase 5 — customtkinter · Session 5 of 8
What we’re learning today
Your forms are getting long. Every pack() adds another
widget below the last — fine for a few widgets, awkward for
a real app. Today we learn grid() for arranging
widgets in rows and columns, and CTkFrame for grouping
related widgets together. By the end, you’ll have a
multi-section app that looks like a real app, not just
a stack of widgets.
You’ll need to remember from last time
- Widgets — labels, buttons, entries, checkboxes, radios, dropdowns.
pack(pady=10)— the layout method we’ve been using.- f-strings for combining text.
Part A: grid() — rows and columns
Open Thonny. Save a new file as grid_demo.py.
The motivation
pack() is great for one-widget-per-row. But what if you
want two columns — like a label next to an entry?
Today’s first try:
import customtkinter as ctk
app = ctk.CTk()
app.title("Grid demo")
app.geometry("400x250")
# label and entry on the same "row"
name_label = ctk.CTkLabel(app, text="Name:")
name_label.grid(row=0, column=0, padx=10, pady=10)
name_entry = ctk.CTkEntry(app)
name_entry.grid(row=0, column=1, padx=10, pady=10)
age_label = ctk.CTkLabel(app, text="Age:")
age_label.grid(row=1, column=0, padx=10, pady=10)
age_entry = ctk.CTkEntry(app)
age_entry.grid(row=1, column=1, padx=10, pady=10)
button = ctk.CTkButton(app, text="Submit")
button.grid(row=2, column=0, columnspan=2, pady=15)
app.mainloop()
Save. Run.
Two rows of label-and-entry pairs, perfectly aligned in two columns. Then a button below that spans both columns.
What’s new
widget.grid(row=R, column=C, ...)— places the widget at row R, column C. Rows count from 0 (top); columns from 0 (left).padx=10, pady=10— padding around each widget (same as pack).columnspan=2— makes the widget span 2 columns. (rowspan=2does the equivalent for rows.)
grid() and pack() don’t mix in the same parent
container. Pick one per container and stick with it.
Important rule: in any window or frame, use either
pack()orgrid()— not both. Mixing them breaks the layout in confusing ways. (You can use different ones in different frames; we’ll see frames next.)
When you mix them anyway (the bug you’ll write)
This is the one mistake every Python GUI beginner makes.
You start a project with pack(), switch over to grid()
mid-file, and end up with something like:
# DON'T do this — mixed pack and grid in the same window
name_label = ctk.CTkLabel(app, text="Name:")
name_label.pack(pady=5) # ← pack
name_entry = ctk.CTkEntry(app)
name_entry.grid(row=1, column=1) # ← grid
Run that. The window hangs. It looks like Python is
frozen — it’s not. Behind the scenes, customtkinter’s layout
engine is in an infinite back-and-forth: pack resizes the
window, grid resizes it differently, repeat forever. You
hit the red Stop button to escape.
Fix: pick one geometry manager per container. To use
grid for the form, change every pack(...) in that
window to a grid(row=..., column=...):
name_label = ctk.CTkLabel(app, text="Name:")
name_label.grid(row=0, column=0, padx=5, pady=5) # ← grid
name_entry = ctk.CTkEntry(app)
name_entry.grid(row=1, column=1, padx=5, pady=5) # ← grid
If your window ever hangs as soon as it opens, this is the first thing to check.
A bigger grid
Try a 3-column layout:
import customtkinter as ctk
app = ctk.CTk()
app.title("3-column grid")
app.geometry("500x300")
# header row spans all 3 columns
header = ctk.CTkLabel(app, text="My App", font=("Arial", 24, "bold"))
header.grid(row=0, column=0, columnspan=3, pady=10)
# row of labels
ctk.CTkLabel(app, text="Name").grid(row=1, column=0, padx=10)
ctk.CTkLabel(app, text="Age").grid(row=1, column=1, padx=10)
ctk.CTkLabel(app, text="Color").grid(row=1, column=2, padx=10)
# row of entries
ctk.CTkEntry(app).grid(row=2, column=0, padx=10, pady=5)
ctk.CTkEntry(app).grid(row=2, column=1, padx=10, pady=5)
ctk.CTkEntry(app).grid(row=2, column=2, padx=10, pady=5)
# submit button across the bottom
button = ctk.CTkButton(app, text="Submit")
button.grid(row=3, column=0, columnspan=3, pady=15)
app.mainloop()
Run. Three columns of input. Way more compact than single-column pack.
Notice the inline pattern: ctk.CTkLabel(app, text="..."). grid(...). We didn’t save the widget in a variable because
we don’t need to read or modify it later. Saves typing.
sticky — alignment within a cell
Each grid cell has a fixed size. Inside it, a widget can
align to any side. sticky="w" (west = left), "e" (east),
"n" (north), "s" (south), or combinations like "ew"
(stretches across).
ctk.CTkLabel(app, text="Right-aligned:").grid(row=0, column=0, sticky="e", padx=5)
Useful for aligning labels to the right of their column so they sit next to the entries on the left of the next.
Checkpoint: You’ve built a layout with grid() that
has at least two columns and at least two rows. This is
the natural stop point if class is cut short.
Part B: Frames — grouping widgets
When forms get bigger, they need sections — visual
groupings of related widgets. That’s a CTkFrame.
What a frame is
A CTkFrame is a container widget. It holds other
widgets inside it. You can pack/grid widgets inside a
frame, then pack/grid the frame inside the main window.
Frames give you:
- Visual grouping (you can color the background).
- Layout flexibility (one frame can use grid, another can use pack).
- Easier reorganization (move a frame and everything in it moves together).
A two-section app
import customtkinter as ctk
app = ctk.CTk()
app.title("Two-section app")
app.geometry("600x400")
# Left frame — input
left_frame = ctk.CTkFrame(app)
left_frame.pack(side="left", fill="both", expand=True, padx=10, pady=10)
ctk.CTkLabel(left_frame, text="Input", font=("Arial", 18, "bold")).pack(pady=10)
ctk.CTkEntry(left_frame, placeholder_text="Name").pack(pady=5)
ctk.CTkEntry(left_frame, placeholder_text="Age").pack(pady=5)
ctk.CTkButton(left_frame, text="Submit").pack(pady=10)
# Right frame — output
right_frame = ctk.CTkFrame(app)
right_frame.pack(side="right", fill="both", expand=True, padx=10, pady=10)
ctk.CTkLabel(right_frame, text="Output", font=("Arial", 18, "bold")).pack(pady=10)
ctk.CTkLabel(right_frame, text="(submit something to see results)").pack(pady=5)
app.mainloop()
Save. Run.
Two frames side by side. Left holds inputs; right holds
output. Inside each frame, widgets stack with pack().
What’s new:
CTkFrame(app)— a frame that lives in the main window.pack(side="left", fill="both", expand=True)— packs the frame to the left side; fills available space; expands as the window grows.- Widgets inside the frame are children of the frame, not
of the app. So
ctk.CTkLabel(left_frame, ...)puts the label insideleft_frame, not the main window.
The side="left" and side="right" is pack()’s way of
doing horizontal layout (instead of the default vertical).
You can also use "top" and "bottom".
Inside a frame, use
pack()orgrid()independently of the parent. The “no mixing” rule applies within each container, not across the whole app.
A real layout — header, sidebar, content
import customtkinter as ctk
app = ctk.CTk()
app.title("App with sections")
app.geometry("700x500")
# Header (top)
header = ctk.CTkFrame(app, height=60)
header.pack(side="top", fill="x", padx=5, pady=5)
ctk.CTkLabel(header, text="My App", font=("Arial", 24, "bold")).pack(pady=10)
# Body — sidebar + content
body = ctk.CTkFrame(app)
body.pack(side="top", fill="both", expand=True, padx=5, pady=5)
# Sidebar (left part of body)
sidebar = ctk.CTkFrame(body, width=150)
sidebar.pack(side="left", fill="y", padx=5, pady=5)
ctk.CTkLabel(sidebar, text="Menu", font=("Arial", 14, "bold")).pack(pady=10)
ctk.CTkButton(sidebar, text="Home").pack(pady=5)
ctk.CTkButton(sidebar, text="Settings").pack(pady=5)
ctk.CTkButton(sidebar, text="About").pack(pady=5)
# Content (right part of body)
content = ctk.CTkFrame(body)
content.pack(side="right", fill="both", expand=True, padx=5, pady=5)
ctk.CTkLabel(content, text="Welcome!", font=("Arial", 32)).pack(pady=30)
ctk.CTkLabel(content, text="This is a real-looking app layout.").pack(pady=5)
app.mainloop()
Save. Run.
A header bar across the top. A sidebar on the left with buttons. A main content area on the right. This actually looks like a real app.
Frames within frames. Each one organizes its part of the window.
That’s the base goal — a multi-frame layout that looks like a real app.
Stretch — make the sidebar do something
Add callbacks to the sidebar buttons so they update the content area:
def show_home():
content_label.configure(text="🏠 Home")
def show_settings():
content_label.configure(text="⚙️ Settings")
def show_about():
content_label.configure(text="ℹ️ About this app")
# ...
ctk.CTkButton(sidebar, text="Home", command=show_home).pack(pady=5)
ctk.CTkButton(sidebar, text="Settings", command=show_settings).pack(pady=5)
ctk.CTkButton(sidebar, text="About", command=show_about).pack(pady=5)
# in the content frame:
content_label = ctk.CTkLabel(content, text="🏠 Home", font=("Arial", 32))
content_label.pack(pady=30)
Now clicking a sidebar button changes the content. That’s how every navigation-based app works.
Extension — wrap the whole thing in a class
If you’ve got time, refactor the whole app into a class (callback to Phase 4 Session 4):
import customtkinter as ctk
class MyApp:
def __init__(self):
self.app = ctk.CTk()
self.app.title("My App")
self.app.geometry("700x500")
self._build_header()
self._build_sidebar_and_content()
def _build_header(self):
header = ctk.CTkFrame(self.app, height=60)
header.pack(side="top", fill="x", padx=5, pady=5)
ctk.CTkLabel(header, text="My App", font=("Arial", 24, "bold")).pack(pady=10)
def _build_sidebar_and_content(self):
body = ctk.CTkFrame(self.app)
body.pack(side="top", fill="both", expand=True, padx=5, pady=5)
sidebar = ctk.CTkFrame(body, width=150)
sidebar.pack(side="left", fill="y", padx=5, pady=5)
ctk.CTkButton(sidebar, text="Home", command=self.show_home).pack(pady=5)
ctk.CTkButton(sidebar, text="Settings", command=self.show_settings).pack(pady=5)
content = ctk.CTkFrame(body)
content.pack(side="right", fill="both", expand=True, padx=5, pady=5)
self.content_label = ctk.CTkLabel(content, text="🏠 Home", font=("Arial", 32))
self.content_label.pack(pady=30)
def show_home(self):
self.content_label.configure(text="🏠 Home")
def show_settings(self):
self.content_label.configure(text="⚙️ Settings")
def run(self):
self.app.mainloop()
app = MyApp()
app.run()
Same app, organized as a class. State (self.content_label)
lives on the instance. Methods access it via self. No
global needed. This is how real GUI apps are organized.
Wrap-up
Before we leave, share with the room:
- For the kids who built the two-section app — does side-by-side feel cleaner than stacked?
- For the kids who built the header-sidebar-content layout — does it feel like a real app?
- For the class refactor — does the class version feel more organized, or just longer?
You learned today the organizational toolkit for GUI
apps. grid() for precise rows-and-columns layouts.
CTkFrame for grouping related widgets. Combined,
they let you build interfaces that look like real software
— not just stacked widgets.
Most real apps are built as frames within frames — a header frame, a body frame, a sidebar frame, a content frame. Each one self-contained. Each one easy to rearrange. That’s the structural pattern of every GUI app you’ve ever used.
If you missed this session
Open Thonny. Then:
-
Build the basic 2-column form from Part A using
grid()instead ofpack(). -
Try
columnspanto make a button span multiple columns. -
Build the two-section app with frames from Part B.
-
Build the header-sidebar-content layout (Part B base).
About 50 minutes — this is a long session. If you get stuck, ask your buddy at the start of next class.
Stretch and extension ideas
grid_rowconfigure(weight=1)— makes a row grow when the window resizes. Useful for “expand this row to fill space.”grid_columnconfigure(weight=1)— same for columns.place()— a third layout method that puts widgets at exact pixel positions. Almost never used in practice (windows resize and pixel positions break). Mention as “exists; usually don’t.”- Border styles on frames —
CTkFrame(app, border_width=2, border_color="gray")for visible borders. - Scrollable frames —
CTkScrollableFramefor content that’s bigger than the window.
What’s next
Next week we put it all together — building a complete app using everything from Phase 5 so far. After that, you have one milestone planning session and one demo session, then Phase 5 is done.