A Django library for building rich, interactive detail cards in your views — with 30+ display options, AJAX reload, search, export, datatables, and more.
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
CardListandCardTree - Datatable integration — embed django-datatables with drag-and-drop ordering
pip install django-cardsAdd to INSTALLED_APPS:
INSTALLED_APPS = [
...
'cards',
]django-cards requires:
- ajax-helpers
- django-menus
- django-datatables (for datatable card types)
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'){% 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 %}| 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 |
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)The add_entry() method accepts 30+ parameters to control how each row is displayed.
| 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)| 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)| 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"| 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')| 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, ''))| 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='$')| 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 |
value_method |
callable | None |
Transform the value before rendering |
value_type |
str | None |
Rendering hint ('currency', 'boolean', 'm2m', etc.) |
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 |
m2m_field |
str | — | Attribute name on M2M related objects to display |
query_filter |
dict | — | Filter for M2M querysets |
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 |
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)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')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 |
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')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 columnsColumns 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'})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
)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')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')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>')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 |
details_class |
'col-sm-7 col-md-8 col-lg-9 float-left' |
CSS class for details panel |
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 |
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.
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.
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)Enable the reload button on a card:
card = self.add_card('live_data', title='Live Data', ajax_reload=True)Automatically refresh a card every N seconds:
card = self.add_card('dashboard', title='Dashboard', ajax_reload=True, reload_interval=30)Trigger a card reload from a button handler:
def button_update(self, **kwargs):
# ... perform update ...
self.reload_card('live_data')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()),
]card = self.add_html_card('myapp/chart.html', context={'data': chart_data}, title='Chart')card = self.add_html_data_card('<div class="alert alert-info">Custom HTML</div>', title='Info')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')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.
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')- The first table loads data normally via AJAX
- Subsequent tables start empty — they load when a row is selected in the previous table
- Selecting a row sends the
linked_fieldvalue as a filter to the next table's AJAX query - The first row is auto-selected on load (except for the last table)
- Selecting a different row clears and reloads all downstream tables
| 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() |
| 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.
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.
- 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
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.
Collapsible accordion panels where each panel can contain a different card type (standard detail cards, datatables, HTML cards, etc.).
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')| 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() |
| 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) |
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},
]
)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},
]
)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.
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).
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'},
]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 viaself.cards['name']) - HTML cards
- Image gallery cards
- Other card types
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')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.
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')| 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 |
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 |
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 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.
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 |
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.
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)- 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)
Embed external URLs or inline HTML content in a sandboxed iframe.
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')| 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() |
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')MIT