Horilla CRM already provides several layouts for list pages. Depending on the page, users can switch between List, Kanban, Card, Timeline, Group By, Chart, and Split View. Most of the time, these layouts are enough. But sometimes a page needs a completely different way of displaying its records.

A hierarchy or tree view is a good example. It doesn’t fit into the existing layouts, so adding another built-in layout just for one page wouldn’t make much sense.

Horilla CRM handles this through custom_layouts.

The important thing to understand is that custom_layouts is not a separate registration system. It is simply a dictionary defined on the page’s HorillaView and its corresponding HorillaNavView.

Where Custom Layouts Are Defined

Two classes are involved when adding a custom layout:

  • HorillaView controls the main content area.
  • HorillaNavView controls the navbar and layout switcher.

Both classes have a custom_layouts attribute:

custom_layouts: dict = {}

If you add a custom layout, it needs to be defined on both the view and its paired navbar. The two definitions should use the same layout key and URL so that the layout switcher and the content area stay in sync.

A Real Example from Horilla

The Roles page currently provides the clearest example. It has a custom Hierarchy layout.

In horilla/contrib/core/views/roles.py, the main view defines it like this:

class RolesView(LoginRequiredMixin, HorillaView):
    template_name = "settings/settings_list_shell.html"
    view_id = "role-view"
    default_layout = "hierarchy"
    nav_url = reverse_lazy("core:roles_nav_bar")
    list_url = reverse_lazy("core:role_list_view")
    custom_layouts = {
        "hierarchy": {
            "url": reverse_lazy("core:roles_hierarchy_view"),
            "icon": "assets/icons/hierarchy.svg",
            "label": _("Hierarchy"),
        },
    }

The key is “hierarchy”. It identifies the layout throughout the view system.

The URL points to the view that actually renders the hierarchy, while the icon and label are used by the layout switcher.

The navbar defines the same layout:

class RoleNavbar(LoginRequiredMixin, HorillaNavView):
    nav_title = Role._meta.verbose_name_plural
    search_url = reverse_lazy("core:role_list_view")
    main_url = reverse_lazy("core:roles_view")
    default_layout = "hierarchy"


    custom_layouts = {
        "hierarchy": {
            "url": reverse_lazy("core:roles_hierarchy_view"),
            "icon": "assets/icons/hierarchy.svg",
            "label": _("Hierarchy"),
        },
    }

This duplication is intentional. The main view needs to know where the custom content lives, while the navbar needs the same information to build the layout switcher.

What default_layout Does

If the custom layout should be the initial layout for the page, set default_layout to the custom key:

default_layout = “hierarchy”

Without a layout parameter in the URL, Horilla uses this value to decide which layout should be displayed.

For example:

?layout=hierarchy

explicitly requests the hierarchy layout.

If no layout parameter is provided, default_layout takes over.

This is particularly useful for pages where the custom layout makes more sense than the standard list view.

How Horilla Resolves the Layout

The layout URL is resolved by HorillaView.get_layout_url().

The process is straightforward.

First, Horilla checks the layout query parameter. If the requested value matches one of the built-in layouts or a key in custom_layouts, and it has a valid URL, that URL is used.

If there is no valid query parameter, Horilla checks default_layout.

Finally, it falls back to the older URL behaviour. If list_url is available, it uses that; otherwise, it can fall back to kanban_url.

This means a custom layout doesn’t require changes to the base view. The page simply tells Horilla which additional layouts it supports.

How the Layout Switcher Finds It

The navbar has its own layout resolution through get_all_layouts().

It first collects the built-in layouts whose URLs are available:

list
card
kanban
timeline
group_by
chart
split_view

Then it adds the entries from custom_layouts.

Because the custom dictionary is processed in insertion order, you can add your custom layout without modifying the existing layout list.

The navbar template can therefore use the same generic layout dropdown instead of having separate template logic for every possible layout.

That is one of the useful parts of this approach: adding a new layout does not require changing the shared navbar template.

Custom Layouts Are Not a Decorator Registry

This is worth pointing out because Horilla has several registry-based features.

For example, some menu systems use registration functions and automatic imports. custom_layouts works differently.

There is no:

register_layout(…)

and there is no module that needs to be added to an automatic import list.

It is just a class attribute:

custom_layouts = {
    "hierarchy": {
        ...
    }
}

Horilla reads it when handling the request.

This also means the configuration stays close to the view that actually uses it.

One Thing to Watch: Navbar Actions

There is one limitation to keep in mind.

The built-in HorillaNavView action handling knows about the standard layout names. For example, it has special handling for things such as Kanban Settings and Group By Settings.

A custom layout doesn’t automatically get a corresponding action just because it was added to custom_layouts.

The Roles navbar handles this explicitly:

def get_context_data(self, kwargs): context = super().get_context_data(kwargs)
effective = context.get(“effective_layout”, “list”)
context[“search_option”] = effective == “list”
context[“filter_option”] = effective == “list”
return context

This allows the navbar to change its available options depending on the active layout.

If your custom layout needs its own navbar behaviour, overriding get_context_data() in the navbar is the appropriate place to handle it.

Adding Your Own Custom Layout

For a new page, the basic structure is:

class MyView(LoginRequiredMixin, HorillaView):
    template_name = "my_app/my_list_shell.html"
    default_layout = "tree"
    list_url = reverse_lazy("my_app:my_list")


    custom_layouts = {
        "tree": {
            "url": reverse_lazy("my_app:my_tree_view"),
            "icon": "assets/icons/tree.svg",
            "label": _("Tree"),
        },
    }

Then add the same configuration to the corresponding navbar:

class MyNavbar(LoginRequiredMixin, HorillaNavView):
    main_url = reverse_lazy("my_app:my_view")
    search_url = reverse_lazy("my_app:my_list")
    default_layout = "tree"


    custom_layouts = {
        "tree": {
            "url": reverse_lazy("my_app:my_tree_view"),
            "icon": "assets/icons/tree.svg",
            "label": _("Tree"),
        },
    }

The important parts are the matching key, URL, icon, and label.

Once these are in place, the custom layout becomes part of the normal layout-switching mechanism.

Final Checklist

Before considering the layout complete, check the following:

  • Define custom_layouts on the main HorillaView.
  • Define the same custom_layouts on the paired HorillaNavView.
  • Use the same layout key in both places.
  • Set default_layout if the custom layout should be the default.
  • Make sure the custom URL points to the view that renders the layout.
  • Add an appropriate icon and translated label.
  • Handle custom navbar actions yourself if the built-in action logic doesn’t cover them.
  • No decorator registration or automatic import is required.

Horilla’s custom layout support keeps the layout system flexible without making the generic views more complicated. The built-in layouts continue to work as they always have, while individual pages can add something completely different when their data needs it.

The main idea is simple: define the custom layout on both the content view and its navbar, give it a unique key and URL, and let Horilla’s existing layout-switching system handle the rest.

The Roles hierarchy view shows how this works in practice. For other pages, the same pattern can be used for tree views, specialized dashboards, calendars, or any other layout that doesn’t belong in Horilla’s fixed set of built-in layouts.

Share this article