When you’re working with CRM records, displaying everything in a table isn’t always the best option.
Tables are useful when you need to compare many values at once, but sometimes you just want to quickly scan through records and get the important information without reading across multiple columns.
This is where a card layout works well.
Instead of showing records as rows, a card view gives each record its own small tile containing the most useful information, an avatar, and an actions menu.
The good news is that you don’t have to build all of this yourself in Horilla.
Horilla provides HorillaCardView, which is built on top of HorillaListView. It lets you keep the filtering, sorting, permissions, actions, and queryset logic you already use in a list view while displaying the records in a responsive card grid.
Let’s look at how it works.
Creating Your First Card View
If you’re already familiar with HorillaListView, creating a card view is quite straightforward.
For example, a simple contact card view can look like this:
from django.contrib.auth.mixins import LoginRequiredMixin
from horilla.contrib.generics.views import HorillaCardView
from .models import Contact
from .filters import ContactFilter
class ContactCardView(LoginRequiredMixin, HorillaCardView):
model = Contact
filterset_class = ContactFilter
columns = [
"first_name",
"title",
"email",
"contact_number",
"birth_date",
]
That’s enough to get a basic card grid working.
You don’t need to create a separate queryset or pagination system just for the cards. HorillaCardView already gets most of its behavior from HorillaListView.
The main thing you need to decide is which fields should appear on each card.
How columns Works in a Card View
The columns attribute works a little differently in a card view than it does in a table.
Suppose you define:
columns = [
"title",
"first_name",
"email",
"lead_status",
"lead_source",
]
Horilla uses the first field as the main title of the card.
The second field is displayed as the subtitle.
The remaining fields are shown as additional label and value pairs.
Conceptually, the card will look something like this:
Website Redesign Proposal
John
—————————-
Email: john@example.com
Lead Status: New
Lead Source: Website
This is why it’s a good idea to keep the columns list short.
A card isn’t meant to replace every field from your model. It should contain just enough information for someone to recognize the record and decide what to do next.
For example, a contact card might only need:
columns = [
"first_name",
"title",
"email",
"contact_number",
]
That usually gives users a much cleaner view.
Defaults Provided by HorillaCardView
Because HorillaCardView is designed specifically for cards, it changes a few defaults inherited from HorillaListView.
The important ones are:
| Attribute | Default | Purpose |
| template_name | card_view.html | Uses the card layout |
| paginate_by | 24 | Loads 24 cards at a time |
| supports_quick_filters | False | Quick filters are disabled by default |
| bulk_select_option | False | Card items don’t have selection checkboxes |
| table_class | False | Table-specific styling isn’t required |
| table_width | False | Not relevant to the card layout |
These defaults make sense for a card interface.
For example, loading 100 records at once might be reasonable for a table, but cards generally contain more visual information, so loading a smaller number initially makes the page easier to handle.
You can still customize the inherited list-view attributes when your module needs them.
Reusing Your Existing List View
One of the biggest advantages of HorillaCardView is that you don’t need to duplicate your list-view logic.
Since it extends HorillaListView, the existing filtering and queryset behavior is available automatically.
You can also add module-specific filtering when necessary.
For example:
class LeadCardView(LoginRequiredMixin, HorillaCardView):
model = Lead
view_id = "leads-card"
filterset_class = LeadFilter
columns = [
"title",
"first_name",
"email",
"lead_status",
"lead_source",
]
actions = LeadListView.actions
def get_queryset(self):
queryset = super().get_queryset()
view_type = (
self.request.GET.get("view_type")
or self.get_default_view_type()
)
if view_type == "converted_lead":
queryset = queryset.filter(is_convert=True)
self.actions = None
self.no_record_add_button = False
self.no_record_msg = "Not found converted leads"
else:
queryset = queryset.filter(is_convert=False)
return queryset
The important part here is:
queryset = super().get_queryset()
You should call the parent implementation first.
That allows the normal list-view filtering and ownership logic to run before you apply your additional conditions.
This is particularly useful when your application already has a list view with custom queryset behavior.
Making the Card Title Clickable
A card becomes much more useful when users can click its title and open the corresponding record.
HorillaCardView uses col_attrs for this, just like HorillaListView.
For example:
from functools import cached_property
from urllib.parse import urlencode
@cached_property
def col_attrs(self):
query_params = {}
if "section" in self.request.GET:
query_params["section"] = self.request.GET.get("section")
query_string = urlencode(query_params)
return [
{
"title": {
"hx-get": f"{{get_detail_url}}?{query_string}",
"hx-target": "#mainContent",
"hx-swap": "outerHTML",
"hx-push-url": "true",
"hx-select": "#mainContent",
"permission": "leads.view_lead",
"own_permission": "leads.view_own_lead",
"owner_field": "lead_owner",
}
}
]
The {get_detail_url} placeholder is resolved for each record, so every card gets the correct detail URL.
The HTMX attributes handle the navigation without requiring a full page reload.
For example:
hx-target=”#mainContent”
tells HTMX where to place the response, while:
hx-push-url=”true”
updates the browser URL as the user navigates.
That also means normal browser back and forward navigation can continue to work.
Controlling Who Can Open the Record
The permission settings inside col_attrs are important as well.
For example:
“permission”: “leads.view_lead”,
“own_permission”: “leads.view_own_lead”,
“owner_field”: “lead_owner”,
These determine whether the title should be clickable for the current user.
If the user doesn’t have the required permission, Horilla can still display the title, but it won’t make it a clickable link.
There isn’t a separate setting specifically for card-title permissions. The existing col_attrs permission mechanism handles it.
Reusing Configuration from the List View
If your list view already has actions, col_attrs, and other configuration, you don’t need to copy the same code into the card view.
You can reuse it directly.
For example:
class ContactCardView(LoginRequiredMixin, HorillaCardView):
model = Contact
columns = [
"first_name",
"title",
"email",
"contact_number",
"birth_date",
]
col_attrs = ContactListView.col_attrs
actions = ContactListView.actions
no_record_add_button = ContactListView.no_record_add_button
This keeps the two views consistent.
It also reduces duplication.
If the actions or permissions change later, you’re less likely to end up with one configuration in the list view and a different configuration in the card view.
Card Actions
The actions defined for the list view can also be used by the card view.
For example, if your list view already defines actions such as:
- Edit
- Delete
- Duplicate
- Change Owner
those actions can appear in the menu associated with each card.
This means you don’t need to create a completely separate action system for the card layout.
You can simply reuse the existing actions:
actions = ContactListView.actions
This is one of the practical benefits of building the card view on top of HorillaListView.
The Avatar on Each Card
Each card also includes a small circular avatar.
You don’t need to configure an image field for this.
Horilla automatically generates the avatar from the value of the first column.
For example, if the first field contains:
Website Redesign Proposal
the generated short name can appear as:
WR
The template handles this automatically:
<div class="rounded-full w-10 h-10 overflow-hidden flex-shrink-0 bg-primary-500">
<div class="bg-primary-400 text-primary-600 w-full h-full flex items-center justify-center font-medium">
{{ data|get_field:first_cell.1|default:data.id|shortname }}
</div>
</div>
So there is no need to manually generate initials for every record.
The avatar is based on the first displayed field, which keeps the implementation simple.
Infinite Scrolling with HTMX
A card grid can quickly become large.
Imagine a CRM with several hundred leads. Loading every card when the page opens would be unnecessary and could make the initial page slower.
HorillaCardView avoids that by loading records in pages.
The default is 24 records per page.
When the user reaches the bottom of the current cards, HTMX requests the next page.
The view distinguishes between a normal request and an HTMX request:
def render_to_response(self, context, **response_kwargs):
is_htmx = self.request.headers.get("HX-Request") == "true"
context["request_params"] = self.request.GET.copy()
if is_htmx:
page_kwarg = getattr(self, "page_kwarg", "page")
if self.request.GET.get(page_kwarg):
html = render_to_string(
"partials/card_view_load_more.html",
context,
request=self.request,
)
return HttpResponse(html)
return render(
self.request,
"card_view.html",
context,
)
return super(
HorillaListView,
self
).render_to_response(
context,
**response_kwargs
)
The idea is simple:
- A normal request renders the complete card page.
- An HTMX request without a page parameter can render the card page as well.
- An HTMX request with a page parameter is treated as a request for more cards.
- Only the additional cards are returned for that request.
This prevents the browser from loading the entire dataset at once.
How the Load-More Trigger Works
The next page is loaded automatically using a small sentinel element at the bottom of the grid.
The template contains something similar to:
{% if has_next %}
<div
class="htmx-sentinel col-span-full"
hx-get="{{ request.path }}?{{ search_params }}&page={{ next_page }}"
hx-target="this"
hx-swap="outerHTML"
hx-select="#data-container-{{ view_id }} .card-item, #data-container-{{ view_id }} .htmx-sentinel"
hx-trigger="intersect once"
hx-indicator="#loadingIndicator"
></div>
{% endif %}
The key part is:
hx-trigger=”intersect once”
When the sentinel comes into view, HTMX automatically requests the next page.
The new cards are then inserted into the existing grid.
From the user’s perspective, it simply feels like the list keeps loading as they scroll.
No separate pagination controls are required.
Responsive Card Layout
The default card grid is responsive.
The template uses:
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 p-4 bg-surface">
{% include "partials/card_view_cards.html" %}
</div>
This gives you:
- One column on smaller screens
- Two columns on medium-sized screens
- Four columns on larger screens
The layout is handled by the template and Tailwind CSS classes rather than by a Python view setting.
So if you need a different number of columns for a particular module, that would normally be handled at the template/CSS level.
A Complete Card View Example
Here is how everything can come together in a campaign module:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from horilla.contrib.generics.views import HorillaCardView
from horilla.utils.decorators import (
htmx_required,
permission_required_or_denied,
)
from .models import Campaign
from .filters import CampaignFilter
from .views import CampaignListView
@method_decorator(htmx_required, name="dispatch")
@method_decorator(
permission_required_or_denied(
[
"campaigns.view_campaign",
"campaigns.view_own_campaign",
]
),
name="dispatch",
)
class CampaignCardView(LoginRequiredMixin, HorillaCardView):
model = Campaign
view_id = "campaign-card"
filterset_class = CampaignFilter
search_url = reverse_lazy(
"campaigns:campaign_list_view"
)
main_url = reverse_lazy(
"campaigns:campaign_view"
)
columns = [
"campaign_name",
"campaign_owner",
"campaign_type",
"status",
"expected_revenue",
]
actions = CampaignListView.actions
col_attrs = CampaignListView.col_attrs
no_record_add_button = CampaignListView.no_record_add_button
That’s really all the module-specific code you need in many cases.
The model tells Horilla what records to display.
The filterset provides filtering.
The columns list controls the information shown on the cards.
The existing list-view configuration provides actions and clickable behavior.
The inherited HorillaListView functionality takes care of the common list behavior.
A Minimal Example
If you don’t need any custom behavior, the implementation can be even smaller:
class ContactCardView(LoginRequiredMixin, HorillaCardView):
model = Contact
filterset_class = ContactFilter
columns = [
"first_name",
"email",
]
You can then add more configuration as the requirements grow.
For example:
enable_quick_filters = True
if the card view needs quick filters.
Or:
actions = ContactListView.actions
col_attrs = ContactListView.col_attrs
if you want to reuse the existing list-view behavior.
For custom record filtering, you can override get_queryset() and build on the inherited queryset.
When HorillaCardView Makes Sense
A card layout works especially well when users don’t need to compare dozens of fields at the same time.
For example, a lead card might show:
John Smith
Sales Manager
Email: john@example.com
Status: New
Source: Website
That’s enough information for someone to recognize the lead and choose an action.
A table might still be better when users need to compare many records across the same fields. But when quick scanning is more important, cards can provide a much cleaner experience.
HorillaCardView provides a simple way to turn an existing list-based view into a responsive card grid without rebuilding the underlying functionality.
Because it extends HorillaListView, you can continue using the filtering, sorting, queryset handling, ownership rules, permissions, and actions that already exist in your application.
The card view adds the presentation layer: records are displayed as individual cards, complete with a generated avatar, useful fields, actions, and HTMX-powered infinite scrolling.
In many cases, getting started requires only three things:
model = Contact
filterset_class = ContactFilter
columns = [
"first_name",
"email",
]
From there, you can reuse the configuration from your existing list view or add custom behavior when needed.
The result is a card-based interface that feels different from a traditional table, while still fitting naturally into the existing Horilla view architecture.