When you’re working with a list of records, a table is usually enough. You can see the important fields, sort the records, apply filters, and quickly find what you’re looking for.

But tables aren’t always the best way to understand time.

For example, imagine an opportunity that was created in January and is expected to close in March. A table can show you the two dates, but it doesn’t immediately tell you how long the opportunity has been active.

The same applies to leads, campaigns, projects, tasks, or any other records that have a start and end date. When the duration matters, seeing those dates as a visual timeline can make the information much easier to understand.

That’s where Horilla’s HorillaTimelineView becomes useful.

Instead of displaying records as rows in a table, it represents them as horizontal bars on a date-based timeline. The bars can be grouped into rows, giving the view a layout similar to a Gantt chart.

The interesting part is that HorillaTimelineView doesn’t introduce an entirely separate viewing system. It builds on top of HorillaListView, so many of the things you already use in a normal list view—filters, columns, actions, permissions, and queryset handling—continue to work.

Let’s take a look at how it works and how to add one to a Horilla application.

1. Creating a Basic Timeline View

The first step is to create a view that inherits from HorillaTimelineView.

You provide the model, filtering configuration, URLs, columns, and, most importantly, the fields that define the start and end of each timeline bar.

For example, an opportunity timeline can be defined like this:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from horilla.contrib.generics.views.timeline import HorillaTimelineView


from .models import Opportunity
from .filters import OpportunityFilter
from .views import OpportunityListView




class OpportunityTimelineView(LoginRequiredMixin, HorillaTimelineView):
    model = Opportunity
    view_id = "opportunity-timeline"
    filterset_class = OpportunityFilter
    search_url = reverse_lazy("opportunities:opportunities_list")
    main_url = reverse_lazy("opportunities:opportunities_view")


    enable_quick_filters = True
    timeline_start_field = "created_at"
    timeline_end_field = "close_date"
    timeline_group_by_field = "stage"
    timeline_title_field = "name"


    columns = [
        "name",
        "amount",
        "close_date",
        "stage",
        "opportunity_type",
    ]


    actions = OpportunityListView.actions
    col_attrs = OpportunityListView.col_attrs

Here, each opportunity is represented by a bar that starts at created_at and ends at close_date.

The bars are grouped according to the opportunity’s stage, and the opportunity name is displayed as the title of the bar.

This makes the configuration fairly straightforward: tell Horilla which fields represent the timeline, and the view takes care of the rest.

2. How HorillaTimelineView Fits Into the Existing View System

One thing that makes HorillaTimelineView convenient is that it doesn’t work independently from the existing list-view architecture.

The relationship looks like this:

HorillaListView
└── HorillaTimelineView
├── OpportunityTimelineView
└── LeadTimelineView

In other words, HorillaTimelineView extends the functionality already provided by HorillaListView.

That means you don’t have to rebuild common list functionality just because you’re creating a timeline.

The timeline view changes some defaults to suit its layout:

AttributeList View DefaultTimeline View Default
template_name“list_view.html”“timeline_view.html”
bulk_select_optionTrueFalse
supports_quick_filtersTrueFalse
table_class / table_widthFalse
paginate_by100200

One small detail worth knowing is that quick filters are disabled by default for the timeline. If you want them, you can simply enable them:

enable_quick_filters = True

The familiar list-view properties such as columns, actions, col_attrs, header_attrs, filterset_class, search_url, main_url, and view_id still work as expected.

This also means you can often reuse configuration from an existing list view instead of defining everything again.

For example:

actions = OpportunityListView.actions
col_attrs = OpportunityListView.col_attrs

This keeps the timeline view consistent with the regular list view.

3. Configuring the Timeline Fields

The main difference between a normal list view and a timeline view is the timeline_* configuration.

These attributes tell Horilla how each record should be displayed on the timeline.

AttributeDefaultPurpose
timeline_start_fieldNoneDate/datetime field used as the start of the bar
timeline_end_fieldStart fieldDate/datetime field used as the end of the bar
timeline_fallback_end_fieldNoneFallback end date when the end date is ambiguous
timeline_title_fieldFirst columnField displayed as the bar’s label
timeline_group_by_fieldNoneField used to divide records into timeline rows

Start field

timeline_start_field defines where the timeline bar begins.

For example:

timeline_start_field = “created_at”

This means the bar starts from the record’s creation date.

This field is required. If it isn’t configured, Horilla cannot determine where the timeline should begin and displays a timeline_error instead of rendering the bars.

End field

The timeline_end_field determines where the bar ends.

For example:

timeline_end_field = “close_date”

If you don’t provide an end field, Horilla uses the start field as the end as well. In that case, the record is displayed as a single-day bar.

Title field

The timeline_title_field determines the text displayed on the timeline bar.

For example:

timeline_title_field = “name”

This makes the opportunity name appear on its corresponding bar.

Group-by field

You can also group timeline records into separate rows.

For example:

timeline_group_by_field = “stage”

With this configuration, opportunities belonging to different stages appear in different timeline rows.

4. Which Fields Can Be Used?

Not every model field can be used for a timeline.

The start and end fields need to be date-related fields. Horilla allows DateField and DateTimeField fields for these settings.

For example:

timeline_start_field = “created_at”
timeline_end_field = “close_date”

For grouping, Horilla supports fields such as choice fields with defined choices and ForeignKey fields.

This keeps the timeline configuration meaningful. A timeline needs something that represents time for its bars and something sensible to use for grouping.

5. Letting Users Change Timeline Fields

The fields defined on the view aren’t necessarily permanent.

Horilla allows users to change the timeline’s start, end, and grouping fields through the UI.

The selected values are passed through query parameters such as:

timeline_start
timeline_end
timeline_group_by
group_by

An important detail here is that the user’s selection can also be remembered.

Once a user changes the timeline configuration, Horilla saves those settings for that user and can restore them on the next visit.

This is useful when different users need to look at the same records from different perspectives.

For example, one user might want to see:

Created Date → Close Date

while another might prefer:

Start Date → Updated Date

without requiring developers to create separate timeline views.

You can also control which fields appear in the grouping picker using:

exclude_kanban_fields
include_kanban_fields

These work in the same way as the corresponding configuration in HorillaGroupByView.

6. Grouped Rows, Record Counts, and Timeline Scale

Timeline grouping works a little differently from the grouping you see in HorillaGroupByView.

A timeline uses a single level of grouping. Each group becomes a row, or lane, on the timeline.

Each row represents one group, and Horilla displays a count next to the group label.

If no grouping field is configured or resolved, all records are displayed together in a single row called “All“.

Timeline scale

The timeline can be displayed using different scales:

  • Days
  • Weeks
  • Months
  • Quarters

The scale is controlled through the timeline_scale query parameter.

The default scale is:

months

So the timeline will normally use months as its main date axis.

Horilla also automatically determines a suitable visible date range based on the records being displayed. When records are available, the range is fitted around their start and end dates with some additional margin.

If there are no records, it uses a default window extending approximately 90 days into the past and 30 days into the future.

7. One Important Detail: The Timeline URL

There is one behavior of HorillaTimelineView that’s important when integrating it into an application.

A normal, non-HTMX GET request directly to the timeline URL doesn’t simply render the timeline page.

Instead, the view redirects the request to the main view with:

layout=timeline

and preserves the current query parameters.

This is intentional.

The timeline is designed to work inside the same parent shell that handles the other layouts, such as list, kanban, and group-by views.

Because of this, you should normally enter the timeline through the parent shell view instead of opening the timeline endpoint directly as a standalone page.

This approach allows different layouts to share the same navigation and page structure.

8. Adding the Timeline to the Parent View

Once the timeline view itself is ready, you need to connect it to the parent view.

Suppose the opportunity module already has URLs for list, kanban, and group-by views.

You can add the timeline URL alongside them:

class OpportunityView(LoginRequiredMixin, HorillaView):
nav_url = reverse_lazy(“opportunities:opportunities_nav”)
list_url = reverse_lazy(“opportunities:opportunities_list”)
kanban_url = reverse_lazy(“opportunities:opportunities_kanban”)
group_by_url = reverse_lazy(“opportunities:opportunities_group_by”)
timeline_url = reverse_lazy(“opportunities:opportunities_timeline”)

The important part is:

timeline_url = reverse_lazy(“opportunities:opportunities_timeline”)

Now the parent shell knows where to find the timeline layout.

9. Registering the Timeline URL

The next step is to add the timeline endpoint to urls.py.

For example:

path(
“opportunities-timeline/”,
views.OpportunityTimelineView.as_view(),
name=”opportunities_timeline”,
),

At this point, the pieces are connected:

Parent View

timeline_url

OpportunityTimelineView

HorillaTimelineView

Timeline layout

You don’t need separate endpoints for expanding rows or loading more records.

The timeline reuses the pagination and HTMX infrastructure provided by HorillaListView.

The timeline’s default pagination is 200 records per page of the underlying queryset. This is pagination of the records themselves, rather than pagination separately inside each timeline row.

10. Filtering the Queryset for a Specific Timeline

Since HorillaTimelineView ultimately builds on HorillaListView, you can override get_queryset() just like you would in a regular list view.

This becomes useful when different versions of the timeline need to show different subsets of records.

For example, the lead timeline can show only non-converted leads by default, while still supporting a converted-lead view.

class LeadTimelineView(LoginRequiredMixin, HorillaTimelineView):
    model = Lead
    view_id = "leads-timeline"
    filterset_class = LeadFilter
    search_url = reverse_lazy("leads:leads_list")
    main_url = reverse_lazy("leads:leads_view")


    enable_quick_filters = True
    timeline_start_field = "created_at"
    timeline_end_field = "updated_at"
    timeline_group_by_field = "lead_status"
    timeline_title_field = "title"


    columns = ["title", "first_name", "email", "lead_status"]


    actions = LeadListView.actions
    col_attrs = LeadListView.col_attrs


    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
        else:
            queryset = queryset.filter(is_convert=False)


        return queryset

The important thing here is that the timeline doesn’t force you into a special way of querying records.

You can still use Django’s normal queryset logic.

In this example:

  • Converted leads are filtered using is_convert=True.
  • Converted leads have their actions disabled.
  • The default timeline only shows non-converted leads.

This is one of the benefits of building the timeline on top of the existing list-view architecture.

11. Reusing Existing List View Configuration

You may have already spent time configuring your list view with actions, column attributes, permissions, and other settings.

The good news is that you don’t necessarily need to duplicate that work for the timeline.

For example:

actions = OpportunityListView.actions
col_attrs = OpportunityListView.col_attrs

This lets the timeline reuse the existing configuration.

The same idea applies to other list-view functionality inherited through HorillaListView.

So instead of treating the timeline as an entirely different feature, it’s better to think of it as another way of presenting the same underlying records.

The data and querying logic remain familiar; only the visual representation changes.

12. A Simple Timeline Configuration

For many use cases, the timeline-specific configuration can be very small.

You might only need these four properties:

timeline_start_field = "created_at"
timeline_end_field = "close_date"
timeline_group_by_field = "stage"
timeline_title_field = "name"

These four settings answer the main questions the timeline needs to know:

  • When does the record start?
  • When does it end?
  • Which row should it appear in?
  • What should be displayed on the bar?

Everything else can continue to come from the existing list-view infrastructure.

Adding a timeline view in Horilla doesn’t mean creating an entirely new system for displaying records.

HorillaTimelineView follows the same design philosophy as HorillaGroupByView: it extends the existing list-view infrastructure instead of replacing it.

That means your existing columns, filters, permissions, actions, queryset handling, and col_attrs can continue to work with the timeline.

The main configuration you usually need is simply:

timeline_start_field = “created_at”
timeline_end_field = “close_date”
timeline_group_by_field = “stage”
timeline_title_field = “name”

From there, connect the timeline_url to the parent shell, register the URL pattern, and you’re ready to use the timeline layout.

The result is a much more useful way to look at records where duration and timing matter. Instead of making users compare dates manually in a table, the timeline turns those dates into something they can understand at a glance.

And because it builds on top of HorillaListView, you get that visual timeline without having to throw away the infrastructure you’ve already built.

Share this article