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.
| Attribute | Default | Purpose |
| template_name | split_view.html | Template used for the two-panel layout |
| list_column_visibility | False | Uses the columns defined by the split view |
| bulk_select_option | False | Bulk selection isn’t used for tiles |
| table_class | False | Uses a card/tile layout instead of a table |
| table_width | False | Keeps the panels flexible |
| paginate_by | 50 | Number of tiles loaded per page |
| split_detail_target | #splitViewDetailPanel | HTMX target for the detail panel |
| split_layout_param | layout=split | Tells 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:
- get_detail_url()
- get_detail_view_url()
- 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.