Skip to main content

PyPI version License: MIT

django-cards

A Django library for building rich, interactive detail cards in your views — with 30+ display options, AJAX reload, search, export, datatables, and more.


Overview

django-cards gives you a Python API to build Bootstrap-styled information cards directly from your Django views. Instead of writing repetitive template HTML, you declare cards and entries in Python:

  • 12 card types — standard detail, table, HTML, datatable, ordered datatable, list selection, layout/group, message, linked datatables, accordion, panel layout, and iframe
  • 30+ entry display options — badges, icons, sparklines, ratings, progress bars, status dots, popovers, copy-to-clipboard, and more
  • Interactive features — AJAX reload, client-side search, CSV/JSON export, collapsible cards
  • Layout system — card groups, layout cards, and child card groups for complex page layouts
  • List & tree views — built-in list-detail and tree-detail patterns with CardList and CardTree
  • Datatable integration — embed django-datatables with drag-and-drop ordering

Installation

pip install django-cards

Add to INSTALLED_APPS:

INSTALLED_APPS = [
    ...
    'cards',
]

Dependencies

django-cards requires:

Bootstrap 4 and Bootstrap 5

Cards render for both, from a template pack per version:

cards/templates/cards/bootstrap4/
cards/templates/cards/bootstrap5/

One setting picks between them, and Bootstrap 4 is the default:

DJANGO_CARDS_TEMPLATE_PACK = 'bootstrap5'

A pack emits only its own version's names, so the rendered page carries no classes the browser will ignore, and the two are free to diverge where Bootstrap 5 changed more than a name -- the lightbox close button is the worked example, since Bootstrap 4's .close styles a × the markup supplies while Bootstrap 5's .btn-close must be empty and draws its own.

The setting may instead be a dotted path to a callable taking the request and returning a pack name, for a project that has to serve both:

DJANGO_CARDS_TEMPLATE_PACK = 'myapp.bootstrap.pack_for_request'

def pack_for_request(request):
    return 'bootstrap5' if request and request.user.is_staff else 'bootstrap4'

A pack name never contains a dot, which is what tells the two forms apart.

What this means when you pass your own classes. Anywhere cards takes a CSS class from you -- div_css_class, css_class, table_td_css_class, badge, a column's css_class, the list_class / details_class on CardList -- the string is passed through untouched, so write it for the version you are on:

# on the bootstrap4 pack                     # on the bootstrap5 pack
div_css_class='col-6 float-left'             div_css_class='col-6 float-start'
css_class='text-right'                       css_class='text-end'
badge='badge-success'                        badge='text-bg-success'

The renames worth knowing: float-left/float-right → float-start/float-end, text-left/text-right → text-start/text-end, ml-*/mr-* → ms-*/me-*, pl-*/pr-* → ps-*/pe-*, font-weight-bold → fw-bold, badge-* → text-bg-*, badge-pill → rounded-pill, btn-block → w-100.

Overriding a template still works by the old name. cards/standard/default.html and the rest are still the paths cards renders, and are still what a project overrides, includes or names in a CardBase.templates subclass. What sits at each of them now is a one-line forwarder to the pack, so a file of your own at that path shadows it exactly as before -- and applies to both packs. To replace one version only, put your file at the pack path instead:

myproject/templates/cards/standard/default.html      both packs
myproject/templates/cards/bootstrap5/standard/default.html   Bootstrap 5 only

A few class names are not in a template, because they are built in Python or written by the treegrid's JavaScript: the two badges, the column filter selects, the count beside a filter value and the gap after a context menu icon. They live in cards.packs.PACK_CLASSES, one entry per pack, and are picked with the same setting.

One difference is not a rename and cannot be dualled. Bootstrap 4 gave col-* a fixed 15px of padding; Bootstrap 5 takes it from a --bs-gutter-x variable that .row sets. Card groups are emitted at the top level rather than inside a .row, so a col-* group that had gutters under 4 sits flush under 5. Add your own spacing -- px-2, or wrap the groups in a .row -- if you want them back.

jQuery is required, and under Bootstrap 5 the order matters. ajax-helpers already loads it, so this is normally free -- but Bootstrap 5 dropped jQuery as a dependency and only registers its jQuery plugin interface if jQuery is already on the page when it loads. Several cards go through that interface: tooltips and popovers, and the image gallery's lightbox. Load jQuery before bootstrap.bundle.js.

The rest of the stack is not all there yet. django-menus has the same mechanism, under its own DJANGO_MENUS_TEMPLATE_PACK -- set both to the same version. django-modals, django-datatables and crispy's bootstrap4 template pack still emit Bootstrap 4 markup only, so under Bootstrap 5 your cards will be correct while the modals around them and the datatables in them are not, until those libraries get the same treatment.

To see the difference, the example app serves either version, and the nav bar carries a toggle that flips between them on the page you are looking at -- no restart:

python manage.py runserver          # starts on Bootstrap 4

?bootstrap=5 on any URL does the same thing and sticks for the session. CARDS_EXAMPLE_BOOTSTRAP=5 in the environment sets where a fresh session starts.

Quick Start

1. Create a view

from cards.standard import CardMixin
from django.views.generic import DetailView

class CompanyDetailView(CardMixin, DetailView):
    model = Company
    template_name = 'company/detail.html'

    def setup_cards(self):
        card = self.add_card('info', title='Company Info', details_object=self.object)
        card.add_entry(field='name')
        card.add_entry(field='active')
        card.add_entry(field='importance')

        self.add_card_group('info', div_css_class='col-12')

2. Use in your template

{% load django_cards_tags %}

<div class="row">
    {{ card_groups }}
</div>

Or render individual cards:

{% load django_cards_tags %}

{% for card in cards.values %}
    {% show_card card %}
{% endfor %}

Card Types

Constant Type Description
CARD_TYPE_STANDARD (1) Standard Label/value detail card (default)
CARD_TYPE_DATATABLE (2) Datatable Embedded datatable
CARD_TYPE_ORDERED_DATATABLE (3) Ordered Datatable Datatable with drag-and-drop row ordering
CARD_TYPE_HTML (4) HTML Arbitrary HTML content
CARD_TYPE_LIST_SELECTION (5) List Selection Scrollable selectable list panel
CARD_TYPE_CARD_GROUP (6) Card Group Group of cards with a shared header
CARD_TYPE_CARD_LAYOUT (7) Layout Headerless nested layout container
CARD_TYPE_CARD_MESSAGE (8) Message Alert/warning message card
CARD_TYPE_LINKED_DATATABLES (9) Linked Datatables Side-by-side datatables with drill-down filtering
CARD_TYPE_ACCORDION (10) Accordion Collapsible panels containing any card type
CARD_TYPE_PANEL_LAYOUT (11) Panel Layout CSS Grid resizable/collapsible panel regions
CARD_TYPE_IFRAME (12) Iframe Embedded external URL or inline HTML content
CARD_TYPE_TREEGRID (13) Treegrid Fancytree hierarchical grid with lazy loading, filters, and editing

Import constants from cards.base:

from cards.base import (CARD_TYPE_STANDARD, CARD_TYPE_DATATABLE, CARD_TYPE_HTML,
                        CARD_TYPE_LINKED_DATATABLES, CARD_TYPE_ACCORDION,
                        CARD_TYPE_PANEL_LAYOUT, CARD_TYPE_IFRAME)

Values are text: escaping

A row's value and label are escaped when the card is drawn, the way {{ }} escapes a template variable, unless they are marked safe. That covers a value passed in, a field read off details_object (add_rows('name')), each line of a list value, the default shown for an empty value and the entry name of a CardList, so stored text reaches the page as text whoever typed it:

from django.utils.html import format_html
from django.utils.safestring import mark_safe

card.add_rows('name')                                    # escaped
card.add_entry(label='Swatch', value=format_html(       # markup, with the colour escaped in it
    '<i class="fa fa-square" style="color: #{}"></i>', colour.hex_code))
card.add_entry(label=mark_safe('Width &times; Height'), value=size)

Markup has to be marked safe where it is made -- mark_safe on a literal, format_html around data, a rendered template or menu, an html_classes element. Joining safe strings with an f-string, + or % gives back a plain str, which is then escaped; build the join with format_html instead.

Some parameters are markup, and are used as they are: html_override, a row style's html, merge_string and the many-to-many html_barge. What the card puts into them -- the value for %1%, a row style's {placeholders} (and anything looked up on them, {value[v1]}), each badge's text, each merged part -- is escaped the same way. Keep data out of the markup itself: put it in the value or a placeholder rather than writing it into the string. add_html_entry, add_html_string_entry and HTML cards take markup, as before.

Upgrading to 1.7. Up to 1.6 every value and label was printed with |safe. A value that was markup in a plain str -- an icon, a &thinsp; in a price, a <sup> in a label -- now shows its tags; mark it safe where it is made.

Entry Parameters Reference

The add_entry() method accepts 30+ parameters to control how each row is displayed.

Core

Parameter Type Default Description
value any None Direct value to display
field str None Field name on details_object (supports __ traversal, e.g. 'category__name')
label str None Row label (auto-generated from field if omitted)
default str 'N/A' Fallback when value is None or empty
hidden bool False Skip rendering this entry entirely
hidden_if_blank_or_none bool None Hide row if value is blank or None
hidden_if_zero bool None Hide row if value is 0
Parameter Type Default Description
link str/callable None URL — makes the entire row a hyperlink
value_link str None URL wrapping only the value (not the label)
auto_link bool False Auto-detect URLs and emails in text and make them clickable
card.add_entry(value='Visit https://example.com for details', label='Website', auto_link=True)
card.add_entry(value='Contact support@example.com', label='Email', auto_link=True)

Display

Parameter Type Default Description
badge bool/str None True for default badge (bg-secondary), or a CSS class string
icon str None Font Awesome class (e.g. 'fas fa-envelope')
prefix str None Text before the value
suffix str None Text after the value
status_dot str None CSS color for a dot indicator (e.g. 'green', '#ff0000')
progress_bar bool/str None True for default bar, or a CSS class (value is percentage)
image bool/str None True for 40px height, or custom height string; value is image URL
rating bool/int None True for 5 stars, or int for custom max; value is filled count
sparkline bool/str False True for line chart, 'bar' for bar chart; value is a list of numbers
boolean_icon bool False Show check/cross icon for boolean values
card.add_entry(value='Active',   label='Status',  badge=True)
card.add_entry(value='Overdue',  label='Payment', badge='bg-danger')
card.add_entry(value='Premium',  label='Plan',    icon='fas fa-crown', badge='bg-warning text-dark')

card.add_entry(value='Active',   label='Server',  status_dot='green')
card.add_entry(value=75,         label='Progress', progress_bar=True)
card.add_entry(value=90,         label='Disk',    progress_bar='bg-danger')

card.add_entry(value=4,          label='Rating',   rating=True)      # 4 out of 5 stars
card.add_entry(value=7,          label='Score',    rating=10)        # 7 out of 10 stars

card.add_entry(value=[10, 25, 15, 30, 20, 35, 28], label='Trend',   sparkline=True)
card.add_entry(value=[5, 10, 3, 8, 12, 6, 9],      label='Volume',  sparkline='bar')

card.add_entry(value=True,  label='Active',   boolean_icon=True)
card.add_entry(value=False, label='Verified', boolean_icon=True)

Formatting

Parameter Type Default Description
number_format bool/int None True for comma-separated integers, int for decimal places
truncate int None Max characters before truncation with ellipsis (full text in tooltip)
timestamp bool False Display as "X ago" with full datetime in tooltip
placeholder str/bool None Muted/italic placeholder when value is empty
card.add_entry(value=1234567,     label='Population', number_format=True)    # "1,234,567"
card.add_entry(value=1234567.891, label='Revenue',    number_format=2, prefix='$')  # "$1,234,567.89"
card.add_entry(value='A very long description that should be cut off', label='Desc', truncate=30)
card.add_entry(field='created_date', timestamp=True)  # "2 hours ago"

Interactivity

Parameter Type Default Description
tooltip str None Bootstrap tooltip text on hover
popover str/dict None Popover content; string or {'title': '...', 'content': '...'}
copy_to_clipboard bool False Adds a copy button next to the value
help_text str None Small muted text displayed below the value
card.add_entry(value='Hover me', label='Tooltip', tooltip='Extra information here')

card.add_entry(value='Click me', label='Popover',
               popover='Simple popover content')
card.add_entry(value='Click me', label='Rich Popover',
               popover={'title': 'Details', 'content': 'Popover with title and content'})

card.add_entry(value='sk-abc123def456ghi789', label='API Key', copy_to_clipboard=True)
card.add_entry(field='name', help_text='The primary display name')

Conditional Display

Parameter Type Default Description
show_if callable None show_if(details_object) -> bool — only show if returns True
css_class_method callable None css_class_method(value) -> str — dynamic CSS class based on value
default_if callable None Conditionally apply the default value
card.add_entry(field='name', show_if=lambda obj: obj.active)

card.add_entry(value=150, label='Balance',
               css_class_method=lambda v: 'text-success' if v >= 0 else 'text-danger')

card.add_entry(value='HIGH', label='Priority',
               css_class_method=lambda v: {'HIGH': 'text-danger fw-bold',
                                           'MEDIUM': 'text-warning',
                                           'LOW': 'text-success'}.get(v, ''))

Diff / Change Indicator

Parameter Type Default Description
old_value any None Shows old value as strikethrough with arrow to new value
card.add_entry(value='Active', label='Status', old_value='Pending')
card.add_entry(value=1500, label='Revenue', old_value=1200, number_format=True, prefix='$')

Layout & Styling

Parameter Type Default Description
row_style str None Named row style defined via add_row_style()
separator bool False Render an <hr> separator before this entry
entry_css_class str None CSS class for the value element
css_class str None CSS class for the row container
html_override str None Custom HTML — use %1% as value placeholder. The HTML is used as it is; the value is escaped unless marked safe
value_method callable None Transform the value before rendering
value_type str None Rendering hint ('currency', 'boolean', 'm2m', etc.)

Additional kwargs

These can be passed via **kwargs:

Parameter Type Default Description
merge bool — Join list values into a single string
merge_string str ' ' Separator when merging list values. Markup; the parts are escaped unless marked safe
m2m_field str — Attribute name on M2M related objects to display
query_filter dict — Filter for M2M querysets

Card-Level Options

These are passed to add_card() or CardBase.__init__():

Parameter Type Default Description
title str None Card heading text
show_header bool True Whether to show the card header
header_icon str None CSS icon class for header (e.g. 'fas fa-user')
header_css_class str '' CSS class for the header div
footer str None Footer HTML content
menu list/HtmlMenu None Action menu items in the header
tab_menu list/HtmlMenu None Tab menu items in the header
collapsed bool None None=no collapse; False=collapsible, open; True=collapsible, closed
template_name str None Template key ('default', 'table', 'blank') or custom path
ajax_reload bool False Enable AJAX reload button
reload_interval int None Auto-reload interval in seconds (requires ajax_reload=True)
searchable bool False Adds a search input that filters card rows client-side
exportable bool False Adds CSV/JSON export dropdown button
show_created_modified_dates bool False Show created/modified timestamps from the details object
column_search bool False Adds per-column search inputs to the header row (treegrid cards)
border str/bool None Card chrome: None (or True) keeps the default Bootstrap card border, 'thin' draws a 1px hairline, 'none' (or False) removes the border
details_object object None The data object for field-based entries
is_empty bool False Render as empty state
empty_message str 'N/A' Message shown when card is empty
hidden_if_blank_or_none list None Card-wide list of fields to hide when blank/None
hidden_if_zero list None Card-wide list of fields to hide when zero
extra_card_context dict None Extra context passed to the card template
card = self.add_card('profile',
                     title='User Profile',
                     details_object=user,
                     header_icon='fas fa-user',
                     header_css_class='bg-primary text-white',
                     footer='Last updated: today',
                     collapsed=False,
                     ajax_reload=True,
                     reload_interval=30,
                     searchable=True,
                     exportable=True)

Thin and borderless cards

Pass border='thin' for a 1px hairline around the card — the same look as a compact detail box on a purchase-order page. Pass border='none' (or False) to drop the box entirely, which is how you get a card that is just icons sitting on the page:

# 1px hairline, like Purchase Order Details / Delivery Address
details = self.add_card('po_details', title='Purchase Order Details',
                        template_name='table', border='thin',
                        extra_card_context={'table_css_class': 'table table-sm mb-0'})
details.add_entry(value='25/03/2026', label='Order Date')
details.add_entry(value='GBP', label='Currency')

# No chrome at all — just the icons
self.add_html_data_card(
    '<div class="d-flex align-items-center" style="gap:0.75rem">'
    '<i class="fas fa-print fa-lg text-secondary"></i>'
    '<i class="fas fa-cog fa-lg text-secondary"></i>'
    '<i class="fas fa-file-alt fa-lg text-secondary"></i>'
    '</div>',
    card_name='po_actions',
    show_header=False,
    border='none',
)

The stylesheet that draws these looks after itself: cards.css is emitted once per request, immediately before the first card that asks for a non-default border, so there is no lib_include to add and nothing to change in your base template. Cards left on the default chrome never pull it in.

Icons in a compact header

django-menus gives every menu item the .btn class, and a button is taller than the title beside it. On the Card Borders example a thin header with two icon buttons measured 44.8px, against 32px for the same header with no menu — so the compact chrome bought nothing. django-card__header-icon ships in the same stylesheet and brings it back to 32px, identical to having no menu at all:

menu = [MenuItem('app:edit_modal', menu_display='', font_awesome='fas fa-edit',
                 css_classes='django-card__header-icon',
                 attributes={'title': 'Edit', 'aria-label': 'Edit'})]
card = self.add_card('po_details', title='Purchase Order Details',
                     border='thin', menu=menu)

An icon-only item has no text for a screen reader to read, so give it a title and an aria-label. The class itself lives in cards.css, which is only emitted for cards with a non-default border= — on a page with no bordered card the class styles nothing.

It is meant for icon-only items (menu_display=''); an item with a text label wants the button. Buttons in the card body are unaffected — this is only about what sits on the header row next to the title.

border styles the card itself and is separate from treegrid_borderless, which controls the grid lines inside a treegrid card. They compose: a treegrid card can have border='thin' around the card and treegrid_borderless=True within it.

Compact card layouts

The standard and table templates take the same inline style hooks the html template has, passed through extra_card_context, which covers the rest of a compact detail-box page:

Key Effect
card_css_style Inline style on the card itself, e.g. width:fit-content
card_body_css_style Inline style on the body, e.g. max-height:600px;overflow:auto to scroll a long card in place
table_css_class The table's classes, e.g. table table-sm mb-1
table_td_css_class The value cell's classes, e.g. text-right

add_entry(row_css_class=...) puts a class on the row itself — in the table template that is the <tr>, so a line can be highlighted with row_css_class='table-warning'. It is separate from css_class, which styles the value heading and is left alone. Pass hidden_if_blank_or_none=True on the card and entries with no value drop out entirely rather than rendering a blank row.

card = self.add_card('po_details', title='Purchase Order Details',
                     template_name='table', border='thin',
                     hidden_if_blank_or_none=True,
                     extra_card_context={'table_css_class': 'table table-sm mb-1',
                                         'table_td_css_class': 'text-right',
                                         'card_body_css_style': 'max-height:600px;overflow:auto'})
card.add_entry(value='GBP', label='Currency')
card.add_entry(value='3 lines overdue', label='Status', row_css_class='table-warning')
card.add_entry(value='', label='Free Issue')   # no value, so no row

See the Purchase Order Layout example page for the whole thing.

Table Template

Use template_name='table' for a table-style layout:

card = self.add_card('details', title='Details', template_name='table',
                     extra_card_context={'table_css_class': 'table table-bordered'})
card.add_entry(value='Hello', label='Greeting')

Card Groups & Layouts

Card Groups

Arrange cards into Bootstrap grid columns:

def setup_cards(self):
    self.add_card('profile', title='Profile', details_object=self.object)
    self.add_card('stats',   title='Statistics', details_object=self.object)
    self.add_card('notes',   title='Notes', details_object=self.object)

    # Two-column layout
    self.add_card_group('profile', 'stats', div_css_class='col-6 float-left')
    self.add_card_group('notes', div_css_class='col-6 float-right')

add_card_group() parameters:

Parameter Type Default Description
*args str/CardBase — Card names or card objects to include
div_css_class str '' CSS class for the container div
div_css str '' Inline CSS styles
div_id str '' HTML id for the container
script str '' JavaScript to include in a <script> tag after the group
group_title str '' Heading displayed above the group
group_code str 'main' Group identifier

Layout Cards

For nesting cards inside a card (with or without a header):

def setup_cards(self):
    child1 = self.add_card(title='Left Panel')
    child1.add_entry(value='Hello', label='Greeting')

    child2 = self.add_card(title='Right Panel', template_name='table')
    child2.add_entry(value='World', label='Target')

    layout = self.add_layout_card()
    layout.add_child_card_group(child1, div_css_class='col-6 float-left')
    layout.add_child_card_group(child2, div_css_class='col-6 float-left')

    self.add_card_group(layout, div_css_class='col-12')

Use CARD_TYPE_CARD_GROUP instead for a layout card with a header and menu:

from cards.base import CARD_TYPE_CARD_GROUP

card = self.add_card('overview', title='Overview', group_type=CARD_TYPE_CARD_GROUP, menu=my_menu)
card.add_child_card_group(child1, div_css_class='col-6 float-left')
card.add_child_card_group(child2, div_css_class='col-6 float-left')

Multi-Entry Rows

add_row()

Place multiple entries side by side in a single row:

card.add_row('first_name', 'last_name')                # Two columns
card.add_row('city', 'state', 'zip_code')               # Three columns
card.add_row('field1', 'field2', 'field3', 'field4')    # Four columns

Columns are automatically sized using Bootstrap grid classes (col-sm-6 for 2, col-sm-4 for 3, col-sm-3 for 4).

You can also pass dicts for full control:

card.add_row({'field': 'email', 'icon': 'fas fa-envelope'},
             {'field': 'phone', 'icon': 'fas fa-phone'})

add_rows()

Bulk-add entries — each argument can be a string (field name), dict (entry kwargs), or list/tuple (passed to add_row()):

card.add_rows(
    'name',                                            # Single entry
    {'field': 'email', 'icon': 'fas fa-envelope'},     # Dict entry
    ['first_name', 'last_name'],                       # Multi-column row
    [{'field': 'city', 'label': 'City'}, 'state'],    # Mixed row
)

Custom Row Styles

Define custom HTML layouts for entries using add_row_style():

from ajax_helpers.html_include import HtmlDiv, HtmlElement

card.add_row_style('header_style', html=HtmlDiv([
    HtmlElement(element='span', contents=[
        HtmlElement(element='h4', contents='{label}')
    ]),
    HtmlElement(element='span', contents='{value}')
]))

card.add_entry(value='Custom layout', label='Title', row_style='header_style')

The style is markup and is used as it is; whatever fills {label}, {value} and any other placeholder is escaped unless it is marked safe. Put data in a placeholder, never into the style's own string.

Set a default style for all subsequent entries:

card.add_row_style('compact', html='<div class="compact">{label}: {value}</div>')
card.set_default_style('compact')

card.add_entry(value='Uses compact style', label='A')
card.add_entry(value='Also compact', label='B')

HTML Entries

Insert raw HTML or rendered templates as card rows:

card.add_html_entry(template_name='myapp/custom_entry.html', context={'key': 'value'}, colspan=2)
card.add_html_string_entry('<div class="custom">Raw HTML content</div>')

CardList & CardTree

CardList — List-Detail Pattern

A two-panel layout with a selectable list on the left and detail cards on the right:

from cards.card_list import CardList
from django.views.generic import TemplateView

class CompanyListView(CardList, TemplateView):
    template_name = 'myapp/cards.html'
    list_title = 'Companies'
    model = Company

    def get_details_title(self, details_object):
        return details_object.name

    def get_details_menu(self, details_object):
        return [MenuItem('myapp:edit', menu_display='Edit', url_args=[details_object.pk])]

    def get_details_data(self, card, details_object):
        card.add_rows('name', 'active', 'importance')
        card.add_entry(field='company_category__name', label='Category')

Key class attributes:

Attribute Default Description
model None Django model for list entries
list_title '' Heading for the list panel
list_class 'col-sm-5 col-md-4 col-lg-3 float-left' CSS class for list panel. The float follows the template pack (float-start on Bootstrap 5); setting your own string replaces the lot
details_class 'col-sm-7 col-md-8 col-lg-9 float-left' CSS class for details panel, as above

Key methods to override:

Method Purpose
get_details_data(card, details_object) Populate the detail card entries
get_details_title(details_object) Return the detail card title
get_details_menu(details_object) Return menu items for the detail card
get_list_entries() Return the queryset for list items
get_list_entry_name(entry_object) Return display name for a list item
get_list_colour(entry_object) Return optional colour for a list item

CardTree — Tree-Detail Pattern

A two-panel layout with a jsTree navigation on the left:

from cards.card_list import CardTree
from django.views.generic import TemplateView

class CategoryTreeView(CardTree, TemplateView):
    template_name = 'myapp/cards.html'
    list_title = 'Categories'

    def get_tree_data(self, selected_id):
        return [
            {'id': '1', 'parent': '#',  'text': 'Root Node'},
            {'id': '2', 'parent': '#',  'text': 'Another Root'},
            {'id': '3', 'parent': '2',  'text': 'Child Node', 'icon': 'fas fa-folder'},
            {'id': '4', 'parent': '2',  'text': 'Another Child'},
        ]

    def get_details_data(self, card, details_object):
        card.add_entry(value=details_object, label='Selected ID')

Override get_tree_data(selected_id) to return a list of node dicts with id, parent ('#' for root), text, and optionally icon and state.


Datatables

Datatable Card

Embed a django-datatables table inside a card:

from cards.base import CARD_TYPE_DATATABLE

class MyView(CardMixin, TemplateView):
    ajax_commands = ['datatable', 'row', 'column']

    def setup_datatable_cards(self):
        self.add_card('companies',
                      title='Companies',
                      group_type=CARD_TYPE_DATATABLE,
                      datatable_model=Company,
                      collapsed=False)

    def setup_cards(self):
        self.add_card_group('companies', div_css_class='col-12')

    def setup_table_companies(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name', 'importance')

The setup_table_<card_name>() method is called automatically to configure the table.

Ordered Datatable

Adds drag-and-drop row reordering:

from cards.base import CARD_TYPE_ORDERED_DATATABLE

def setup_datatable_cards(self):
    self.add_card('statuses',
                  title='Statuses',
                  group_type=CARD_TYPE_ORDERED_DATATABLE,
                  datatable_model=Status)

AJAX Reload

Button Reload

Enable the reload button on a card:

card = self.add_card('live_data', title='Live Data', ajax_reload=True)

Auto-Reload Interval

Automatically refresh a card every N seconds:

card = self.add_card('dashboard', title='Dashboard', ajax_reload=True, reload_interval=30)

Programmatic Reload

Trigger a card reload from a button handler:

def button_update(self, **kwargs):
    # ... perform update ...
    self.reload_card('live_data')

WebSocket Push Reload

Use CardReloadConsumer with Django Channels for server-pushed card reloads:

# routing.py
from cards.channels import CardReloadConsumer

websocket_urlpatterns = [
    path('ws/cards/', CardReloadConsumer.as_asgi()),
]

HTML & Message Cards

HTML Card (from template)

card = self.add_html_card('myapp/chart.html', context={'data': chart_data}, title='Chart')

HTML Card (from string)

card = self.add_html_data_card('<div class="alert alert-info">Custom HTML</div>', title='Info')

Message Card

card = self.add_message_card(title='Warning', message='No data available for this period.')

Display a visual gallery of links as uniform 120px-height tiles. Supports multiple link types: images (thumbnail + lightbox), data sheets (PDF icon + new tab), product pages (web icon + new tab), and other links (link icon + new tab).

links = [
    {'url': 'https://example.com/front.jpg', 'name': 'Front View', 'type': 'image'},
    {'url': 'https://example.com/side.jpg', 'name': 'Side View', 'type': 'image'},
    {'url': 'https://example.com/datasheet.pdf', 'name': 'Data Sheet', 'type': 'data_sheet'},
    {'url': 'https://example.com/product', 'name': 'Product Page', 'type': 'product_page'},
    {'url': 'https://example.com/other', 'name': 'Other Link', 'type': 'other'},
]
card = self.add_link_gallery_card(links, card_name='links', title='Links')

# Optionally show names below image thumbnails
card = self.add_link_gallery_card(links, card_name='links', title='Links', show_image_names=True)

# Optionally add edit buttons to individual tiles by supplying 'edit_url' on any item
links = [
    {'url': 'https://example.com/front.jpg', 'name': 'Front View', 'type': 'image', 'edit_url': '/images/1/edit/'},
    {'url': 'https://example.com/datasheet.pdf', 'name': 'Data Sheet', 'type': 'data_sheet'},
]
card = self.add_link_gallery_card(links, card_name='links', title='Links')

add_link_gallery_card() parameters:

Parameter Type Default Description
links list[dict] — List of dicts with 'url', 'type' (required), 'name' and 'edit_url' (optional) keys
card_name str None Unique card identifier
title str 'Links' Card header title
show_image_names bool False Show name labels below image thumbnails
**kwargs Additional keyword arguments passed to add_card() (e.g. collapsed, menu)

Link types:

Type Icon Click behaviour
'image' Thumbnail (natural aspect ratio) Opens lightbox modal
'data_sheet' fa-file-pdf Opens URL in new tab
'product_page' fa-globe Opens URL in new tab
'other' fa-link Opens URL in new tab

All tiles are 120px height. Image thumbnails preserve their aspect ratio using object-fit: contain. Icon tiles (data sheet, product page, other) are 120x120px squares with the icon and name label.

If a link dict includes an 'edit_url' key, a small edit button appears in the top-right corner of that tile on hover. Clicking it navigates to the edit URL without triggering the tile's own click action.

Returns None if links is empty (no card rendered).

add_image_gallery_card() is a convenience wrapper around add_link_gallery_card() for image-only galleries:

images = [
    {'url': 'https://example.com/front.jpg', 'name': 'Front View'},
    {'url': 'https://example.com/side.jpg', 'name': 'Side View', 'edit_url': '/images/2/edit/'},  # edit button on hover
    {'url': 'https://example.com/detail.jpg'},  # name is optional
]
card = self.add_image_gallery_card(images, card_name='photos', title='Product Photos')

Features:

  • Thumbnails: 120px-height tiles preserving image aspect ratio
  • Lightbox: Click any thumbnail to open a Bootstrap modal with the full-size image
  • Navigation: Prev/next buttons when multiple images exist
  • Multiple galleries: Each card gets a unique ID, so multiple gallery cards on one page work independently

Full example — a product detail view with a links gallery alongside other cards:

from cards.standard import CardMixin
from django.views.generic import DetailView

class ProductDetailView(CardMixin, DetailView):
    model = Product
    template_name = 'products/detail.html'

    def setup_cards(self):
        # Main details card
        card = self.add_card('details', title='Product Details', details_object=self.object)
        card.add_rows('name', 'sku', 'description', 'price')

        # Links gallery from related model
        product_links = self.object.links.all()
        links = [{'url': l.url, 'name': l.name, 'type': l.link_type} for l in product_links]
        gallery = self.add_link_gallery_card(links, card_name='links', title='Links')

        # Layout: details on the left, gallery on the right
        self.add_card_group('details', div_css_class='col-6 float-left')
        right_cards = [gallery] if gallery else []
        self.add_card_group(*right_cards, div_css_class='col-6 float-right')

Tiles

add_tiles() renders a card's body as a grid of small tiles, one per object -- the shape add_link_gallery_card() draws for links, for anything else. Where add_entry/add_rows give label/value rows, this gives a wrapping row of small bordered cards, each with a heading, an optional subheading, a few meta lines, an optional image, an optional badge and an edit pencil.

from cards.standard import CardMixin, Tile

card = self.add_card('colours', title='Colours & Finishes')
card.add_tiles(
    [
        Tile(
            key=f'colour_finish_{cf.pk}',     # stable id -> the tile's DOM id
            heading=cf.colour.name,           # escaped
            subheading=cf.finish.name,
            meta=[cf.price_display, ('none in stock', 'text-danger')],
            image_url=cf.finish.image.url,
            badge='Default' if cf.is_default else None,
            edit_url=f"javascript:django_modal.show_modal('colour_finish_modal-{cf.pk}')",
            tooltip=f'{cf.colour.name} / {cf.finish.name}',
            css_class='my-app-colour-tile',   # for anything only this page styles
        )
        for cf in colour_finishes
    ],
    empty_message='No colours or finishes yet. Use Add above to add one.',
    width='150px',
)

Tile fields -- every one but key is optional, and a tile draws only the parts it was given:

Field Purpose
key Stable identifier; becomes the tile's DOM id, so it must be unique on the page
heading The tile's first line. Escaped
heading_html The first line as markup, for a heading that is built rather than written. Rendered as-is -- see below
subheading A second, quieter line. Escaped
meta Short lines under the heading: a string, or a (text, css_class) pair. Escaped
image_url A 64px thumbnail under the meta lines
badge A flag drawn last, in a Bootstrap badge. Escaped
edit_url Where the pencil in the top-right corner goes. No pencil without one
tooltip The tile's title, and its image's alt text. Escaped
css_class Extra classes on the tile

add_tiles() parameters:

Parameter Type Default Description
tiles list required Tile objects, or dicts of the same fields
empty_message str None Shown in place of the grid when there are no tiles. Nothing is drawn without one
width str 150px The width of one tile, as a CSS length

Called more than once, add_tiles() adds to the grid rather than replacing it, and it returns the card so the call can be chained onto add_card().

Escaping

Everything a tile shows is escaped except heading_html, which is the one field rendered as markup. It is named for what it does so that putting user-entered text through it is an obvious mistake rather than an invisible one:

from django.utils.html import format_html

# The colour's name written on the colour itself. format_html escapes the name, so only the
# span built on this line is markup -- the tenant's text stays text.
Tile(key=f'colour_{colour.pk}',
     heading_html=format_html('<span style="background:{}">{}</span>', colour.hex, colour.name))

Pass anything that came from a form or a model field through heading instead, where the template escapes it. Markup handed to heading is not quietly rendered: the escaping follows the field the caller chose, not the shape of the value.

Styling

The chrome lives in the package stylesheet (cards/css/cards.css, injected once per request when a card that needs it renders), under .django-card__tile*. It reuses image_gallery's numbers -- 8px gap and padding, #dee2e6 on #f8f9fa, .8 hover opacity -- so a page carrying both reads as one family. Two differences from the gallery's own tiles are deliberate:

  • The pencil is always shown, not revealed on hover. It is what tells a reader the tile can be edited at all.
  • Tile width is per card. width='190px' for a tile holding a two-part measurement that must not wrap; the default 150px otherwise. It reaches the CSS as the --django-card-tile-width custom property on the container.

Retint the chrome without out-specifying anything by setting the custom properties it reads: --django-card-border-color, --django-card-tile-bg, --django-card-tile-subheading-color, --django-card-tile-meta-color.

See the /tiles/ page in django_examples for a tile with every field, one with none of the optional ones, both shapes above, and the empty state.


Linked Datatables

Display multiple datatables side by side with drill-down filtering. Clicking a row in one table filters the next table in the chain. Supports any number of linked tables.

Basic Setup

from cards.base import CARD_TYPE_LINKED_DATATABLES
from cards.standard import CardMixin
from django.views.generic import TemplateView

class CompanyDrilldown(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'
    ajax_commands = ['datatable', 'row']

    def setup_cards(self):
        self.add_linked_datatables_card(
            card_name='drilldown',
            title='Company Drilldown',
            datatables=[
                {'id': 'ld_categories', 'model': CompanyCategory, 'title': 'Categories'},
                {'id': 'ld_companies', 'model': Company, 'title': 'Companies',
                 'linked_field': 'company_category_id'},
                {'id': 'ld_people', 'model': Person, 'title': 'People',
                 'linked_field': 'company_id'},
            ]
        )
        self.add_card_group('drilldown', div_css_class='col-12')

    def setup_table_ld_categories(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name')

    def setup_table_ld_companies(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name', 'importance')

    def setup_table_ld_people(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'first_name', 'surname')

How It Works

  1. The first table loads data normally via AJAX
  2. Subsequent tables start empty — they load when a row is selected in the previous table
  3. Selecting a row sends the linked_field value as a filter to the next table's AJAX query
  4. The first row is auto-selected on load (except for the last table)
  5. Selecting a different row clears and reloads all downstream tables

add_linked_datatables_card() Parameters

Parameter Type Default Description
card_name str — Unique card identifier
title str '' Card header title
datatables list[dict] — List of datatable configuration dicts (see below)
**kwargs Additional keyword arguments passed to add_card()

Datatable Config Dict

Key Type Required Description
id str Yes Unique table identifier (also used for setup_table_<id>() method name)
model Model Yes Django model class for the table
title str No Display title above the table (defaults to id with underscores replaced)
linked_field str No Field name to filter by when the previous table's row is selected
css_class str No Additional CSS class for the table's panel container
row_link str No URL name for navigation when a row is clicked (last table only)
menu list No Menu items (e.g. buttons) displayed next to the table title

Add a row_link to the last table to navigate to another page when a row is clicked:

from django_datatables.helpers import DUMMY_ID

datatables=[
    {'id': 'ld_categories', 'model': CompanyCategory, 'title': 'Categories'},
    {'id': 'ld_companies', 'model': Company, 'title': 'Companies',
     'linked_field': 'company_category_id'},
    {'id': 'ld_people', 'model': Person, 'title': 'People',
     'linked_field': 'company_id',
     'row_link': f'admin:cards_examples_person_change,{DUMMY_ID}'},
]

The row_link uses the same format as django-datatables row links. DUMMY_ID is replaced with the actual row ID on click. Navigation only happens on a real click — auto-selection does not trigger it.

Custom Query Methods

For complex filtering (e.g. where the linked field isn't a direct FK), define a get_<table_id>_query method:

def get_ld_payments_query(self, table, **kwargs):
    person_id = self.request.POST.get('linked_filter_value')
    if person_id:
        company = Person.objects.get(id=person_id).company
        table.filter['company_id'] = company.id
    return table.get_query(**kwargs)

When a custom query method exists, the automatic linked_field filter is skipped.

Features

  • Keyboard navigation: Arrow keys to move between rows (up/down) and tables (left/right)
  • Arrow indicator: A ▶ column is automatically added to tables that link to the next table
  • Toggle deselect: Clicking a selected row deselects it and clears downstream tables
  • Auto-select: The first row is automatically selected on load for all tables except the last

Table Setup

Each table is configured via a setup_table_<id>() method, just like standard datatable cards:

def setup_table_ld_companies(self, table, details_object):
    table.ajax_data = True
    table.add_columns('id', 'name', 'importance')

Set table.ajax_data = True on all tables — the linked datatables system handles starting subsequent tables empty and loading them when needed.


Accordion

Collapsible accordion panels where each panel can contain a different card type (standard detail cards, datatables, HTML cards, etc.).

Basic Setup

from cards.base import CARD_TYPE_DATATABLE
from cards.standard import CardMixin
from django.views.generic import TemplateView

class AccordionView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'
    ajax_commands = ['datatable', 'row']

    def setup_datatable_cards(self):
        self.add_card('acc_companies',
                      group_type=CARD_TYPE_DATATABLE,
                      datatable_model=Company)

    def setup_table_acc_companies(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name', 'importance')

    def setup_cards(self):
        # Standard detail card
        detail_card = self.add_card(title='Overview')
        detail_card.add_entry(label='Total', value=Company.objects.count())

        # Datatable card
        companies_card = self.cards['acc_companies']

        # HTML card
        notes_card = self.add_card(title='Notes')
        notes_card.add_entry(label='Info', value='Any card type works inside an accordion.')

        self.add_accordion_card(
            card_name='my_accordion',
            title='Accordion Example',
            panels=[
                {'title': 'Overview', 'card': detail_card, 'icon': 'fas fa-chart-bar',
                 'expanded': True},
                {'title': 'Companies', 'card': companies_card, 'icon': 'fas fa-building'},
                {'title': 'Notes', 'card': notes_card, 'icon': 'fas fa-sticky-note'},
            ]
        )

        self.add_card_group('my_accordion', div_css_class='col-12')

add_accordion_card() Parameters

Parameter Type Default Description
card_name str — Unique card identifier
title str '' Card header title
panels list[dict] — List of panel configuration dicts (see below)
multi_open bool False Allow multiple panels to be open simultaneously
full_height bool False Stretch accordion to fill remaining viewport height
min_height str '300px' Minimum height when full_height is enabled
**kwargs Additional keyword arguments passed to add_card()

Panel Config Dict

Key Type Default Description
title str 'Panel N' Panel header text
card CardBase — Card object to render inside the panel
icon str None Font Awesome class for the panel header icon
expanded bool False Whether the panel starts expanded
ajax_load bool False Load panel content via AJAX on first expand
header_css_class str '' CSS class for the panel header
id str auto Custom panel ID (auto-generated if omitted)

Single Open (Default)

By default, only one panel can be open at a time. Opening a panel collapses the others:

self.add_accordion_card(
    card_name='single',
    title='Single Open',
    panels=[
        {'title': 'Panel A', 'card': card_a, 'expanded': True},
        {'title': 'Panel B', 'card': card_b},
        {'title': 'Panel C', 'card': card_c},
    ]
)

Multi Open

Set multi_open=True to allow multiple panels open simultaneously:

self.add_accordion_card(
    card_name='multi',
    title='Multi Open',
    multi_open=True,
    panels=[
        {'title': 'Details', 'card': card1, 'expanded': True},
        {'title': 'Status', 'card': card2, 'expanded': True},
        {'title': 'Notes', 'card': card3},
    ]
)

AJAX Lazy Loading

Set ajax_load=True on a panel to defer loading its content until the panel is first expanded. This is useful for panels with expensive queries or large datatables:

self.add_accordion_card(
    card_name='lazy',
    title='Lazy Loading',
    panels=[
        {'title': 'Summary', 'card': summary_card, 'expanded': True},
        {'title': 'People', 'card': people_card, 'ajax_load': True},
        {'title': 'Notes', 'card': notes_card, 'ajax_load': True},
    ]
)

AJAX-loaded panels show a spinner placeholder until the content is fetched. Content is only loaded once — subsequent expand/collapse toggles use the cached content.

Events

The accordion toggles its panels itself rather than through Bootstrap's collapse plugin, but it raises the plugin's events on each panel's #<panel id>_collapse element, so page code written against Bootstrap still works: show.bs.collapse and hide.bs.collapse before a panel moves, shown.bs.collapse and hidden.bs.collapse once it has. They bubble, and reach both jQuery .on() and native addEventListener listeners. Calling preventDefault() on show or hide cancels the change:

$('#my_accordion_accordion').on('show.bs.collapse', function(e) {
    // e.target is the panel's .collapse element
});

Full Height

Set full_height=True to make the accordion stretch to fill the remaining viewport height. The expanded panel's content area becomes scrollable. A minimum height prevents the accordion from being too small on short viewports:

self.add_accordion_card(
    card_name='sidebar',
    title='Navigation',
    full_height=True,
    min_height='400px',
    panels=[
        {'title': 'Items', 'card': items_card, 'expanded': True},
        {'title': 'Settings', 'card': settings_card},
    ]
)

This works well for sidebar layouts where the accordion sits alongside other content (see the Layout Example below).

Panel Icons and Styles

Each panel can have an icon and custom header styling:

panels=[
    {'title': 'Overview', 'card': card1, 'icon': 'fas fa-info-circle'},
    {'title': 'Settings', 'card': card2, 'icon': 'fas fa-cog',
     'header_css_class': 'bg-light'},
]

Nesting Card Types

Any card type can be placed inside an accordion panel. The panel automatically hides the nested card's own header to avoid visual duplication:

  • Standard detail cards
  • Datatable cards (define in setup_datatable_cards(), reference via self.cards['name'])
  • HTML cards
  • Image gallery cards
  • Tile cards
  • Other card types

Layout Example — Accordion with Side Panel

Use card groups to place an accordion alongside other cards:

class DashboardView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'
    ajax_commands = ['datatable', 'row']

    def setup_datatable_cards(self):
        self.add_card('acc_people',
                      group_type=CARD_TYPE_DATATABLE,
                      datatable_model=Person)

    def setup_table_acc_people(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'first_name', 'surname')

    def setup_cards(self):
        # Cards for accordion panels
        summary_card = self.add_card(title='Summary')
        summary_card.add_entry(label='Companies', value=Company.objects.count())
        summary_card.add_entry(label='People', value=Person.objects.count())

        people_card = self.cards['acc_people']

        notes_card = self.add_card(title='Notes')
        notes_card.add_entry(label='Tip', value='Accordion on the left, details on the right.')

        # Accordion card — fills remaining page height
        self.add_accordion_card(
            card_name='nav_accordion',
            title='Navigation',
            full_height=True,
            panels=[
                {'title': 'Summary', 'card': summary_card, 'icon': 'fas fa-chart-bar',
                 'expanded': True},
                {'title': 'People', 'card': people_card, 'icon': 'fas fa-users'},
                {'title': 'Notes', 'card': notes_card, 'icon': 'fas fa-sticky-note'},
            ]
        )

        # Detail card on the right
        detail_card = self.add_card('details', title='Details', details_object=self.get_object())
        detail_card.add_rows('name', 'active', 'importance')
        detail_card.add_entry(field='company_category__name', label='Category')

        # Layout: accordion col-4 left, details col-8 right
        self.add_card_group('nav_accordion', div_css_class='col-4 float-left')
        self.add_card_group('details', div_css_class='col-8 float-left')

Panel Layout

A CSS Grid-based panel layout system for building IDE-style interfaces with resizable and collapsible regions. Supports nested splits, tabbed content, header toolbars, linked datatables across regions, and persistent state via localStorage.

Basic Setup

from cards.standard import CardMixin
from django.views.generic import TemplateView

class DashboardView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'

    def setup_cards(self):
        layout = self.add_panel_layout(min_height='500px')
        root = layout.root

        sidebar = root.add_region('sidebar', size='250px', collapsible=True, min_size=150,
                                  title='Navigation')
        main_region = root.add_region('main', size='1fr', min_size=200,
                                      title='Dashboard')

        nav_card = self.add_card(title='Navigation')
        nav_card.add_rows(
            {'label': 'Dashboard', 'value': 'Overview of all data'},
            {'label': 'Companies', 'value': 'Manage company records'},
        )
        sidebar.add_card(nav_card)

        main_card = self.add_card(title='Dashboard')
        main_card.add_rows(
            {'label': 'Info', 'value': 'Drag the splitter bar to resize panels'},
        )
        main_region.add_card(main_card)

        self.add_card_group(layout.render(), div_css_class='col-12')

add_panel_layout() Parameters

Parameter Type Default Description
card_name str 'panel_layout' Unique card identifier
layout_id str auto DOM id for the layout container
direction str 'horizontal' Root split direction — 'horizontal' or 'vertical'
resizable bool True Whether panels can be resized by dragging
full_height bool True Automatically size the layout to fill viewport height (see note below)
min_height str '400px' CSS min-height value
css_class str '' Extra CSS classes on the layout container
css_style str '' Extra inline styles on the layout container
persist bool True Save/restore panel sizes and collapse state to localStorage

With full_height=True the layout is sized to what is left of the viewport below its own top edge, less whatever comes after it on the page — a breadcrumb, a footer — so those land on the bottom edge instead of being pushed past it. A page with nothing below the layout is sized as it always was. The measurement is re-taken on resize and again on load, when the heights below have settled.

Splits

Splits arrange child items (regions or nested splits) horizontally or vertically using CSS Grid. Splits can be nested to create complex layouts.

# Nested layout: sidebar + right side split into top and bottom
layout = self.add_panel_layout(min_height='550px')
root = layout.root

sidebar = root.add_region('sidebar', size='250px', collapsible=True)

right = root.add_split(direction='vertical')
top_region = right.add_region('top', size='200px')
bottom_region = right.add_region('bottom', size='1fr')

add_split() parameters:

Parameter Type Default Description
direction str opposite of parent 'horizontal' or 'vertical'
sizes list None Explicit CSS grid track sizes
resizable bool True Show splitter bars between children
name str None Identifier (required if collapsible)
collapsible bool False Allow collapsing the whole split
collapsed bool False Start collapsed
title str None Title for the collapse toolbar

Regions

Regions are the leaf containers that hold cards, tabs, or nested layouts. Each region occupies a cell in its parent split.

region = root.add_region(
    'editor', size='1fr',
    title='Editor',
    menu=[MenuItem(...)],           # right side of title bar
    toolbar=[MenuItem(...)],        # separate bar below title
    collapsible=True,
    min_size=200,
)
region.add_card(content_card)

add_region() parameters:

Parameter Type Default Description
name str — Unique region identifier
size str '1fr' CSS grid track size (e.g. '250px', '1fr', 'auto')
min_size int None Minimum pixel size during drag resize
max_size int None Maximum pixel size during drag resize
collapsible bool False Allow collapsing
collapsed bool False Start collapsed
collapse_direction str auto Override chevron direction ('horizontal' or 'vertical')
overflow str 'auto' CSS overflow value
title str None Title in the header toolbar
menu list None Menu items in the header toolbar (right-aligned by default)
menu_align str 'right' Alignment of menu — 'right' or 'left'
toolbar list None Menu items for a separate left-aligned bar below the header

Visual structure of a region:

┌─────────────────────────────────────┐
│ Header toolbar  (title + menu)      │  ← title/menu params
├─────────────────────────────────────┤
│ Menu bar  (left-aligned buttons)    │  ← toolbar param
├─────────────────────────────────────┤
│ Tab bar  (tabs + per-tab menu)      │  ← add_tab()
├─────────────────────────────────────┤
│                                     │
│ Content area  (cards)               │  ← add_card() / tab.add_card()
│                                     │
└─────────────────────────────────────┘

All layers are optional. A region with just add_card() has only the content area.

Tabs

Add tabbed content within a region. Each tab can have its own cards and per-tab menu:

from django_menus.menu import AjaxButtonMenuItem

region = root.add_region('main', size='1fr', title='Data')

companies_menu = [
    AjaxButtonMenuItem(button_name='add_company', menu_display='',
                       font_awesome='fas fa-plus',
                       css_classes='btn btn-sm btn-outline-success'),
]
companies_tab = region.add_tab('companies', title='Companies',
                                icon='fas fa-building', active=True,
                                menu=companies_menu)
companies_tab.add_card(companies_datatable)

people_tab = region.add_tab('people', title='People',
                             icon='fas fa-users')
people_tab.add_card(people_datatable)

add_tab() parameters:

Parameter Type Default Description
name str — Unique tab identifier
title str — Display label on the tab
icon str None Font Awesome class for a tab icon
active bool False Whether this tab is initially selected (first tab is active by default)
menu list None Per-tab menu items shown to the right of the tab bar when active

Linked Datatables in Panel Layout

Place linked datatables in separate resizable regions:

class DrilldownView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'
    ajax_commands = ['datatable', 'row']

    def setup_datatable_cards(self):
        layout = self.add_panel_layout(min_height='550px')
        root = layout.root

        cat_region = root.add_region('categories', size='1fr', title='Categories')
        comp_region = root.add_region('companies', size='1fr', title='Companies')
        people_region = root.add_region('people', size='1fr', title='People')

        cat_card = self.add_card('pl_categories', group_type=CARD_TYPE_DATATABLE,
                                  datatable_model=CompanyCategory)
        cat_region.add_card(cat_card)

        comp_card = self.add_card('pl_companies', group_type=CARD_TYPE_DATATABLE,
                                   datatable_model=Company)
        comp_region.add_card(comp_card)

        people_card = self.add_card('pl_people', group_type=CARD_TYPE_DATATABLE,
                                     datatable_model=Person)
        people_region.add_card(people_card)

        layout.linked_tables = [
            {'table_id': 'pl_categories'},
            {'table_id': 'pl_companies', 'linked_field': 'company_category_id'},
            {'table_id': 'pl_people', 'linked_field': 'company_id'},
        ]

        self.add_card_group(layout.render(), div_css_class='col-12')

    def setup_table_pl_categories(self, table, details_object):
        table.ajax_data = True
        table.add_columns('id', 'name')

    def setup_table_pl_companies(self, table, details_object):
        table.ajax_data = False
        table.table_data = []
        table.add_columns('id', 'name', 'importance')

    def setup_table_pl_people(self, table, details_object):
        table.ajax_data = False
        table.table_data = []
        table.add_columns('id', 'first_name', 'surname')

Set linked_tables on the layout as a list of dicts. Subsequent tables start empty and load when a row is selected in the previous table.

Holy Grail Layout

A classic header/sidebar/content/sidebar/footer layout using nested splits:

layout = self.add_panel_layout(min_height='600px', direction='vertical')
root = layout.root

header = root.add_region('header', size='auto')
middle = root.add_split(direction='horizontal')
footer = root.add_region('footer', size='auto')

left = middle.add_region('left_nav', size='200px', collapsible=True)
centre = middle.add_region('content', size='1fr')
right = middle.add_region('aside', size='220px', collapsible=True)

Features

  • Drag-resizable splitter bars between regions
  • Collapsible regions with animated chevron icons
  • Tabbed content within regions with per-tab menus
  • Header toolbars and separate menu bars on regions
  • Linked datatables across regions
  • Accordion cards that fill region height
  • Nested splits for complex multi-pane layouts
  • Full-height mode fills viewport minus surrounding content
  • Persistent state via localStorage (sizes + collapse state)

Treegrid

A Fancytree-based hierarchical grid with lazy-loaded children, optional inline editing, per-column filters, row selection, and custom toolbar buttons.

Basic Setup

from cards.standard import CardMixin
from django.views.generic import TemplateView
from django.urls import reverse

class OrgTreeView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'

    def setup_cards(self):
        self.add_treegrid_card(
            card_name='org_tree',
            title='Organisation Tree',
            treegrid_columns=[
                {'title': 'Name',     'field': 'title',    'width': '50%'},
                {'title': 'Category', 'field': 'category', 'width': '30%'},
                {'title': 'People',   'field': 'people_count', 'width': '20%'},
            ],
            treegrid_icon_map={
                'category': 'fas fa-layer-group',
                'company':  'fas fa-building',
                'person':   'fas fa-user',
            },
        )
        self.add_card_group('org_tree', div_css_class='col-12')

    def get_treegrid_org_tree_data(self, parent=None):
        if parent is None:
            return [{'title': 'Root', 'key': 'root_1', 'folder': True, 'lazy': True,
                     'data': {'type': 'category', 'category': '', 'people_count': 5}}]
        # Return children for the given parent key
        return []

add_treegrid_card() Parameters

Parameter Type Default Description
card_name str None Unique card identifier
title str None Card header title. If None, no header is shown
treegrid_columns list [] Column definitions (see below)
treegrid_data_url str '' URL for a separate data endpoint (GET with ?parent= param)
treegrid_static_data list None Inline static tree data (no AJAX)
treegrid_read_only bool True Disable inline editing
treegrid_height str '600px' CSS max-height of the scrollable table area
treegrid_indentation int 20 Pixels of indentation per tree level
treegrid_icon_map dict {} Maps node data.type → FontAwesome class
treegrid_show_filter bool True Show the Expand All / Collapse All / global filter toolbar
treegrid_show_search bool None Show the search box on its own. None follows treegrid_show_filter
treegrid_show_expand_buttons bool None Show Expand All / Collapse All on their own. None follows treegrid_show_filter
treegrid_auto_hide_expand_buttons bool False Hide Expand All / Collapse All while nothing in the tree can expand
treegrid_expand_all bool False Expand all root nodes on initial load
treegrid_show_column_filters bool False Show per-column filter inputs in header row
treegrid_filter_auto_expand bool False Expand collapsed branches to reveal filter matches (before 1.5.0 this was accepted but had no effect)
treegrid_toolbar list [] Custom toolbar buttons (see below)
treegrid_toolbar_after list [] Additional buttons rendered after the checkbox controls
treegrid_toolbar_end list [] Additional buttons rendered at the end of the toolbar, after Expand All / Collapse All
treegrid_submit_label str 'Submit Selected' Label for the submit button when treegrid_checkbox=True
treegrid_header_rows list [] Multi-row header definitions (for colspan/rowspan headers)
treegrid_node_column int 0 Column index that displays the tree node title and expand icon
treegrid_save_mode str 'auto' 'auto' = save on each change; 'batch' = collect then save all
treegrid_checkbox bool False Enable row selection checkboxes
treegrid_checkbox_column int 0 Column index for the checkbox (default: leftmost extra column)
treegrid_show_select_buttons bool True Show the Select All / Deselect All buttons a checkbox grid renders
treegrid_show_submit_button bool True Show the submit button a checkbox grid renders (see treegrid_submit_label)
treegrid_show_select_count bool True Show the "N selected" counter a checkbox grid renders
treegrid_context_menu list None Context menu items on right-click (MenuItems or dicts)
treegrid_resizable bool False Allow dragging column borders to resize columns
treegrid_pagination bool False Enable client-side pagination of root-level nodes
treegrid_page_size int 50 Rows per page when treegrid_pagination=True
column_search bool False Alias for treegrid_show_column_filters (card-level parameter)
**kwargs Additional parameters passed to add_card() (e.g. collapsed, menu, footer)

Column Definitions

Each entry in treegrid_columns is a dict:

Key Type Default Description
title str — Column header text
field str — Key in node data dict. Use 'title' for the node title column
width str None CSS column width (e.g. '30%', '120px')
css_class str None Classes added to every cell in the column and to its header, e.g. 'text-center'
header_css_class str None Classes for the header only. Overrides css_class there
type str None 'boolean', 'html', 'actions', 'checkbox', 'select'
editable bool False Enable inline editing for this column
inline bool True False = open a popup widget instead of editing in-place
options list None For type='select': list of {'value': ..., 'label': ...} dicts
visible_for list None Only show a widget for node types in this list (e.g. ['item', 'group'])
filter bool True Set False to disable the column's filter input when column filters are on
filter_options any None Controls the filter widget (see Column Filters below)

Data Modes

There are three ways to provide node data:

1. Self-dispatch (default) — define a get_treegrid_<card_name>_data(parent=None) method on the view:

def get_treegrid_my_tree_data(self, parent=None):
    if parent is None:
        # Return root nodes
        return [{'title': 'Root', 'key': 'root_1', 'folder': True, 'lazy': True,
                 'data': {'type': 'category'}}]
    # Return children for parent key
    if parent.startswith('root_'):
        return [{'title': 'Child', 'key': 'child_1', 'folder': False,
                 'data': {'type': 'item'}}]
    return []

2. Separate URL — pass treegrid_data_url to a view that accepts ?parent=<key>:

from django.views import View
from django.http import JsonResponse

class MyTreeData(View):
    def get(self, request):
        parent_key = request.GET.get('parent')
        return JsonResponse(get_nodes(parent_key), safe=False)
self.add_treegrid_card(
    card_name='my_tree',
    treegrid_data_url=reverse('myapp:tree_data'),
    ...
)

3. Static data — pass treegrid_static_data as a Python list with nested children:

data = [
    {
        'title': 'Fruits', 'key': 'fruits', 'folder': True,
        'data': {'type': 'group', 'price': ''},
        'children': [
            {'title': 'Apple', 'key': 'apple', 'folder': False,
             'data': {'type': 'item', 'price': '1.20'}},
            {'title': 'Banana', 'key': 'banana', 'folder': False,
             'data': {'type': 'item', 'price': '0.80'}},
        ],
    },
]
self.add_treegrid_card(card_name='my_tree', treegrid_static_data=data, ...)

Node Data Format

Each node returned by your data source is a dict:

Key Required Description
title Yes The text displayed in the tree node column
key Yes Unique string identifier — passed back as parent to load children
folder Yes True if this node can have children
lazy No True to defer loading children until the node is expanded
data Yes Dict of column field values. Include 'type' for icon mapping
childCount No Badge shown next to the node title (e.g. 5)
children No Inline pre-loaded children (for static data or eager loading)
disableEdit No Disable edit for this row when editing is enabled.

Styled Cells and Rows

Include styling keys in node.data to colour individual cells or entire rows:

{
    'title': 'Company A',
    'key': 'company_1',
    'data': {
        'type': 'company',
        'status': 'Critical',
        'amount': '99500',
        # Per-cell: field__bg, field__color, field__class
        'amount__bg': '#d4edda',
        'amount__color': '#28a745',
        'amount__class': 'font-weight-bold fw-bold',
        # Per-row: _row_bg, _row_color, _row_class
        '_row_bg': '#fff3cd',
        '_row_class': 'my-group-row',
    }
}

_row_class is handed to Fancytree as an extraClasses entry as well as applied to the <tr>, so it survives the row re-renders Fancytree does on expand, collapse, activate and focus. A class that should apply to a whole column, rather than to the rows that happen to carry a key for it, belongs on the column instead (css_class, see Column Definitions) — that puts it on the header too.

You can also apply styles server-side after a save using the helper methods:

def button_my_tree_save(self, **kwargs):
    key = kwargs.get('key')
    # Style a single cell
    self.treegrid_style_cell('my_tree', key, 'amount', bg='#d4edda', color='#28a745')
    # Style an entire row
    self.treegrid_style_row('my_tree', key, bg='#fff3cd')
    # Update a cell value
    self.treegrid_update_cell('my_tree', key, 'total', '99,500')
    return self.command_response()

Inline Editing

Set treegrid_read_only=False and mark individual columns editable=True. When a cell is double-clicked an input opens. On blur/enter the value is posted to button_<card_name>_save:

def setup_cards(self):
    self.add_treegrid_card(
        card_name='edit_tree',
        treegrid_read_only=False,
        treegrid_columns=[
            {'title': 'Name',  'field': 'title',      'width': '50%', 'editable': True},
            {'title': 'Score', 'field': 'score',       'width': '25%', 'editable': True},
            {'title': 'Grade', 'field': 'grade',       'width': '25%', 'editable': True,
             'type': 'select',
             'options': [
                 {'value': 'A', 'label': 'A — Excellent'},
                 {'value': 'B', 'label': 'B — Good'},
                 {'value': 'C', 'label': 'C — Pass'},
             ]},
        ],
        ...
    )

def button_edit_tree_save(self, **kwargs):
    key   = kwargs.get('key')       # node key, e.g. 'company_42'
    field = kwargs.get('field')     # column field name, e.g. 'score'
    value = kwargs.get('value')     # new value as string
    # row_<field> keys also available for the full current row state
    # ... persist to database ...
    return self.command_response()

Widget types:

type Behaviour
(omitted) Plain text input
'select' Dropdown — provide options list of {'value': ..., 'label': ...}
'checkbox' Toggle boolean. Use 'inline': False for a modal-style popup
'boolean' Read-only checkmark/cross display (not editable)
'html' Raw HTML rendered in the cell (not editable)
'actions' Action button column — define actions list of {'name', 'icon', 'title'}

Use 'inline': False to force a confirmation popup rather than in-place editing:

{'title': 'Active', 'field': 'is_active', 'type': 'checkbox', 'editable': True, 'inline': False}

Use 'visible_for' to only show a widget on certain node types (useful for mixed-type trees):

{'title': 'Primary', 'field': 'primary', 'type': 'checkbox', 'editable': True, 'visible_for': ['item']}

Batch Save

Set treegrid_save_mode='batch' to collect all changes locally and post them all at once when the user clicks the Save button:

self.add_treegrid_card(
    card_name='batch_tree',
    treegrid_read_only=False,
    treegrid_save_mode='batch',
    ...
)

def button_batch_tree_batch_save(self, **kwargs):
    import json
    changes = json.loads(kwargs.get('changes', '[]'))
    # Each change: {'key': '...', 'field': '...', 'value': '...'}
    for change in changes:
        ...
    return self.command_response()

Toolbar Buttons

Add custom buttons to the toolbar above the tree:

self.add_treegrid_card(
    card_name='my_tree',
    treegrid_toolbar=[
        {'name': 'new_group',   'label': 'New Group',   'icon': 'fas fa-folder-plus'},
        {'name': 'new_company', 'label': 'New Company', 'icon': 'fas fa-plus'},
    ],
    ...
)

def button_my_tree_new_group(self, **kwargs):
    return self.command_response(toast_commands(header='New Group', text='...'))

def button_my_tree_new_company(self, **kwargs):
    return self.command_response(toast_commands(header='New Company', text='...'))

Each button dict takes:

Key Type Default Description
name str — Identifier; posted back to button_<card_name>_<name>()
label str — Button text
icon str None FontAwesome class
button_class str 'btn-outline-secondary' Bootstrap button class (treegrid_toolbar_after / treegrid_toolbar_end only)
needs_selection int/bool None Render disabled until this many rows are ticked (True means 1)

There are three toolbar slots. treegrid_toolbar renders first, treegrid_toolbar_after follows the selection controls, and treegrid_toolbar_end renders last — after the card's own Expand All / Collapse All, which is where a button that acts on the grid usually belongs:

self.add_treegrid_card(
    card_name='my_tree',
    treegrid_checkbox=True,
    treegrid_toolbar_end=[
        # Dead until two rows are ticked: the row's own action covers the single case.
        {'name': 'delete', 'label': 'Delete', 'icon': 'fas fa-trash',
         'button_class': 'btn-danger', 'needs_selection': 2},
    ],
    ...
)

Row Selection (Checkboxes)

Set treegrid_checkbox=True to add a checkbox column. Select All / Deselect All buttons appear in the toolbar. Clicking Submit posts the selected keys:

self.add_treegrid_card(
    card_name='select_tree',
    treegrid_checkbox=True,
    treegrid_submit_label='Apply Selection',
    ...
)

def button_select_tree_selected(self, **kwargs):
    import json
    keys = json.loads(kwargs.get('selected_keys', '[]'))
    # keys is a list of selected node key strings
    return self.command_response()

Each of those three controls can be left out where the grid does not want it — a grid whose rows carry their own actions, or one where "select every row" is not something anyone means to press:

self.add_treegrid_card(
    card_name='pick_tree',
    treegrid_checkbox=True,
    treegrid_show_select_buttons=False,   # no Select All / Deselect All
    treegrid_show_submit_button=False,    # no Submit Selected
    treegrid_show_select_count=False,     # no "3 selected"
    ...
)

Search and Expand Buttons

treegrid_show_filter switches the search box and the Expand All / Collapse All pair together. treegrid_show_search and treegrid_show_expand_buttons split that switch when a grid wants one without the other — a page of rows the reader can see all of needs no search box, but still needs to open and close its groups:

self.add_treegrid_card(
    card_name='usage_tree',
    treegrid_show_search=False,               # no search box
    treegrid_show_expand_buttons=True,        # keep Expand All / Collapse All
    ...
)

treegrid_auto_hide_expand_buttons=True hides those two buttons while nothing in the tree can expand, so a flat list does not carry a pair of controls that do nothing. It is decided from the tree on every load and lazy load, not from a flag saying the grid is flat, so a list that starts nesting gets them back on its own.

Pagination

Set treegrid_pagination=True to page through root-level nodes in the browser. All root nodes load in a single request; children still lazy-load normally:

self.add_treegrid_card(
    card_name='paginated_tree',
    treegrid_pagination=True,
    treegrid_page_size=10,
    treegrid_checkbox=True,   # checkbox + pagination work together
    ...
)

Column Filters

Enable per-column filter inputs in the header row with either treegrid_show_column_filters=True or column_search=True:

self.add_treegrid_card(
    card_name='filter_tree',
    treegrid_show_column_filters=True,
    treegrid_columns=[
        {'title': 'Name',     'field': 'title'},
        {'title': 'Category', 'field': 'category'},
        {'title': 'Status',   'field': 'status', 'type': 'boolean'},
        {'title': 'Actions',  'field': '',        'type': 'actions'},  # no filter
    ],
    ...
)

The default filter widget per column is:

  • type='boolean' → Yes/No <select>
  • type='actions' → no filter
  • everything else → text <input>

Override the filter widget using filter_options on a column definition:

Explicit list of options:

{'title': 'Category', 'field': 'category',
 'filter_options': ['Technology', 'Finance', 'Healthcare']}

Options with separate display label and search value:

{'title': 'Category', 'field': 'category',
 'filter_options': [
     {'label': 'Technology',  'value': 'tech'},
     {'label': 'Finance',     'value': 'fin'},
     {'label': 'Healthcare',  'value': 'health'},
 ]}

The value is matched against the node data; the label is what appears in the dropdown.

Auto-generated from loaded data:

{'title': 'Category', 'field': 'category', 'filter_options': True}

Setting filter_options=True makes the select automatically populate with all unique values found across loaded nodes. The options refresh after each lazy-load expansion. This works with both paginated and non-paginated modes.

Disable filtering on a specific column with 'filter': False:

{'title': 'Notes', 'field': 'notes', 'filter': False}

Side-Panel JS Filters

Pass treegrid_js_filters to render a pivot-style filter panel to the left of the tree. Each filter shows the unique values for a field as checkboxes with occurrence counts; unchecking a value hides matching rows:

self.add_treegrid_card(
    card_name='my_tree',
    treegrid_js_filters=[
        {'field': 'category', 'title': 'Category'},
        {'field': 'status',   'title': 'Status'},
    ],
    ...
)
  • All values are checked (shown) by default.
  • Click All in a filter block header to re-check every value in that block.
  • Works in both paginated and non-paginated modes.
  • In paginated mode the panel filters combine with the toolbar search box — both must match for a row to appear.

Multi-Row Headers

Use treegrid_header_rows to build colspan/rowspan headers. Define a list of rows, each a list of cell dicts:

self.add_treegrid_card(
    card_name='colspan_tree',
    treegrid_node_column=4,   # which column holds the tree expand icon
    treegrid_header_rows=[
        [
            {'title': 'Status',  'rowspan': 2},
            {'title': 'Selections', 'colspan': 3, 'css_class': 'text-center'},
            {'title': 'Name',    'rowspan': 2},
            {'title': 'Options', 'colspan': 2, 'css_class': 'text-center'},
        ],
        [
            {'title': 'Primary'},
            {'title': 'Optional'},
            {'title': 'Ignore'},
            {'title': 'Category'},
            {'title': 'Type'},
        ],
    ],
    treegrid_columns=[
        {'title': 'Status',   'field': 'status'},
        {'title': 'Primary',  'field': 'primary',  'editable': True, 'type': 'checkbox'},
        {'title': 'Optional', 'field': 'optional', 'editable': True, 'type': 'checkbox'},
        {'title': 'Ignore',   'field': 'ignore',   'editable': True, 'type': 'checkbox'},
        {'title': 'Name',     'field': 'title'},
        {'title': 'Category', 'field': 'category'},
        {'title': 'Type',     'field': 'window_type'},
    ],
    ...
)

When using colspan headers, the treegrid_node_column must be set to the correct 0-based index of the column that should show the tree icon.

Context Menu

Add a right-click context menu with treegrid_context_menu. Mix MenuItem/DividerItem objects (rendered server-side) with plain dicts (handled by JS):

from django_menus.menu import MenuItem, DividerItem

self.add_treegrid_card(
    card_name='adv_tree',
    treegrid_context_menu=[
        MenuItem(url='myapp:detail', menu_display='View Details',
                 font_awesome='fas fa-external-link-alt', link_type=MenuItem.HREF),
        DividerItem(),
        {'name': 'add_child',  'label': 'Add Child',  'icon': 'fas fa-plus'},
        {'name': 'delete',     'label': 'Delete',      'icon': 'fas fa-trash text-danger'},
    ],
    ...
)

def button_adv_tree_context(self, **kwargs):
    action = kwargs.get('action')   # e.g. 'add_child' or 'delete'
    key    = kwargs.get('key')      # the right-clicked node key
    if action == 'delete':
        self.treegrid_remove_node('adv_tree', key)
    elif action == 'add_child':
        self.treegrid_add_node('adv_tree', parent_key=key, node_data={
            'title': 'New Node', 'key': 'new_1', 'folder': False,
            'data': {'type': 'item'},
        })
    return self.command_response()

Server-Side Node Manipulation

After a save or button press, manipulate tree nodes from the view:

# Update a cell value
self.treegrid_update_cell(card_name, key, field, value)

# Style a cell (bg and/or color)
self.treegrid_style_cell(card_name, key, field, bg='#d4edda', color='#28a745')

# Style a whole row
self.treegrid_style_row(card_name, key, bg='#fff3cd', color='')

# Add a node (mode='child', 'before', or 'after')
self.treegrid_add_node(card_name, parent_key=key, node_data={...}, mode='child')

# Add a root node
self.treegrid_add_node(card_name, parent_key=None, node_data={...})

# Remove a node
self.treegrid_remove_node(card_name, key)

# Move a node
self.treegrid_move_node(card_name, key, target_key, mode='child')

# Force a full data reload
return self.treegrid_reload_response(card_name)

Remembering State

If ajax_helpers are being used inside a project, the treegrid state can be remembered when reloading the tree. For example:

class SomeView:
    def get_tree_grid_card(self):
        ...
        return tree_grid_card # The treegrid card object
    
    # AJAX helpers button or other method
    def button_refresh_tree_grid(self):
        card = self.get_tree_grid_card()
        self.add_command('treegrid_snapshot_state', card=card.code)
        selector = 'some_selector_for_parent_div_of_treegrid'
        return self.command_response('html', selector=selector, html=card.render())

NOTES:

  • This does not work with lazy treegrids, only static data treegrids. For lazy, the self.add_command('reload_treegrid', card=card_name) command can be used.
  • This also does not work with paginated treegrids currently.

Several Treegrids on One Page

The stylesheet and the grid's behaviour -- about 110 KiB together -- are the same for every treegrid, so they are emitted once per page rather than once per card. Each card emits only its own config object and one call into the shared behaviour, which is what keeps two grids on a page independent: the per-card state lives in the closure that call makes.

Nothing needs configuring for this, and each card still renders as it always did. Two things are worth knowing:

  • "The page" means the request cycle. A card reloaded over ajax, or a modal body fetched on its own, is a request of its own and carries the shared half again -- harmlessly, since the browser already has it and the second copy does not overwrite the first.
  • A card rendered outside a request cycle carries its own copy. Building cards in a management command or straight from RequestFactory fires no request signals, so there is no page to mark; the shared half is emitted per card, as it was before. Rendering the same cards through a served request -- including through a form widget that builds its own card mixin, and so has no request to hand its card -- shares one copy.

Overriding cards/standard/treegrid.html in a project means keeping its last three lines, which are what fetch the shared half and this card's config:

{% treegrid_shared_assets %}
{% include 'cards/standard/_treegrid_init.html' %}
{% include 'cards/standard/_reload_script.html' %}

treegrid_shared_assets comes from {% load django_cards_tags %}.

Upgrading from 1.4.x

Nothing changes for a project using the package templates as-is, or for one that wraps cards/standard/treegrid.html with {% include %}. A project that copied either template to override it must update its copy, because _treegrid_script.html no longer initialises a grid by itself -- it defines the shared behaviour once, and something has to call it. A stale copy renders grids that never come to life, so each shape logs a console error rather than failing silently:

  • A copied treegrid.html includes _treegrid_script.html per card and never includes _treegrid_init.html, so the shared behaviour lands with nothing to call it. The console error says so; the fix is the three closing lines shown above.
  • A copied _treegrid_script.html overrides the shared half with per-card 1.4.x code that defines no shared behaviour, so every card's config queues and nothing drains it. The console error names the affected card codes; the fix is to drop the override, or to re-copy it from 1.5.

The same queued-config error covers an overridden treegrid.html that kept the _treegrid_init.html include but dropped the {% treegrid_shared_assets %} tag.

An override of _treegrid_script.html or _treegrid_css.html must also stay free of {% trans %} and of anything else that varies per request: the shared half is rendered without a context and cached for the life of the process.


Iframe Card

Embed external URLs or inline HTML content in a sandboxed iframe.

Basic Usage

from cards.standard import CardMixin
from django.views.generic import TemplateView

class IframeView(CardMixin, TemplateView):
    template_name = 'myapp/cards.html'

    def setup_cards(self):
        # Load an external URL
        self.add_iframe_card(
            card_name='docs',
            title='Documentation',
            iframe_url='https://docs.djangoproject.com/',
        )

        # Inline HTML content (e.g. Three.js, D3, charts)
        self.add_iframe_card(
            card_name='scene',
            title='3D Viewer',
            iframe_srcdoc='<html><body><h1>Hello</h1></body></html>',
            iframe_height='500px',
        )

        self.add_card_group('docs', div_css_class='col-6 float-left')
        self.add_card_group('scene', div_css_class='col-6 float-left')

add_iframe_card() Parameters

Parameter Type Default Description
card_name str None Unique card identifier
title str None Card header title. If None, no header is shown
iframe_url str '' URL to load in the iframe
iframe_srcdoc str '' Inline HTML content for the iframe
iframe_height str '400px' CSS height of the iframe. Use '100%' inside panel layout regions
iframe_sandbox str 'allow-scripts allow-same-origin' Sandbox attribute value
**kwargs Additional keyword arguments passed to add_card()

Inside Panel Layout

Iframe cards work well inside panel layout regions. Use iframe_height='100%' to fill the region:

def setup_cards(self):
    layout = self.add_panel_layout(min_height='550px')
    root = layout.root

    sidebar = root.add_region('sidebar', size='280px', collapsible=True)
    right = root.add_split(direction='vertical')
    top = right.add_region('top', size='1fr')
    bottom = right.add_region('bottom', size='1fr')

    info_card = self.add_card(title='Info')
    info_card.add_entry(label='Top', value='Three.js demo')
    sidebar.add_card(info_card)

    threejs_card = self.add_iframe_card(
        card_name='threejs',
        title='Three.js Demo',
        iframe_srcdoc='<html>...</html>',
        iframe_height='100%',
    )
    top.add_card(threejs_card)

    chart_card = self.add_iframe_card(
        card_name='chart',
        title='Chart',
        iframe_srcdoc='<html>...</html>',
        iframe_height='100%',
    )
    bottom.add_card(chart_card)

    self.add_card_group(layout.render(), div_css_class='col-12')

License

MIT

Release files for django-cards 1.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-cards 1.7.0
File Size Uploaded
django_cards-1.7.0.tar.gz 694.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-cards 1.7.0
File Interpreter ABI Platform
django_cards-1.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.4 MB

Release files / django_cards-1.7.0.tar.gz

Download URL django_cards-1.7.0.tar.gz
Size 694.8 kB
Tags Source
SHA-256 checksum
How to use checksums
e43ec296740cf71191fb0b073b1040db5bba412566ff35876ec133fa249aafad
BLAKE2b-256 checksum
How to use checksums
8dc310ebc6c96dbca01cec67e42a6a745cf877f0d0bc835edd718936bcaaa5e8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / django_cards-1.7.0-py3-none-any.whl

Download URL django_cards-1.7.0-py3-none-any.whl
Size 679.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d3280864339321c7d078f736e2e8f6fe02292d4e0d3ad4ac2b9ea9de0adc10c8
BLAKE2b-256 checksum
How to use checksums
c69fd7f20f255a5f1d4a62786882ba7453a215c5cb2cc74f458fcde24621ed96
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.7.0 This release

2 release files

1.6.1

2 release files

1.6.0

2 release files

1.5.4

2 release files

1.5.3

2 release files

1.5.2

2 release files

1.5.1

2 release files

1.5.0

2 release files

1.4.9

2 release files

1.4.8

2 release files

1.4.7

2 release files

1.4.6

2 release files

1.4.5

2 release files

1.4.4

2 release files

1.4.2

2 release files

1.4.1

2 release files

1.4.0

2 release files

1.3.5

2 release files

1.3.4

2 release files

1.3.3

2 release files

1.3.2

2 release files

1.3.1

2 release files

1.3.0

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.3

2 release files

1.1.2

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.7

2 release files

1.0.6

2 release files

1.0.5

2 release files

1.0.4

2 release files

1.0.3

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

0.12.4

2 release files

0.12.3

2 release files

0.12.2

2 release files

0.12.1

1 release file

0.12.0

1 release file

0.11.8

1 release file

0.11.7

1 release file

0.11.6

1 release file

0.11.4

1 release file

0.11.3

1 release file

0.11.2

1 release file

0.11.1

1 release file

0.11.0

1 release file

0.10.3

1 release file

0.10.2

1 release file

0.10.1

1 release file

0.10.0

1 release file

0.9.8

1 release file

0.9.7

1 release file

0.9.6

1 release file

0.9.5

1 release file

0.9.4

1 release file

0.9.3

1 release file

0.9.2

1 release file

0.9.1

1 release file

0.9.0

1 release file

0.8.14

1 release file

0.8.13

1 release file

0.8.12

1 release file

0.8.11

1 release file

0.8.10

1 release file

0.8.9

1 release file

0.8.8

1 release file

0.8.7

1 release file

0.8.6

1 release file

0.8.5

1 release file

0.8.4

1 release file

0.8.3

1 release file

0.8.2

1 release file

0.8.1

1 release file

0.8.0

1 release file

0.7.20

1 release file

0.7.19

1 release file

0.7.18

1 release file

0.7.17

1 release file

0.7.16

1 release file

0.7.15

1 release file

0.7.14

1 release file

0.7.13

1 release file

0.7.12

1 release file

0.7.11

1 release file

0.7.10

1 release file

0.7.9

1 release file

0.7.8

1 release file

0.7.7

1 release file

0.7.6

1 release file

0.7.5

1 release file

0.7.4

1 release file

0.7.3

1 release file

0.7.2

1 release file

0.7.1

1 release file

0.7.0

1 release file

0.6.6

1 release file

0.6.5

1 release file

0.6.4

1 release file

0.6.3

1 release file

0.6.2

1 release file

0.6.1

1 release file

0.6.0

1 release file

0.5.10

1 release file

0.5.9

1 release file

0.5.8

1 release file

0.5.7

1 release file

0.5.6

1 release file

0.5.5

1 release file

0.5.3

1 release file

0.5.2

1 release file

0.5.1

1 release file

0.4.0

1 release file

0.3.4

1 release file

0.3.3

1 release file

0.3.2

1 release file

0.3.1

1 release file

0.3.0

1 release file

0.2.1

1 release file

0.2.0

1 release file

0.1.0

1 release file

0.0.6

1 release file

0.0.5

1 release file

0.0.4

1 release file

0.0.3

1 release file

0.0.2

1 release file

0.0.1

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page