CTkKanban
A focused Kanban board for CustomTkinter. Build desktop workflows with schema-driven card data, explicit editing, granular permissions, dependable movement, application-owned persistence, and a typed API.
Installation
Install CTkKanban from PyPI. CustomTkinter is installed automatically as a required dependency.
python -m pip install --upgrade CTkKanBan
CTkKanban requires Python 3.10 or newer. Its package metadata accepts CustomTkinter 5.2.2 or newer below major version 7.
Although the distribution name uses capitals, import the package with its canonical lowercase module name:
import customtkinter as ctk
from ctk_kanban import CTkKanbanBoard
Public helpers can be imported from the same package root:
from ctk_kanban import (
ActionConfig,
BoardConfig,
BoardModel,
BoardModelError,
BoardSnapshot,
Card,
CardRecord,
Column,
ColumnRecord,
DEFAULT_FIELDS,
DEFAULT_THEME,
FieldDefinition,
FieldType,
LayoutConfig,
TextConfig,
merge_config,
merge_theme,
normalize_row,
normalize_rows,
rows_from_cursor,
snapshot_from_cursors,
snapshot_from_rows,
__version__,
)
Release 2.1.0 Highlights
Version 2.1 keeps the focused 2.x board API while adding the flexibility needed by real applications.
| Area | What changed |
|---|---|
| Custom fields | Any number of schema-defined text, numeric, choice, date, boolean, tag, or hidden values now drive validation, the editor, compact cards, and search. |
| Runtime schemas | get_fields() and atomic set_fields() support safe schema inspection and replacement. |
| Configuration | Structured action permissions, layout settings, customizable text, and delete-confirmation policy replace scattered flags. |
| Deletion control | Card and column deletion can be disabled independently, including protection against bypass through a non-empty column cascade. |
| Appearance | The closed theme surface now covers 97 color, typography, spacing, geometry, limit, scrollbar, menu, and motion tokens. |
| Data integration | Arbitrary card metadata round-trips safely and row/cursor snapshot adapters validate against the active field schema. |
| Documentation | The README, browser guide, maintainer runbooks, and automated drift checks now cover the complete public contract. |
The default title, description, priority, and tags behavior remains available when fields and config are omitted. Applications still on 1.x should read Migrating from 1.x because the focused 2.x architecture remains intentionally different.
Quick Start
Create a window, define columns and cards, then pack the board like any other CustomTkinter widget.
import customtkinter as ctk
from ctk_kanban import CTkKanbanBoard
ctk.set_appearance_mode("System")
app = ctk.CTk()
app.title("Project board")
app.geometry("1100x680")
board = CTkKanbanBoard(
app,
columns=[
{"id": "todo", "title": "To do"},
{"id": "doing", "title": "Doing"},
{"id": "done", "title": "Done"},
],
cards=[
{
"id": 1,
"column": "todo",
"title": "Publish CTkKanban",
"description": "Finish the documentation and release checks.",
"priority": "High",
"tags": ["release", "docs"],
}
],
on_change=lambda event: print(event["type"], event["data"]),
)
board.pack(fill="both", expand=True)
app.mainloop()
The board owns its current in-memory model. Your application owns durable storage. Read snapshots with get_data(), replace them with set_data(), and persist successful changes from on_change.
Tutorial 1: Static Board
Start from static Python data without any storage dependency. Disable dragging and omit persistence callbacks when changes only need to live for the lifetime of the window.
from ctk_kanban import Card, Column, CTkKanbanBoard
columns = [
Column(id="planned", title="Planned"),
Column(id="shipped", title="Shipped"),
]
cards = [
Card(
id="docs",
column="planned",
title="Write documentation",
priority="Medium",
tags=("documentation",),
)
]
board = CTkKanbanBoard(
app,
columns=columns,
cards=cards,
enable_drag=False,
confirm_delete=True,
)
Column and Card instances use the same validation as dictionaries. You can freely mix typed instances and mapping records in an input iterable.
Tutorial 2: Board Interaction
CTkKanban makes destructive and mutating actions visible and deliberate. Use config["actions"] or allow_card_deletion=False to remove operations that a particular application must not expose:
- Click a card body or its Edit action to select it and open the editor drawer.
- Save with Save changes, Enter outside a multiline textbox, or Ctrl+Enter anywhere in the editor.
- Press Escape or choose Cancel to close the drawer without saving.
- Drag from the handle in the card's upper-right corner. A six-pixel threshold prevents accidental drags.
- Use card menus to move up, move down, move between columns, edit, or delete.
- Use column menus to rename, move left, move right, or delete a column.
Dragging and positional menu actions are disabled while search is active, because hidden cards make the visible order ambiguous. Clear the search field before reordering cards.
Programmatic actions use the same model and event path as the built-in UI:
board.add_card(
{
"id": 42,
"column": "todo",
"title": "Review pull request",
"priority": "High",
"tags": ["code-review"],
},
index=0,
)
board.update_card(42, {"description": "Check tests and typing."})
board.move_card(42, "doing", index=0)
board.delete_card(42)
Tutorial 3: Save Changes
The on_change callback runs once after each successful mutation. Every event contains type, before, and the latest complete snapshot in data, plus the affected record.
import json
from pathlib import Path
BOARD_FILE = Path("board.json")
def save_change(event):
# Suitable for small local files. Avoid slow network or SQL work here.
BOARD_FILE.write_text(
json.dumps(event["data"], indent=2),
encoding="utf-8",
)
board = CTkKanbanBoard(
app,
columns=initial_data["columns"],
cards=initial_data["cards"],
on_change=save_change,
)
on_change runs on Tk's UI thread. For PostgreSQL, HTTP APIs, or any slow storage, enqueue the event for a worker instead of blocking inside the callback. Tk widgets must still only be accessed from Tk's thread.
Tutorial 4: Complete PostgreSQL App
This example uses Psycopg 3 mapping rows, SQL aliases that match CTkKanban's schema, and load_async() so database work does not freeze the desktop window.
Install Psycopg separately; CTkKanban intentionally does not require a database driver:
python -m pip install "psycopg[binary]"
Example schema
CREATE TABLE kanban_columns (
id text PRIMARY KEY,
title text NOT NULL,
position integer NOT NULL
);
CREATE TABLE kanban_cards (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
column_id text NOT NULL REFERENCES kanban_columns(id),
title text NOT NULL,
description text NOT NULL DEFAULT '',
priority text NOT NULL DEFAULT '',
tags text[] NOT NULL DEFAULT '{}',
position integer NOT NULL,
CHECK (priority IN ('', 'Low', 'Medium', 'High', 'Critical'))
);
Fetch and display
import os
import customtkinter as ctk
import psycopg
from psycopg.rows import dict_row
from ctk_kanban import CTkKanbanBoard, snapshot_from_rows
DATABASE_URL = os.environ["DATABASE_URL"]
COLUMN_QUERY = """
SELECT id, title
FROM kanban_columns
ORDER BY position
"""
CARD_QUERY = """
SELECT
id,
column_id AS column,
title,
description,
priority,
tags
FROM kanban_cards
ORDER BY column_id, position
"""
def fetch_board():
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as connection:
columns = connection.execute(COLUMN_QUERY).fetchall()
cards = connection.execute(CARD_QUERY).fetchall()
return snapshot_from_rows(columns, cards)
app = ctk.CTk()
app.geometry("1200x720")
status = ctk.CTkLabel(app, text="Connecting…")
status.pack(fill="x", padx=16, pady=(12, 0))
board = CTkKanbanBoard(app)
board.pack(fill="both", expand=True)
board.load_async(
fetch_board,
on_success=lambda data: status.configure(
text=f"Loaded {len(data['cards'])} cards"
),
on_error=lambda error: status.configure(text=f"Load failed: {error}"),
)
app.mainloop()
The database uses column_id, while serialized card snapshots use column. column_id AS column produces the exact output key. Input accepts either spelling, but snapshots always return column.
For plain tuple cursors, use rows_from_cursor() after each query. For writes, use stable IDs and positions, handle transactions in your repository layer, and feed the resulting canonical snapshot back through set_data() if the server changes any values.
Board Data Schema
A board snapshot is a mapping with exactly two keys:
{
"columns": [
{"id": "todo", "title": "To do"},
{"id": "done", "title": "Done"},
],
"cards": [
{
"id": 1,
"column": "todo",
"title": "Write release notes",
"description": "Summarize version 2.",
"priority": "Medium",
"tags": ["release"],
}
],
}
Unknown top-level snapshot keys and unknown column keys are rejected. Card mappings are intentionally extensible: schema-defined keys are normalized and other top-level card keys are deep-copied for round-tripping. Loading is atomic, so any invalid column, card, or relationship leaves the current model unchanged.
Column Records
| Field | Type | Required | Description |
|---|---|---|---|
id | str | int | Yes | Unique column identifier. Blank strings and booleans are rejected. |
title | str | Yes | Visible heading. Leading and trailing whitespace is removed; the result must not be blank. |
Column list order is the board's manual left-to-right order.
Card Records
| Field | Type | Required | Description |
|---|---|---|---|
id | str | int | Yes | Unique card identifier. Blank strings and booleans are rejected. |
column | str | int | Yes | ID of an existing column. column_id is accepted as an input alias. |
title | str | Yes | Nonblank card heading, trimmed during normalization. |
description | str | No | Plain text details. Defaults to an empty string. |
priority | str | No | One of empty, Low, Medium, High, or Critical. |
tags | Iterable[str] | No | Trimmed nonblank strings without commas. Snapshots return a new list. |
The fields above are the default schema. A custom schema may add any number of top-level values, while undeclared integration values are preserved without being edited, displayed, validated, formatted, or searched. Cards are serialized in column order and then in their manual top-to-bottom order. IDs should be stable database keys; use strings or integers when snapshots will be JSON encoded.
Card Field Schema
Pass fields to the board, model, or snapshot adapters to describe any number of card data points. One ordered definition controls normalization, generated editor controls, drawer grouping, local search, compact-card placement, custom validation, and display formatting.
fields = [
{
"key": "title",
"label": "Title",
"type": "text",
"required": True,
"searchable": True,
"show_on_card": True,
"card_role": "title",
},
{
"key": "estimate",
"label": "Estimate",
"type": "integer",
"min": 0,
"max": 100,
"section": "Planning",
"show_on_card": True,
"card_role": "metadata",
"formatter": lambda value, _card: (
"" if value is None else f"{value} pts"
),
},
{
"key": "stage",
"label": "Stage",
"type": "select",
"options": ["Discovery", "Delivery", "Review"],
"show_on_card": True,
"card_role": "badge",
"colors": {"Delivery": ("#DCFCE7", "#14532D")},
},
]
board = CTkKanbanBoard(
app, columns=columns, cards=cards, fields=fields
)
Supported types
| Type | Editor control | Normalized value |
|---|---|---|
text | Single-line entry | Trimmed str; None becomes empty text. |
textarea | Multiline textbox | Trimmed str; None becomes empty text. |
number | Entry | float | None; booleans are rejected. |
integer | Entry | int | None; fractional float input is rejected. |
select | Option menu | One value; non-empty options are enforced. |
multiselect | Add/remove pills | Deduplicated list; non-empty options are enforced on save. |
date | Entry | ISO YYYY-MM-DD or empty text; accepts date input. |
datetime | Entry | ISO date-time or empty text; accepts datetime and trailing Z input. |
checkbox | Checkbox | Strict bool. |
tags | Add/remove tag pills | Deduplicated list[str]; trimmed, nonblank, comma-free items. |
hidden | Hidden by default | Deep-copied application value for schema-controlled data without UI. |
Date and date-time controls are text entries and validate on save. Multiselect is a compact add/remove input rather than a large checklist. Supply options for useful select editor choices; an empty sequence only means programmatic validation is unrestricted.
Definition options
Each mapping requires a unique, nonblank key and label. Unknown options are rejected.
| Option | Default | Meaning |
|---|---|---|
type | "text" | One of the eleven supported type strings. |
required | False | Reject None, empty text, and empty lists. |
default | Type-dependent | Deep-copied when a configured key is absent. New controls use list [], checkbox False, numeric None, or empty text when no explicit default exists. |
placeholder | Empty | Entry hint; list inputs fall back to “Add a value”. |
options | () | Allowed sequence for select/multiselect. Empty means no model-level restriction. |
show_on_card | Role-dependent | Render on compact cards; true unless the inferred role is hidden. |
show_in_editor | Type-dependent | Generate a drawer control; false by default only for hidden fields. |
searchable | False | Include the field in case-insensitive substring search. |
read_only | False | Disable the generated control while retaining its value. |
section | "Details" | Drawer group; groups follow first appearance. Column selection lives in Organisation. |
card_role | metadata or hidden | Compact-card presentation role. |
help_text | Empty | Supporting copy; for checkbox controls it becomes the checkbox label. |
min, max | Unset | Inclusive numeric limits. |
min_length, max_length | Unset | Inclusive string character or list item limits. |
validator | Unset | (value, card) -> bool | str | None. False produces a generic error; a string is the error message. |
formatter | Unset | (value, card) -> str for compact display only; storage is unchanged. |
colors | {} | Exact value-to-color mapping for badge/accent or metadata pills. |
min cannot exceed max; length bounds are nonnegative and ordered. Validators run in field order during construction, loading, updates, editor save, and schema replacement, so keep them fast and side-effect free. A required read-only field should define a default so new cards can be created.
Compact-card roles
| Role | Presentation |
|---|---|
title | Main heading. Exactly one definition uses this role and its key must be title. |
body | Wrapped body line, truncated by card_description_max_chars. Multiple body fields are allowed. |
badge | Colored pill; the first non-empty visible badge also colors the accent strip. |
tags | One #value pill per item, capped per field by card_max_visible_tags. |
metadata | Pill formatted as Label: value. |
hidden | No compact-card output. |
Only show_on_card=True fields render, and empty values are omitted. A field colors match takes precedence; the built-in priority field otherwise uses priority theme tokens. formatter affects display text only.
Structural rules and runtime replacement
id,column, andcolumn_idare reserved and cannot be field keys.- If
titleis omitted, the default title definition is inserted first. - Title must be text or textarea and is forced to required, visible-on-card, and the sole title role.
- Fields omitted from a loaded card are only materialized when they have an explicit default or are required; a new editor presents type defaults.
- Undeclared card keys are preserved as private application metadata.
get_fields() returns detached normalized definitions. set_fields(fields) validates every existing card before committing, rebuilds cards and an open drawer, and leaves the old schema/data untouched on failure. It emits one fields_changed event only when normalization actually changes stored card data. Treat changes that add defaults, coerce types, or narrow choices as data migrations.
Snapshots
get_data() returns detached dictionaries and lists. Mutating the returned value cannot accidentally mutate the live board.
snapshot = board.get_data()
snapshot["cards"].append({...}) # Does not change the widget.
board.set_data(snapshot) # Validates, replaces, and redraws.
set_data() does not emit on_change. This prevents a database refresh from being mistaken for a user edit and written straight back to storage.
Copy, replacement, and ordering guarantees
get_data(), modelsnapshot(), record getters, schema getters, and mutation return values are detached copies.set_data()validates the complete candidate first. Use{"columns": [], "cards": []}to clear the board.- Column order is left-to-right. Card order is top-to-bottom inside each column; global card output is grouped by current column order.
- Insertion indices are zero-based and may range from zero through the destination size, inclusive.
Noneappends. update_card()merges values rather than replacing the record. Changing its column appends;move_card()supports an exact position.- String and integer IDs are not coerced:
1and"1"are distinct. A card reference must have the same type as its column ID. - Input
column_idis normalized tocolumnin every output. Conflicting aliases are rejected.
Database Row Sources
CTkKanban includes small conversion helpers without taking a dependency on PostgreSQL, SQLite, SQLAlchemy, or any other database library.
| Row source | Recommended helper | Notes |
|---|---|---|
Psycopg dict_row | snapshot_from_rows() | Rows already implement the mapping protocol. |
sqlite3.Row | snapshot_from_rows() | Converted through its keys() interface. |
SQLAlchemy Row | snapshot_from_rows() | Converted through row._mapping. |
| Plain DB-API tuples | rows_from_cursor() | Uses cursor.description for column names and consumes remaining rows. |
One reusable DB-API cursor
from ctk_kanban import rows_from_cursor, snapshot_from_rows
cursor.execute("""
SELECT id, title
FROM kanban_columns
ORDER BY position
""")
columns = rows_from_cursor(cursor)
cursor.execute("""
SELECT id, column_id AS column, title,
description, priority, tags
FROM kanban_cards
ORDER BY column_id, position
""")
cards = rows_from_cursor(cursor)
board.set_data(snapshot_from_rows(columns, cards, fields=fields))
Two executed cursors
from ctk_kanban import snapshot_from_cursors
snapshot = snapshot_from_cursors(columns_cursor, cards_cursor)
board.set_data(snapshot)
Result column names must be unique, so use SQL aliases for joins. A cursor must contain a SELECT result. PostgreSQL array columns work naturally for tags; JSON text must be decoded into a list before validation.
Feature: Editor Drawer
The built-in editor is an inspector-style drawer attached to the right side of the board. It keeps the board visible, reserves layout space at high display scaling, and only changes data after an explicit save. Its controls are generated from the active field schema.
| Control | Behavior |
|---|---|
| Text and textarea | Single-line or multiline text with required and length validation. |
| Number and integer | Typed numeric values with optional minimum and maximum constraints. |
| Select and multiselect | Configured choices, including optional empty selection. |
| Checkbox | Boolean values. |
| Date and datetime | ISO values; date inputs use YYYY-MM-DD. |
| Hidden | No control by default; the model can still retain and validate the value. |
| Column | Moves the card when a different destination is saved. |
| Tags | Add with Enter and remove from the visible tag pills. |
fields = [
{"key": "title", "label": "Title", "type": "text",
"required": True, "card_role": "title", "show_on_card": True},
{"key": "client", "label": "Client", "type": "text",
"searchable": True, "card_role": "metadata", "show_on_card": True},
{"key": "estimate", "label": "Estimate", "type": "integer", "min": 0},
{"key": "blocked", "label": "Blocked", "type": "checkbox"},
{"key": "due_date", "label": "Due date", "type": "date"},
]
board = CTkKanbanBoard(app, columns=columns, cards=cards, fields=fields)
Keyboard controls
| Key | Action |
|---|---|
Enter | Saves while focus is inside the editor, except when focus is in any multiline textbox. |
Ctrl+Enter | Saves from any field, including multiline textboxes. |
Escape | Closes the drawer without applying unsaved changes. |
Open the drawer directly when a surrounding application has its own buttons or shortcuts:
board.open_add_card_editor("todo")
board.open_edit_card_editor(card_id=17)
board.open_add_column_dialog()
Feature: Search
The toolbar search performs a case-insensitive substring match across every field marked searchable=True. The default schema searches title, description, priority, and every tag. Search filters only the rendered view; the underlying data and column counts stay intact.
board.search("critical")
board.search("database")
board.search("") # Clear the filter.
- Search does not emit a change event.
- The toolbar summary shows visible results and total cards.
- Empty columns display No matching cards.
- Drag and positional movement are disabled until search is cleared.
Feature: Selection
Clicking a card selects it and applies the configured selected border color. Selection is board-local and exposes a detached card record:
selected = board.get_selected_card()
if selected is not None:
print(selected["id"], selected["title"])
Deleting the selected card, or replacing the snapshot without that card, clears selection automatically. Selection itself is presentation state and does not emit on_change.
Feature: Dragging & Menus
Cards can be reordered within a column or moved between columns. The handle-only drag design preserves normal clicking and text interaction across the rest of the card.
| Action | Result | Event |
|---|---|---|
| Drag card handle | Moves to the indicated column and insertion position. | card_moved |
| Card menu: Move up/down | Changes top-to-bottom order in the current column. | card_moved |
| Card menu: Move to column | Appends to the chosen destination. | card_moved |
| Column menu: Move left/right | Changes the column's manual position. | column_moved |
Set enable_drag=False to remove card drag behavior while retaining movement through menus and the public API.
Feature: Change Events
Pass one on_change callback to observe every successful board mutation. Callback exceptions are logged under ctk_kanban and do not undo the in-memory change.
def board_changed(event):
print("Operation:", event["type"])
print("Before:", event["before"])
print("Current:", event["data"])
if "card" in event:
print("Affected card:", event["card"])
if "column" in event:
print("Affected column:", event["column"])
board = CTkKanbanBoard(app, on_change=board_changed)
| Event type | Additional keys | Triggered by |
|---|---|---|
card_added | card | add_card() or the add-card editor. |
card_updated | card, previous | A changed update_card() operation or editor save. |
card_deleted | card | delete_card() or confirmed menu deletion. |
fields_changed | fields | set_fields() when type/default normalization changes stored card values. |
card_moved | card, previous | Drag, menu movement, or move_card(). |
column_added | column | add_column() or the add-column dialog. |
column_updated | column, previous | A changed rename operation. |
column_deleted | column | delete_column() or confirmed menu deletion. |
column_moved | column | Menu movement or move_column(). |
All events also contain detached before and data snapshots. A column cascade exposes removed cards through before. No event is sent for a no-op update/move, a schema replacement that leaves stored values identical, selection, search, rendering, loading presentation, or set_data().
The board has already changed when on_change runs. Callback exceptions are logged and do not roll back automatically. Keep slow SQL/network work off the Tk thread; enqueue event["data"] to an application worker. If your policy requires immediate rollback, restore the detached event["before"] with set_data().
Feature: External Card Editor
Pass on_card_open when the host application owns card editing. The callback receives a detached card dictionary and replaces the built-in editor for existing cards.
def open_application_editor(card):
details_panel.show(card)
board = CTkKanbanBoard(
app,
columns=columns,
cards=cards,
on_card_open=open_application_editor,
)
# When your editor saves:
board.update_card(card["id"], validated_updates)
The callback is for opening an existing card. open_add_card_editor() continues to use CTkKanban's built-in add drawer.
Feature: Loading State & Async Loading
set_loading(True) updates the toolbar summary and disables toolbar inputs without changing board data. load_async() combines that state with safe background fetching and Tk-thread delivery.
thread = board.load_async(
fetch_board,
on_success=lambda snapshot: print(
f"Loaded {len(snapshot['cards'])} cards"
),
on_error=lambda error: print("Could not load:", error),
clear_on_error=False,
)
print(board.is_loading) # True while the request is pending.
print(board.load_error) # None, or the most recent async exception.
fetch_boardruns on a daemon worker thread and must return a board snapshot mapping.- The full snapshot is validated on the worker before it reaches the widget.
set_data(),on_success, andon_errorrun on Tk's thread.- Existing data stays visible after failure unless
clear_on_error=True. - Starting a newer load invalidates an older pending result so stale data cannot overwrite the newer request.
- A new request clears
load_error. A failed request stores the original exception beforeon_error. - Success applies data through
set_data(), so loading does not emiton_change. - Invalidating an old result does not forcibly stop its Python worker; it only prevents delivery.
- The returned
threading.Threadcan be inspected or joined by non-UI test code, but do not join it from Tk's UI thread.
The fetch callback may use database connections and pure Python conversion helpers. It must not read or configure Tk widgets. Use the success or error callback for UI updates.
Manual set_loading(True) only changes toolbar presentation. It does not create a worker, prevent API mutations, or alter data. Destroying the board invalidates pending delivery; attempting a new load after destruction raises RuntimeError.
Feature: Configuration & Permissions
config accepts a BoardConfig or a nested mapping. It keeps action availability, layout, user-facing labels, and confirmation policy separate from visual theme tokens.
board = CTkKanbanBoard(
app,
columns=columns,
cards=cards,
config={
"actions": {
"delete_cards": False,
"delete_columns": False,
"move_columns": False,
},
"layout": {
"show_toolbar": True,
"column_width": 340,
"column_height": 560,
"editor_width": 480,
},
"text": {
"board_title": "Release planning",
"add_card": "+ New work item",
},
"confirm_delete": True,
},
)
Nested mappings may be partial. Dataclass defaults fill omitted values, while unknown names are rejected. The equivalent typed form uses frozen ActionConfig, LayoutConfig, TextConfig, and BoardConfig instances. merge_config() validates either representation.
Action permissions
Every action defaults to enabled.
| Setting | Built-in UI | Protected board API |
|---|---|---|
add_cards | Add-card buttons and drawer opening | add_card() |
edit_cards | Card opening/edit commands, including on_card_open | update_card() |
move_cards | Drag handle, card move menus, and drawer column changes | move_card(), plus column-changing update_card() |
delete_cards | Card delete command | delete_card() and non-empty column cascades |
add_columns | Add-column controls/dialog | add_column() |
edit_columns | Rename command | update_column() |
move_columns | Move-left/right commands | move_column() |
delete_columns | Column delete command | delete_column() |
A disabled public mutation raises BoardModelError; a disabled UI-opening helper simply returns. Disabling card deletion also blocks delete_column(..., delete_cards=True) when cards are present, so a cascade cannot bypass the policy.
Permissions do not restrict set_data(), set_fields(), or direct board.model calls. BoardModel has no action policy. Keep real authorization in the host application and expose the board API rather than its model when UI policy matters.
Layout and confirmation
| Setting | Default | Contract |
|---|---|---|
show_toolbar | True | Show title, summary, search, and add controls. Programmatic search() still works when hidden. |
enable_drag | True | Enable handle dragging only when move_cards is also true. Menus/API remain available when only drag is off. |
column_width | 320 | Integer at least 220. |
column_height | 500 | Integer at least 240. |
editor_width | 420 | Integer at least 320. |
confirm_delete | True | Prompt only for built-in menu deletions. Direct deletion methods never prompt. |
Customizable text
| Setting | Default |
|---|---|
board_title | "Board" |
search_placeholder | "Search cards…" |
add_card | "+ Add card" |
add_column | "Add column" |
no_columns | "No columns yet" |
no_columns_help | "Create a column to start planning" |
no_cards | "No cards yet" |
no_cards_help | "Add a card to get started" |
no_results | "No results" |
no_results_help | "Try another search" |
All text settings must be strings. Short mechanical labels inside menus and the editor are not currently configurable.
Override precedence
Direct constructor values override structured config only when explicitly supplied: show_toolbar, enable_drag, all three dimensions, confirm_delete, and board_title. allow_card_deletion and allow_column_deletion override the corresponding action settings. Remaining keyword arguments are forwarded to the outer CTkFrame.
Feature: Styling & Themes
CTkKanban derives its default surfaces, text, inputs, buttons, hover states, and scrollbars from the active CustomTkinter theme. Set appearance and color theme before creating the board:
import customtkinter as ctk
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("green")
board = CTkKanbanBoard(app, columns=columns, cards=cards)
Override only the tokens you need. A string fixes one color in both modes; a (light, dark) pair follows appearance mode.
purple_theme = {
"accent_color": ("#7C3AED", "#A78BFA"),
"selected_border_color": ("#7C3AED", "#A78BFA"),
"drop_indicator_color": ("#7C3AED", "#A78BFA"),
"column_accent_colors": (
("#2563EB", "#60A5FA"),
("#7C3AED", "#A78BFA"),
("#C11574", "#F472B6"),
),
"card_border_color": ("#CBD5E1", "#334155"),
}
board = CTkKanbanBoard(
app,
columns=columns,
cards=cards,
theme=purple_theme,
corner_radius=14, # Normal CTkFrame option for the board itself.
)
Theme tokens
| Token | Controls |
|---|---|
board_fg_color | Root board surface. |
toolbar_fg_color | Toolbar surface. |
column_fg_color | Column and empty-board surfaces. |
column_header_fg_color | Column header background. |
column_border_color | Column outlines and secondary controls. |
column_accent_colors | Cycled accent palette for column headers. |
card_fg_color | Card surface. |
card_hover_color | Card hover surface. |
dragging_card_fg_color | Card surface during dragging. |
card_border_color | Normal card outline. |
selected_border_color | Selected card outline. |
drop_indicator_color | Drag destination indicator. |
text_color | Primary text. |
muted_text_color | Descriptions, summaries, and placeholders. |
accent_color | Primary actions and accents. |
control_hover_color | Menu-like and secondary control hover state. |
count_fg_color | Column count badge surface. |
empty_icon_fg_color | Empty-board icon surface. |
editor_fg_color | Inspector drawer surface. |
editor_section_fg_color | Grouped editor section surface. |
divider_color | Editor separators. |
input_border_color | Search and editor input borders. |
scrollbar_color | Scrollbar thumb. |
scrollbar_hover_color | Scrollbar hover state. |
danger_color | Destructive actions and validation emphasis. |
pill_text_color | Priority and tag pill text. |
priority_low_color | Low-priority pill. |
priority_medium_color | Medium-priority pill. |
priority_high_color | High-priority pill. |
priority_critical_color | Critical-priority pill. |
tag_pill_colors | Cycled palette for tag pills. |
card_*_font, editor_*_font | Typography definitions supplied as CTkFont keyword mappings. |
*_corner_radius, *_border_width | Card, column, editor-section, input, and pill geometry. |
column_gap, card_gap, editor padding/gap tokens | Component spacing and density. |
card_description_max_chars, card_max_visible_tags | Compact-card content limits. |
input_height, textbox_height, scrollbar_width | Control dimensions. |
Complete 97-token index
DEFAULT_THEME exposes the exact current defaults. This index lists every accepted name so a misspelling never has to be discovered by trial and error.
| Area | All accepted tokens |
|---|---|
| Board surfaces | board_fg_color, toolbar_fg_color, column_fg_color, column_header_fg_color, column_border_color, column_accent_colors |
| Cards and drag | card_fg_color, card_hover_color, dragging_card_fg_color, card_border_color, selected_border_color, drop_indicator_color |
| Shared colors | text_color, muted_text_color, accent_color, control_hover_color, count_fg_color, empty_icon_fg_color, divider_color, danger_color |
| Editor/input/scroll colors | editor_fg_color, editor_section_fg_color, input_border_color, scrollbar_color, scrollbar_hover_color, error_text_color |
| Pills and priorities | pill_text_color, priority_low_color, priority_medium_color, priority_high_color, priority_critical_color, tag_pill_colors |
| Native menu colors | menu_fg_color, menu_text_color, menu_hover_color, menu_disabled_text_color |
| Board/toolbar geometry | board_padding_x, board_padding_y, toolbar_height, toolbar_corner_radius, toolbar_padding_x, toolbar_padding_y, toolbar_content_padding_y, search_width, button_height, control_corner_radius, small_control_size |
| Toolbar fonts | toolbar_title_font, toolbar_summary_font |
| Column geometry | column_corner_radius, column_border_width, column_gap, column_accent_height, column_header_padding_x, card_gap |
| Column fonts | column_title_font, column_count_font, column_empty_title_font, column_empty_body_font |
| Card geometry and limits | card_corner_radius, card_border_width, card_selected_border_width, card_accent_width, card_description_max_chars, card_max_visible_tags, pill_height, pill_corner_radius |
| Card fonts | card_title_font, card_body_font, card_metadata_font, pill_font |
| Editor layout | editor_border_width, editor_header_padding_x, editor_header_padding_y, editor_form_padding_x, editor_form_padding_y, editor_field_padding_x, editor_field_gap, editor_section_gap, editor_section_corner_radius, editor_section_border_width, editor_section_title_padding_y |
| Editor motion | editor_slide_step, editor_slide_interval_ms |
| Editor fonts | editor_eyebrow_font, editor_title_font, editor_status_font, section_title_font, field_label_font, help_text_font, status_text_font |
| Inputs and scrollbar | input_height, compact_input_height, input_corner_radius, input_border_width, textbox_height, scrollbar_width |
Color tokens accept CustomTkinter color strings or light/dark pairs. Font tokens are mappings passed to ctk.CTkFont(**mapping). Padding tokens may be scalars or the tuples used by the defaults. column_accent_colors and tag_pill_colors must be non-empty sequences because rendering cycles through them.
Unknown theme keys raise ValueError. Configure the complete appearance before constructing the board; changing a theme mapping later does not restyle widgets that already exist.
API: CTkKanbanBoard Constructor
CTkKanbanBoard(
master,
columns=(),
cards=(),
*,
on_change=None,
on_card_open=None,
theme=None,
fields=None,
config=None,
show_toolbar=None,
enable_drag=None,
column_width=None,
column_height=None,
editor_width=None,
confirm_delete=None,
allow_card_deletion=None,
allow_column_deletion=None,
board_title=None,
**kwargs,
)
| Parameter | Type / default | Description |
|---|---|---|
master | CustomTkinter/Tk parent | Parent widget. |
columns | Iterable, () | Initial column mappings or Column instances in display order. |
cards | Iterable, () | Initial card mappings or Card instances in manual order. |
on_change | Callable | None | Receives one event dictionary after each changed mutation. |
on_card_open | Callable | None | Replaces the built-in existing-card editor and receives a detached card record. |
theme | Mapping | None | Partial overrides for known board theme tokens. |
fields | Iterable[FieldDefinition] | None | Typed values used by model validation, the generated editor, compact cards, and search. |
config | BoardConfig | Mapping | None | Structured action, layout, text, and deletion-confirmation configuration. |
show_toolbar, enable_drag | bool | None | Optional direct overrides for the matching layout settings. |
column_width, column_height, editor_width | int | None | Optional direct size overrides. Defaults are 320, 500, and 420. |
confirm_delete | bool | None | Optional override for built-in menu confirmation. |
allow_card_deletion, allow_column_deletion | bool | None | Convenience permission overrides; disabled card deletion also blocks non-empty column cascades. |
board_title | str | None | Optional toolbar title override. |
**kwargs | CTkFrame options | Forwarded to customtkinter.CTkFrame, including size, corner radius, and root frame colors. |
API: Board Methods
Data and rendering
| Method / property | Returns | Description |
|---|---|---|
get_data() | BoardSnapshot | Detached complete board state. |
set_data(data) | None | Atomically validate and replace the board without a change event. |
refresh(preserve_scroll=True) | None | Rebuild structural widgets, optionally retaining horizontal and per-column scroll positions. |
get_card(card_id) | CardRecord | None | Detached card, or None when the ID is unknown. |
get_cards(column_id=None) | list[CardRecord] | All cards in board order, or cards in one existing column. |
get_columns() | list[ColumnRecord] | Detached columns in manual order. |
get_fields() | list[dict] | Detached active field definitions. |
set_fields(fields) | None | Atomically validate and replace the schema, cards, and an open editor; emits fields_changed if normalization changes data. |
get_selected_card() | CardRecord | None | Current selection snapshot. |
search(query) | None | Apply or clear the view-only card search. |
Card mutations
| Method | Returns | Description |
|---|---|---|
add_card(card, *, index=None) | CardRecord | Add at a zero-based position in its column, or append. |
update_card(card_id, updates) | CardRecord | Update editable fields; column or column_id can also move it. |
move_card(card_id, column_id, index=None) | CardRecord | Move to a destination and insertion position, or append. |
delete_card(card_id) | CardRecord | Delete and return the removed record. |
Column mutations
| Method | Returns | Description |
|---|---|---|
add_column(column, *, index=None) | ColumnRecord | Add at a zero-based position, or append. |
update_column(column_id, updates) | ColumnRecord | Rename with an update mapping containing title. |
move_column(column_id, index) | ColumnRecord | Move to a zero-based manual position. |
delete_column(column_id, *, delete_cards=False) | ColumnRecord | Delete an empty column, or explicitly cascade its cards. |
Editors and loading
| Method / property | Returns | Description |
|---|---|---|
open_add_card_editor(column_id=None) | None | Open the add drawer. With no columns, first opens the add-column dialog. |
open_edit_card_editor(card_id) | None | Open the built-in editor or call on_card_open. Unknown IDs are ignored. |
open_add_column_dialog() | None | Prompt for a title and add a UUID-backed column. |
is_loading | bool | Read-only property for pending asynchronous work. |
load_error | Exception | None | Most recent async load exception; reset when a new load starts. |
set_loading(loading) | None | Set loading presentation. Requires an actual boolean. |
load_async(fetch_snapshot, *, on_success=None, on_error=None, clear_on_error=False) | threading.Thread | Fetch and validate off-thread, then deliver through Tk. |
destroy() | None | Invalidate pending delivery, close the drawer/menu, release drag/scroll bindings, and tear down safely; repeated calls are ignored. |
Board mutation methods enforce ActionConfig and raise BoardModelError when disabled. They never show confirmation dialogs; confirm_delete applies only to built-in menu requests. No-op updates and moves return the normalized record without emitting an event.
API: BoardModel
BoardModel contains the same strict data and ordering behavior without importing or creating a Tk window. It is useful in repositories, tests, command-line tools, and service layers.
from ctk_kanban import BoardModel
model = BoardModel(columns=columns, cards=cards, fields=fields)
model.add_column({"id": "blocked", "title": "Blocked"})
model.move_card(card_id=1, column_id="blocked", index=0)
validated_snapshot = model.snapshot()
| Method | Description |
|---|---|
BoardModel(columns=(), cards=(), fields=None) | Construct and validate initial state with the default or supplied schema. |
snapshot() | Return detached serializable state. |
load(snapshot) | Atomically replace from a complete snapshot. |
load(columns=..., cards=...) | Atomically replace from explicit iterables. |
get_card(id) | Return a record; unlike the widget helper, an unknown ID raises BoardModelError. |
get_cards(column_id=None) | Return ordered card records. |
get_columns() | Return ordered column records. |
get_fields(), set_fields(fields) | Inspect or atomically replace field definitions. |
add_card(), update_card(), move_card(), delete_card() | Mutate cards with validation and manual ordering. |
reorder_card(card_id, index) | Move within the card's current column. |
add_column(), update_column(), move_column(), delete_column() | Mutate columns. Model update_column() also accepts a title string directly. |
clear() | Remove every column and card. |
update_card(card_id, updates=None, **changes) and update_column(column_id, updates=None, **changes) accept keyword changes in addition to a mapping; later keyword values win. Model update_column() also accepts a title string. load(data) requires exactly columns and cards, while load(columns=..., cards=...) is the explicit alternative; the two forms cannot be mixed.
The model returns detached records/snapshots, replaces data and schemas atomically, has no BoardConfig permissions, and emits no widget events. Invalid records, unknown IDs, duplicate IDs, out-of-range indices, protected nonempty column deletion, and unsupported updates raise BoardModelError, a subclass of ValueError.
API: Records & Types
| Name | Kind | Purpose |
|---|---|---|
Column | Frozen dataclass | Typed input with id and title. |
Card | Frozen dataclass | Typed input; stores tags as tuple[str, ...]. |
ColumnRecord | TypedDict | Serializable column output shape. |
CardRecord | dict[str, Any] | Serializable flat card output with structural and arbitrary custom keys. |
FieldDefinition | TypedDict | Editor, validation, search, and compact-display configuration for one value. |
FieldType | Literal union | All supported field type strings. |
DEFAULT_FIELDS | Tuple of mappings | Default title, description, priority, and tags definitions. |
BoardSnapshot | TypedDict | Complete columns and cards output shape. |
BoardModelError | Exception | Validation and model-operation failure. |
ActionConfig | Frozen dataclass | Eight board mutation/action switches. |
LayoutConfig | Frozen dataclass | Toolbar, drag, and board/editor dimensions. |
TextConfig | Frozen dataclass | Stable application-facing board labels. |
BoardConfig | Frozen dataclass | Actions, layout, text, and delete-confirmation policy. |
DEFAULT_THEME | Dictionary | Import-time snapshot of all theme token defaults. |
__version__ | String | Installed CTkKanban package version. |
Definition helpers
from_definition() exposes model normalization without building an entire board:
column = Column.from_definition(
{"id": "todo", "title": " To do "}
)
card = Card.from_definition(
{
"id": 7,
"column_id": "todo",
"title": " Normalize me ",
"tags": [" docs ", "release"],
}
)
assert column.title == "To do"
assert card.column == "todo"
assert card.tags == ("docs", "release")
Card is intentionally a compatibility dataclass for the four default fields and cannot retain arbitrary schema keys. Use mapping records through BoardModel(fields=...) when custom values must survive normalization.
API: Data Adapters
| Function | Returns | Description |
|---|---|---|
normalize_row(row) | dict[str, Any] | Convert a mapping, SQLAlchemy-style _mapping row, or keys/index row into a plain dictionary. |
normalize_rows(rows) | list[dict[str, Any]] | Normalize an iterable of supported row objects. |
rows_from_cursor(cursor) | list[dict[str, Any]] | Consume a DB-API cursor and combine tuples with names from cursor.description. |
snapshot_from_rows(columns, cards, *, fields=None) | BoardSnapshot | Normalize and validate both row collections against the default or supplied schema. |
snapshot_from_cursors(columns_cursor, cards_cursor, *, fields=None) | BoardSnapshot | Consume two executed cursors and validate the complete board against the schema. |
rows_from_cursor() calls fetchall(), so it consumes all remaining result rows. It raises ValueError when there is no result metadata or when output column names are duplicated.
Adapters do not execute SQL, commit, close cursors, or own connections. Pass the same field schema as the board, and convert driver-specific JSON/array values in the repository layer when they are not already the expected Python values.
API: Theme Helpers
| Name | Description |
|---|---|
DEFAULT_THEME | A public dictionary snapshot of all discoverable token names and the CustomTkinter-derived defaults at import time. |
merge_theme(overrides=None) | Build a fresh default mapping from the currently active CustomTkinter color theme, validate override names, and return the merged dictionary. |
from ctk_kanban import DEFAULT_THEME, merge_theme
print(sorted(DEFAULT_THEME))
theme = merge_theme({
"accent_color": ("#2563EB", "#60A5FA"),
"danger_color": ("#B42318", "#F97066"),
})
merge_theme() deep-copies overrides where possible and rejects names outside DEFAULT_THEME. It does not type-check every visual value; malformed colors, font mappings, sizes, or empty palettes may be reported when CustomTkinter constructs the affected widget.
API: Errors & Lifecycle
| Boundary | Failure behavior |
|---|---|
| Board/model data and operations | BoardModelError, a ValueError subclass, for invalid records, schemas, IDs, indices, relationships, unknown records, duplicate IDs, unsupported updates, protected deletion, and disabled board actions. |
| Configuration | TypeError for incorrect kinds and ValueError for unknown options or invalid dimension bounds. |
| Theme names | ValueError for unknown tokens; value-shape failures may come from CustomTkinter during construction. |
| Row adapters | TypeError for an unsupported row, or ValueError for missing cursor metadata/duplicate result names; snapshot validation may raise BoardModelError. |
| Generated editor | Validation text appears in the drawer, focus moves to the failing control when possible, and unsaved data remains open. |
on_change / on_card_open | Callback exceptions are logged under ctk_kanban; an already-applied mutation is not automatically rolled back. |
| Async fetch | The original exception is stored in load_error and delivered to on_error. Success/error callbacks should handle their own failures. |
All widget construction, normal methods, and UI callbacks belong on Tk's main thread. Only load_async()'s no-argument fetch callable runs on a daemon worker; it must not touch Tk. Applying its snapshot and invoking its callbacks happens via the board's Tk event loop.
destroy() is idempotent. It increments the async generation so stale workers cannot deliver, cancels polling/editor animation callbacks, releases active menu and drag state, destroys managed scroll frames, and then tears down the outer widget. Starting load_async() after destruction raises RuntimeError.
Migrating from 1.x
Version 2 is intentionally incompatible with the broad 1.x framework. Treat the migration as a move to a focused widget with application-owned integration.
| 1.x concept | 2.0 replacement |
|---|---|
from CTkKanBan import ... | from ctk_kanban import ... |
| Dynamic field definitions | The focused fields schema and generated explicit-save drawer. |
| Inline editing | The explicit-save embedded editor, or on_card_open for your own editor. |
| Mutation-specific callbacks | One on_change(event) callback. |
| Built-in persistence/data sources | Your repository layer plus snapshots, row adapters, and load_async(). |
| Advanced filtering and sorting | Simple local search and manual order; transform data in your application when needed. |
Many enable_* / show_* flags | Structured BoardConfig actions, layout, text, callbacks, and theme. |
| Extra custom record keys | Preserved directly; add a field definition when the value should be validated, edited, searched, or displayed. |
# Convert an application record before loading it.
card = {
"id": row["task_id"],
"column": row["status_id"],
"title": row["summary"],
"description": row.get("notes", ""),
"priority": row.get("priority", ""),
"tags": row.get("labels", []),
}
FAQ & Troubleshooting
CTkKanBan is the distribution name on PyPI. Version 2's module name is lowercase: from ctk_kanban import CTkKanbanBoard.
No database driver is built in. Query with Psycopg or your preferred library, convert rows with snapshot_from_rows() or rows_from_cursor(), then call set_data() or return the snapshot from load_async().
Your on_change callback runs on Tk's UI thread. Move slow SQL, network, retry, and conflict work to an application-owned worker or queue. Only deliver widget changes back on Tk's thread.
Priority is case-sensitive and must be exactly empty, Low, Medium, High, or Critical. Map database or API values before building the snapshot.
The model protects nonempty columns by default. Move or delete their cards first, or explicitly call delete_column(column_id, delete_cards=True). The built-in confirmed column menu uses the explicit cascade.
Search hides nonmatching cards, so the visible position is not a reliable full-list index. Clear search before dragging or using Move up and Move down. Direct API movement remains available if your application supplies an unambiguous index.
Yes. Supply fields for typed validation and generated controls. Arbitrary keys without definitions are also preserved for private integration metadata but are not shown or searched.
Call board.set_data(snapshot). Replacement validates and redraws but intentionally does not emit on_change.
rows_from_cursor() requires unique result names because duplicate keys cannot be represented safely in a dictionary. Alias joined columns in SQL, for example cards.column_id AS column.
Tags must be an iterable of strings—not one comma-separated string. Each tag is trimmed, must remain nonblank, and cannot contain a comma. PostgreSQL text[] values already arrive in a suitable list form with Psycopg.
Set CustomTkinter appearance and theme before constructing the board, and pass per-board overrides through theme. Existing child widgets are not rebuilt automatically when the original mapping changes; recreate or deliberately rebuild the board for a completely new palette.
Yes, when the fetch callback only performs non-Tk work. Fetching and validation happen on a worker; snapshot application and callbacks happen through the board's Tk event loop. Do not access widgets inside fetch_snapshot.
Open an issue in the CTkKanban GitHub repository with Python, CustomTkinter, and CTkKanban versions plus a minimal reproduction.