Sometimes, a full-page list isn’t the best way to work with records. When you’re going through leads, contacts, campaigns, or similar data, it can be much more convenient to see the list on one side and the selected record’s details on the other.

This is where a split view becomes useful. Instead of opening a new page every time you want to check a record, you can keep the list visible and load the selected record’s details in a separate panel. This also means users don’t lose their current filters, sorting, pagination, or position in the list.

Building this kind of interface from scratch can take quite a bit of work. You would need to create the two-panel layout, connect the detail panel with HTMX, handle filtering and pagination, add previous and next navigation, and make sure permissions are checked correctly.

Horilla provides HorillaSplitView to handle most of this for you.

HorillaSplitView is available in horilla/contrib/generics/views/split_view.py and extends HorillaListView. It provides a scrollable tile list on the left and an HTMX-powered detail panel on the right. Since it inherits from HorillaListView, it also reuses the existing filtering, quick filters, sorting, ownership filtering, and pagination logic.

1. Creating a Basic Split View

If you’ve already worked with HorillaListView, using HorillaSplitView should feel familiar.

A basic split view needs a model, the columns you want to display, and a way to open the detail page for each record.

from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy


from horilla.contrib.generics.views import HorillaSplitView


from .models import Lead
from .filters import LeadFilter




class LeadSplitView(LoginRequiredMixin, HorillaSplitView):


    model = Lead


    view_id = "leads-split"


    filterset_class = LeadFilter


    search_url = reverse_lazy("leads:leads_list")


    main_url = reverse_lazy("leads:leads_view")


    columns = ["title", "lead_status"]

That’s enough to get a basic split page running.

The left side displays a list of tiles based on the values in columns. When a user clicks a tile, its detail view is loaded into the right-hand panel.

For this to work, the model should provide a get_detail_url() method. If that isn’t available, Horilla can fall back to get_detail_view_url().

The detail page should also use HorillaDetailView, which already supports the split-view layout.

2. Split-Specific Defaults

HorillaSplitView already comes with several defaults that make the layout work without much configuration.

AttributeDefaultPurpose
template_namesplit_view.htmlTemplate used for the two-panel layout
list_column_visibilityFalseUses the columns defined by the split view
bulk_select_optionFalseBulk selection isn’t used for tiles
table_classFalseUses a card/tile layout instead of a table
table_widthFalseKeeps the panels flexible
paginate_by50Number of tiles loaded per page
split_detail_target#splitViewDetailPanelHTMX target for the detail panel
split_layout_paramlayout=splitTells the detail view to return the split fragment

In most cases, you won’t need to change these settings. You only need to override them when your template or URL structure requires something different.

3. How columns Works

There is a small difference between columns in a normal HorillaListView and a split view.

In a normal list view, the columns are used to build a table. In HorillaSplitView, the first one or two columns are used to create each tile.

For example:

columns = ["title", "lead_status"]

Here, title becomes the main heading of the tile, while lead_status appears underneath it as a smaller subtitle.

Because the split view only needs enough information to identify the record, it’s better to keep this list short. You generally don’t need to add every field from the model.

4. Reusing the Existing List Logic

One of the biggest advantages of HorillaSplitView is that it extends HorillaListView.

That means you automatically get things such as:

  • Filterset filtering
  • Quick filters
  • Sorting
  • Owner-based filtering
  • Pagination

You don’t need to recreate the queryset logic just because you’re using a split layout.

For example, a lead split view can still customize its queryset when the application needs different record sets.

class LeadSplitView(LoginRequiredMixin, HorillaSplitView):


    model = Lead


    view_id = "leads-split"


    filterset_class = LeadFilter


    search_url = reverse_lazy("leads:leads_list")


    main_url = reverse_lazy("leads:leads_view")


    enable_quick_filters = True


    split_view_permission = "leads.view_lead"


    split_view_own_permission = "leads.view_own_lead"


    split_view_owner_field = "lead_owner"


    columns = ["title", "lead_status"]


    no_record_add_button = LeadListView.no_record_add_button


    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.bulk_update_option = False


        else:
            queryset = queryset.filter(is_convert=False)


        return queryset

The important part here is calling super().get_queryset() first.

That allows the normal list filtering and permission logic to remain in place. You can then add your own module-specific filtering on top of it.

This approach is particularly useful when the same split view needs to behave differently based on something like a view_type parameter.

5. Loading Record Details Into the Right Panel

When a user clicks a tile, HorillaSplitView creates an hx-get request using the record’s detail URL.

It also adds:

?layout=split

This tells HorillaDetailView that the request is coming from a split view and that it should return the smaller detail fragment rather than the complete page.

The model URL lookup works in this order:

  1. get_detail_url()
  2. get_detail_view_url()
  3. If neither exists, Horilla logs a warning and the tile won’t be able to load the details.

For example:

def get_detail_url(self):
    return reverse_lazy(
        "leads:leads_detail",
        kwargs={"pk": self.pk}
    )

On the detail-view side, the template can be selected based on the layout parameter:

def get_template_names(self):

def get_template_names(self):
    """Use fragment template when layout=split for split-view right panel."""


    if self.request.GET.get("layout") == "split":
        return ["detail_view_split_fragment.html"]


    return super().get_template_names()

This allows the same detail view to work in two different situations:

  • As a normal full-page detail view
  • As a compact fragment inside the split view

You don’t need to create a separate detail view just for the split layout.

6. Automatically Loading the First Record

A split view usually feels better when the detail panel isn’t empty when the page first opens.

HorillaSplitView handles this automatically.

When the queryset contains records, it creates a URL for the first record and places it on the detail panel. HTMX then loads that URL when the panel is rendered.

The basic template looks like this:

<div
    id="splitViewDetailPanel"
    class="flex-1 min-w-0 bg-white rounded-lg border border-primary-200"
    {% if split_first_detail_url %}
        hx-get="{{ split_first_detail_url }}"
        hx-trigger="load"
        hx-swap="innerHTML"
        hx-indicator="#loading-indicator"
    {% endif %}
>
</div>

The result is simple: when the split page opens, users immediately see the first record’s details instead of an empty panel.

7. Previous and Next Record Navigation

A split view is especially useful when users need to go through several records one after another.

To support that, HorillaSplitView keeps track of the previous and next record for each item in the current queryset.

The view assigns these values like this:

for i, obj in enumerate(queryset):


    obj.split_next_id = (
        str(queryset[i + 1].id)
        if i + 1 < len(queryset)
        else ""
    )


    obj.split_prev_id = (
        str(queryset[i - 1].id)
        if i > 0
        else ""
    )

These IDs are then included in the tile’s HTMX URL as next_id and prev_id.

This is important because the navigation follows the current filtered and sorted list, rather than simply moving through every record in the database.

So if a user filters the list to a particular set of records, the previous/next controls stay within that result set.

8. Split-View Permissions

Permissions are another important part of a split view.

A user shouldn’t be able to open a record simply because its tile is visible. The tile click also needs to respect Horilla’s permission rules.

HorillaSplitView provides three attributes for this:

split_view_permission = "leads.view_lead"


split_view_own_permission = "leads.view_own_lead"


split_view_owner_field = "lead_owner"

These settings allow Horilla to distinguish between general view permission and ownership-based access.

For example, a user may not have the general view_lead permission but may still be allowed to view leads they own through the view_own_lead permission.

If the required permission or ownership condition isn’t satisfied, Horilla prevents the tile from receiving the HTMX detail request.

This keeps the split view consistent with the permission behavior used elsewhere in Horilla.

9. Reusing Actions and the Add Button

There’s no need to duplicate list-view configuration when the same behavior should apply to the split view.

You can reuse the existing list-view settings:

no_record_add_button = LeadListView.no_record_add_button


actions = LeadListView.actions

This is useful because it keeps the list and split views consistent.

If the list view already defines which actions are available and whether the add button should appear, the split view can use those same definitions.

10. Customizing the Detail Panel

The default detail panel target is:

#splitViewDetailPanel

But your template may use a different container ID.

In that case, you can change split_detail_target.

You can also customize the query parameter used to tell the detail view how to render the request.

For example:

class TicketSplitView(HorillaSplitView):


    model = Ticket


    split_detail_target = "#ticketDetailPane"


    split_layout_param = "layout=split&from=tickets"


    columns = ["subject", "status"]

Your template should then contain the matching element:

<div id=”ticketDetailPane”></div>

If you add custom query parameters, make sure the corresponding detail view knows how to handle them.

11. Keeping Tile Pagination Lightweight

A split view may contain hundreds or even thousands of records, so loading everything at once isn’t practical.

HorillaSplitView handles this with pagination and HTMX.

When HTMX requests another page of tiles, only the tile partial is returned instead of rendering the entire split page again.

The behavior is roughly:

  • HTMX request with a page parameter → returns partials/split_view_tiles.html
  • HTMX request without a page parameter → returns the complete split_view.html
  • Normal request → follows the standard list rendering path

The template uses an intersection-based sentinel to request the next page as the user scrolls:

{% if has_next %}


    <div
        class="htmx-sentinel split-view-sentinel"
        hx-get="{{ request.path }}?{{ search_params }}&page={{ next_page }}"
        hx-target="this"
        hx-swap="outerHTML"
        hx-select=".split-view-tile, .split-view-sentinel"
        hx-trigger="intersect once"
        hx-indicator="#splitViewLoadMore"
    ></div>


{% endif %}

This gives the left panel an infinite-scroll-like experience without requiring additional pagination logic in your view.

12. The Detail View in a Split Layout

HorillaSplitView handles the left side of the interface, but the detail view is what actually fills the right panel.

This is where HorillaDetailView and layout=split work together.

When layout=split is present, HorillaDetailView changes its behavior:

  • It uses detail_view_split_fragment.html
  • It builds the content using get_detail_section_body()
  • It applies split_excluded_fields in addition to the normal excluded fields
  • It replaces the regular action bar with a “View full detail” action

For example:

split_excluded_fields = ["internal_notes", "created_by"]

The detail section body can then exclude fields that aren’t useful in the compact split panel.

This is a useful way to keep the full detail page rich and complete while making the split-view version more focused.

You can keep things such as pipelines, breadcrumbs, tabs, and other full-page elements on the normal detail page without having to create another detail view class.

13. Putting Everything Together

Here’s a more complete example using a campaign split view:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy


from horilla.contrib.generics.views import HorillaSplitView


from .models import Campaign
from .filters import CampaignFilter
from .views import CampaignListView




class CampaignSplitView(LoginRequiredMixin, HorillaSplitView):
    """
    Campaign Split view: left = tile list, right = simple details on click.
    """


    model = Campaign


    view_id = "campaign-split"


    filterset_class = CampaignFilter


    search_url = reverse_lazy("campaigns:campaign_list_view")


    main_url = reverse_lazy("campaigns:campaign_view")


    enable_quick_filters = True


    split_view_permission = "campaigns.view_campaign"


    split_view_own_permission = "campaigns.view_own_campaign"


    split_view_owner_field = "campaign_owner"


    columns = [
        "campaign_name",
        "campaign_type",
        "campaign_owner",
        "status",
        "expected_revenue",
        "budget_cost",
    ]


    no_record_add_button = CampaignListView.no_record_add_button


    actions = CampaignListView.actions

The URL can then point to the split view:

path(

    "campaigns-layout-split/",

    views.CampaignSplitView.as_view(),

    name="campaign_split_view",

)

There are a few things worth noticing in this example.

The model and filterset provide the basic list behavior, while search_url and main_url connect the split view with the existing campaign URLs.

enable_quick_filters keeps the quick-filter functionality available.

The three permission attributes control access to the records.

The columns list determines the information displayed on each tile.

Finally, actions and no_record_add_button are reused from CampaignListView, avoiding duplicate configuration.

When Should You Use HorillaSplitView?

You don’t have to configure every available option to get started.

For a simple contact split view, you could begin with just:

class ContactSplitView(LoginRequiredMixin, HorillaSplitView):


    model = Contact


    filterset_class = ContactFilter


    columns = ["first_name", "email"]

That gives you the basic two-panel browsing experience.

As your module becomes more complex, you can add features as needed:

  • enable_quick_filters for quick-filter options
  • split_view_permission for general view access
  • split_view_own_permission and split_view_owner_field for ownership-based access
  • actions and no_record_add_button to reuse list-view behavior
  • get_queryset() for additional filtering based on the current view type
  • split_detail_target when your detail panel uses a custom ID
  • split_layout_param when you need additional query parameters
  • split_excluded_fields on the related HorillaDetailView when some fields shouldn’t appear in the compact detail panel

Creating a good master-detail interface usually involves more than simply putting two panels next to each other. The list needs to stay in sync with filtering and sorting, the detail panel needs to load without refreshing the page, previous and next navigation needs to follow the current result set, and every record still needs to respect the application’s permissions.

HorillaSplitView takes care of most of this by building on top of HorillaListView. You keep the existing list functionality while adding an HTMX-powered detail panel to the side.

In many cases, getting started only requires a model, a filterset, a small columns list, and the appropriate permission settings. The paired HorillaDetailView then handles the compact layout=split response.

That makes HorillaSplitView a practical option when you want users to browse records quickly without constantly switching between list and detail pages.

Share this article