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:

AttributeDefaultPurpose
template_namecard_view.htmlUses the card layout
paginate_by24Loads 24 cards at a time
supports_quick_filtersFalseQuick filters are disabled by default
bulk_select_optionFalseCard items don’t have selection checkboxes
table_classFalseTable-specific styling isn’t required
table_widthFalseNot 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.

Share this article