✦ CustomTkinter Widget

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.

$ python -m pip install --upgrade CTkKanBan
Typed Data Model Database Helpers CustomTkinter Python 3.10+

Installation


Install CTkKanban from PyPI. CustomTkinter is installed automatically as a required dependency.

bash
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:

python
import customtkinter as ctk
from ctk_kanban import CTkKanbanBoard

Public helpers can be imported from the same package root:

python
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.

AreaWhat changed
Custom fieldsAny number of schema-defined text, numeric, choice, date, boolean, tag, or hidden values now drive validation, the editor, compact cards, and search.
Runtime schemasget_fields() and atomic set_fields() support safe schema inspection and replacement.
ConfigurationStructured action permissions, layout settings, customizable text, and delete-confirmation policy replace scattered flags.
Deletion controlCard and column deletion can be disabled independently, including protection against bypass through a non-empty column cascade.
AppearanceThe closed theme surface now covers 97 color, typography, spacing, geometry, limit, scrollbar, menu, and motion tokens.
Data integrationArbitrary card metadata round-trips safely and row/cursor snapshot adapters validate against the active field schema.
DocumentationThe README, browser guide, maintainer runbooks, and automated drift checks now cover the complete public contract.
Compatibility

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.

python
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()
Data ownership

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.

python
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.
Search and movement

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:

python
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.

python
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,
)
Keep Tk responsive

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:

bash
python -m pip install "psycopg[binary]"

Example schema

sql
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

python
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()
Why the alias matters

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:

python
{
    "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


FieldTypeRequiredDescription
idstr | intYesUnique column identifier. Blank strings and booleans are rejected.
titlestrYesVisible 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


FieldTypeRequiredDescription
idstr | intYesUnique card identifier. Blank strings and booleans are rejected.
columnstr | intYesID of an existing column. column_id is accepted as an input alias.
titlestrYesNonblank card heading, trimmed during normalization.
descriptionstrNoPlain text details. Defaults to an empty string.
prioritystrNoOne of empty, Low, Medium, High, or Critical.
tagsIterable[str]NoTrimmed 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.

python
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

TypeEditor controlNormalized value
textSingle-line entryTrimmed str; None becomes empty text.
textareaMultiline textboxTrimmed str; None becomes empty text.
numberEntryfloat | None; booleans are rejected.
integerEntryint | None; fractional float input is rejected.
selectOption menuOne value; non-empty options are enforced.
multiselectAdd/remove pillsDeduplicated list; non-empty options are enforced on save.
dateEntryISO YYYY-MM-DD or empty text; accepts date input.
datetimeEntryISO date-time or empty text; accepts datetime and trailing Z input.
checkboxCheckboxStrict bool.
tagsAdd/remove tag pillsDeduplicated list[str]; trimmed, nonblank, comma-free items.
hiddenHidden by defaultDeep-copied application value for schema-controlled data without UI.
Control behavior

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.

OptionDefaultMeaning
type"text"One of the eleven supported type strings.
requiredFalseReject None, empty text, and empty lists.
defaultType-dependentDeep-copied when a configured key is absent. New controls use list [], checkbox False, numeric None, or empty text when no explicit default exists.
placeholderEmptyEntry hint; list inputs fall back to “Add a value”.
options()Allowed sequence for select/multiselect. Empty means no model-level restriction.
show_on_cardRole-dependentRender on compact cards; true unless the inferred role is hidden.
show_in_editorType-dependentGenerate a drawer control; false by default only for hidden fields.
searchableFalseInclude the field in case-insensitive substring search.
read_onlyFalseDisable the generated control while retaining its value.
section"Details"Drawer group; groups follow first appearance. Column selection lives in Organisation.
card_rolemetadata or hiddenCompact-card presentation role.
help_textEmptySupporting copy; for checkbox controls it becomes the checkbox label.
min, maxUnsetInclusive numeric limits.
min_length, max_lengthUnsetInclusive string character or list item limits.
validatorUnset(value, card) -> bool | str | None. False produces a generic error; a string is the error message.
formatterUnset(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

RolePresentation
titleMain heading. Exactly one definition uses this role and its key must be title.
bodyWrapped body line, truncated by card_description_max_chars. Multiple body fields are allowed.
badgeColored pill; the first non-empty visible badge also colors the accent strip.
tagsOne #value pill per item, capped per field by card_max_visible_tags.
metadataPill formatted as Label: value.
hiddenNo 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, and column_id are reserved and cannot be field keys.
  • If title is 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.

python
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(), model snapshot(), 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. None appends.
  • 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: 1 and "1" are distinct. A card reference must have the same type as its column ID.
  • Input column_id is normalized to column in 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 sourceRecommended helperNotes
Psycopg dict_rowsnapshot_from_rows()Rows already implement the mapping protocol.
sqlite3.Rowsnapshot_from_rows()Converted through its keys() interface.
SQLAlchemy Rowsnapshot_from_rows()Converted through row._mapping.
Plain DB-API tuplesrows_from_cursor()Uses cursor.description for column names and consumes remaining rows.

One reusable DB-API cursor

python
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

python
from ctk_kanban import snapshot_from_cursors

snapshot = snapshot_from_cursors(columns_cursor, cards_cursor)
board.set_data(snapshot)
Query requirements

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.

ControlBehavior
Text and textareaSingle-line or multiline text with required and length validation.
Number and integerTyped numeric values with optional minimum and maximum constraints.
Select and multiselectConfigured choices, including optional empty selection.
CheckboxBoolean values.
Date and datetimeISO values; date inputs use YYYY-MM-DD.
HiddenNo control by default; the model can still retain and validate the value.
ColumnMoves the card when a different destination is saved.
TagsAdd with Enter and remove from the visible tag pills.
python
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

KeyAction
EnterSaves while focus is inside the editor, except when focus is in any multiline textbox.
Ctrl+EnterSaves from any field, including multiline textboxes.
EscapeCloses the drawer without applying unsaved changes.

Open the drawer directly when a surrounding application has its own buttons or shortcuts:

python
board.open_add_card_editor("todo")
board.open_edit_card_editor(card_id=17)
board.open_add_column_dialog()

Feature: Selection


Clicking a card selects it and applies the configured selected border color. Selection is board-local and exposes a detached card record:

python
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.

ActionResultEvent
Drag card handleMoves to the indicated column and insertion position.card_moved
Card menu: Move up/downChanges top-to-bottom order in the current column.card_moved
Card menu: Move to columnAppends to the chosen destination.card_moved
Column menu: Move left/rightChanges 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.

python
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 typeAdditional keysTriggered by
card_addedcardadd_card() or the add-card editor.
card_updatedcard, previousA changed update_card() operation or editor save.
card_deletedcarddelete_card() or confirmed menu deletion.
fields_changedfieldsset_fields() when type/default normalization changes stored card values.
card_movedcard, previousDrag, menu movement, or move_card().
column_addedcolumnadd_column() or the add-column dialog.
column_updatedcolumn, previousA changed rename operation.
column_deletedcolumndelete_column() or confirmed menu deletion.
column_movedcolumnMenu 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().

Persistence boundary

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.

python
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.

python
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_board runs 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, and on_error run 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 before on_error.
  • Success applies data through set_data(), so loading does not emit on_change.
  • Invalidating an old result does not forcibly stop its Python worker; it only prevents delivery.
  • The returned threading.Thread can be inspected or joined by non-UI test code, but do not join it from Tk's UI thread.
Thread boundary

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.

python
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.

SettingBuilt-in UIProtected board API
add_cardsAdd-card buttons and drawer openingadd_card()
edit_cardsCard opening/edit commands, including on_card_openupdate_card()
move_cardsDrag handle, card move menus, and drawer column changesmove_card(), plus column-changing update_card()
delete_cardsCard delete commanddelete_card() and non-empty column cascades
add_columnsAdd-column controls/dialogadd_column()
edit_columnsRename commandupdate_column()
move_columnsMove-left/right commandsmove_column()
delete_columnsColumn delete commanddelete_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.

Behavior control, not authorization

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

SettingDefaultContract
show_toolbarTrueShow title, summary, search, and add controls. Programmatic search() still works when hidden.
enable_dragTrueEnable handle dragging only when move_cards is also true. Menus/API remain available when only drag is off.
column_width320Integer at least 220.
column_height500Integer at least 240.
editor_width420Integer at least 320.
confirm_deleteTruePrompt only for built-in menu deletions. Direct deletion methods never prompt.

Customizable text

SettingDefault
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:

python
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.

python
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

TokenControls
board_fg_colorRoot board surface.
toolbar_fg_colorToolbar surface.
column_fg_colorColumn and empty-board surfaces.
column_header_fg_colorColumn header background.
column_border_colorColumn outlines and secondary controls.
column_accent_colorsCycled accent palette for column headers.
card_fg_colorCard surface.
card_hover_colorCard hover surface.
dragging_card_fg_colorCard surface during dragging.
card_border_colorNormal card outline.
selected_border_colorSelected card outline.
drop_indicator_colorDrag destination indicator.
text_colorPrimary text.
muted_text_colorDescriptions, summaries, and placeholders.
accent_colorPrimary actions and accents.
control_hover_colorMenu-like and secondary control hover state.
count_fg_colorColumn count badge surface.
empty_icon_fg_colorEmpty-board icon surface.
editor_fg_colorInspector drawer surface.
editor_section_fg_colorGrouped editor section surface.
divider_colorEditor separators.
input_border_colorSearch and editor input borders.
scrollbar_colorScrollbar thumb.
scrollbar_hover_colorScrollbar hover state.
danger_colorDestructive actions and validation emphasis.
pill_text_colorPriority and tag pill text.
priority_low_colorLow-priority pill.
priority_medium_colorMedium-priority pill.
priority_high_colorHigh-priority pill.
priority_critical_colorCritical-priority pill.
tag_pill_colorsCycled palette for tag pills.
card_*_font, editor_*_fontTypography definitions supplied as CTkFont keyword mappings.
*_corner_radius, *_border_widthCard, column, editor-section, input, and pill geometry.
column_gap, card_gap, editor padding/gap tokensComponent spacing and density.
card_description_max_chars, card_max_visible_tagsCompact-card content limits.
input_height, textbox_height, scrollbar_widthControl 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.

AreaAll accepted tokens
Board surfacesboard_fg_color, toolbar_fg_color, column_fg_color, column_header_fg_color, column_border_color, column_accent_colors
Cards and dragcard_fg_color, card_hover_color, dragging_card_fg_color, card_border_color, selected_border_color, drop_indicator_color
Shared colorstext_color, muted_text_color, accent_color, control_hover_color, count_fg_color, empty_icon_fg_color, divider_color, danger_color
Editor/input/scroll colorseditor_fg_color, editor_section_fg_color, input_border_color, scrollbar_color, scrollbar_hover_color, error_text_color
Pills and prioritiespill_text_color, priority_low_color, priority_medium_color, priority_high_color, priority_critical_color, tag_pill_colors
Native menu colorsmenu_fg_color, menu_text_color, menu_hover_color, menu_disabled_text_color
Board/toolbar geometryboard_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 fontstoolbar_title_font, toolbar_summary_font
Column geometrycolumn_corner_radius, column_border_width, column_gap, column_accent_height, column_header_padding_x, card_gap
Column fontscolumn_title_font, column_count_font, column_empty_title_font, column_empty_body_font
Card geometry and limitscard_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 fontscard_title_font, card_body_font, card_metadata_font, pill_font
Editor layouteditor_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 motioneditor_slide_step, editor_slide_interval_ms
Editor fontseditor_eyebrow_font, editor_title_font, editor_status_font, section_title_font, field_label_font, help_text_font, status_text_font
Inputs and scrollbarinput_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.

Strict token names

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


python
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,
)
ParameterType / defaultDescription
masterCustomTkinter/Tk parentParent widget.
columnsIterable, ()Initial column mappings or Column instances in display order.
cardsIterable, ()Initial card mappings or Card instances in manual order.
on_changeCallable | NoneReceives one event dictionary after each changed mutation.
on_card_openCallable | NoneReplaces the built-in existing-card editor and receives a detached card record.
themeMapping | NonePartial overrides for known board theme tokens.
fieldsIterable[FieldDefinition] | NoneTyped values used by model validation, the generated editor, compact cards, and search.
configBoardConfig | Mapping | NoneStructured action, layout, text, and deletion-confirmation configuration.
show_toolbar, enable_dragbool | NoneOptional direct overrides for the matching layout settings.
column_width, column_height, editor_widthint | NoneOptional direct size overrides. Defaults are 320, 500, and 420.
confirm_deletebool | NoneOptional override for built-in menu confirmation.
allow_card_deletion, allow_column_deletionbool | NoneConvenience permission overrides; disabled card deletion also blocks non-empty column cascades.
board_titlestr | NoneOptional toolbar title override.
**kwargsCTkFrame optionsForwarded to customtkinter.CTkFrame, including size, corner radius, and root frame colors.

API: Board Methods


Data and rendering

Method / propertyReturnsDescription
get_data()BoardSnapshotDetached complete board state.
set_data(data)NoneAtomically validate and replace the board without a change event.
refresh(preserve_scroll=True)NoneRebuild structural widgets, optionally retaining horizontal and per-column scroll positions.
get_card(card_id)CardRecord | NoneDetached 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)NoneAtomically validate and replace the schema, cards, and an open editor; emits fields_changed if normalization changes data.
get_selected_card()CardRecord | NoneCurrent selection snapshot.
search(query)NoneApply or clear the view-only card search.

Card mutations

MethodReturnsDescription
add_card(card, *, index=None)CardRecordAdd at a zero-based position in its column, or append.
update_card(card_id, updates)CardRecordUpdate editable fields; column or column_id can also move it.
move_card(card_id, column_id, index=None)CardRecordMove to a destination and insertion position, or append.
delete_card(card_id)CardRecordDelete and return the removed record.

Column mutations

MethodReturnsDescription
add_column(column, *, index=None)ColumnRecordAdd at a zero-based position, or append.
update_column(column_id, updates)ColumnRecordRename with an update mapping containing title.
move_column(column_id, index)ColumnRecordMove to a zero-based manual position.
delete_column(column_id, *, delete_cards=False)ColumnRecordDelete an empty column, or explicitly cascade its cards.

Editors and loading

Method / propertyReturnsDescription
open_add_card_editor(column_id=None)NoneOpen the add drawer. With no columns, first opens the add-column dialog.
open_edit_card_editor(card_id)NoneOpen the built-in editor or call on_card_open. Unknown IDs are ignored.
open_add_column_dialog()NonePrompt for a title and add a UUID-backed column.
is_loadingboolRead-only property for pending asynchronous work.
load_errorException | NoneMost recent async load exception; reset when a new load starts.
set_loading(loading)NoneSet loading presentation. Requires an actual boolean.
load_async(fetch_snapshot, *, on_success=None, on_error=None, clear_on_error=False)threading.ThreadFetch and validate off-thread, then deliver through Tk.
destroy()NoneInvalidate 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.

python
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()
MethodDescription
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


NameKindPurpose
ColumnFrozen dataclassTyped input with id and title.
CardFrozen dataclassTyped input; stores tags as tuple[str, ...].
ColumnRecordTypedDictSerializable column output shape.
CardRecorddict[str, Any]Serializable flat card output with structural and arbitrary custom keys.
FieldDefinitionTypedDictEditor, validation, search, and compact-display configuration for one value.
FieldTypeLiteral unionAll supported field type strings.
DEFAULT_FIELDSTuple of mappingsDefault title, description, priority, and tags definitions.
BoardSnapshotTypedDictComplete columns and cards output shape.
BoardModelErrorExceptionValidation and model-operation failure.
ActionConfigFrozen dataclassEight board mutation/action switches.
LayoutConfigFrozen dataclassToolbar, drag, and board/editor dimensions.
TextConfigFrozen dataclassStable application-facing board labels.
BoardConfigFrozen dataclassActions, layout, text, and delete-confirmation policy.
DEFAULT_THEMEDictionaryImport-time snapshot of all theme token defaults.
__version__StringInstalled CTkKanban package version.

Definition helpers

from_definition() exposes model normalization without building an entire board:

python
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


FunctionReturnsDescription
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)BoardSnapshotNormalize and validate both row collections against the default or supplied schema.
snapshot_from_cursors(columns_cursor, cards_cursor, *, fields=None)BoardSnapshotConsume two executed cursors and validate the complete board against the schema.
Cursor behavior

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


NameDescription
DEFAULT_THEMEA 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.
python
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


BoundaryFailure behavior
Board/model data and operationsBoardModelError, a ValueError subclass, for invalid records, schemas, IDs, indices, relationships, unknown records, duplicate IDs, unsupported updates, protected deletion, and disabled board actions.
ConfigurationTypeError for incorrect kinds and ValueError for unknown options or invalid dimension bounds.
Theme namesValueError for unknown tokens; value-shape failures may come from CustomTkinter during construction.
Row adaptersTypeError for an unsupported row, or ValueError for missing cursor metadata/duplicate result names; snapshot validation may raise BoardModelError.
Generated editorValidation text appears in the drawer, focus moves to the failing control when possible, and unsaved data remains open.
on_change / on_card_openCallback exceptions are logged under ctk_kanban; an already-applied mutation is not automatically rolled back.
Async fetchThe 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 concept2.0 replacement
from CTkKanBan import ...from ctk_kanban import ...
Dynamic field definitionsThe focused fields schema and generated explicit-save drawer.
Inline editingThe explicit-save embedded editor, or on_card_open for your own editor.
Mutation-specific callbacksOne on_change(event) callback.
Built-in persistence/data sourcesYour repository layer plus snapshots, row adapters, and load_async().
Advanced filtering and sortingSimple local search and manual order; transform data in your application when needed.
Many enable_* / show_* flagsStructured BoardConfig actions, layout, text, callbacks, and theme.
Extra custom record keysPreserved directly; add a field definition when the value should be validated, edited, searched, or displayed.
python
# 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.

↑ Top