All configuration lives in UNFOLD dict in Django settings:
# settings.py
from django.templatetags.static import static
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
UNFOLD = {
# Site identity
"SITE_TITLE": _("My Admin"), # <title> tag suffix
"SITE_HEADER": _("My Admin"), # sidebar header text
"SITE_SUBHEADER": _("Admin portal"), # text below header
"SITE_URL": "/", # header link target
# Icons and logos
"SITE_SYMBOL": "dashboard", # Material Symbols icon name
"SITE_ICON": lambda request: static("img/icon.svg"),
"SITE_LOGO": lambda request: static("img/logo.svg"),
"SITE_FAVICONS": [
{"rel": "icon", "sizes": "32x32", "type": "image/svg+xml", "href": lambda request: static("img/favicon.svg")},
],
# Light/dark variants for icon and logo
# "SITE_ICON": {
# "light": lambda request: static("img/icon-light.svg"),
# "dark": lambda request: static("img/icon-dark.svg"),
# },
# UI toggles
"SHOW_HISTORY": True, # history button on change form
"SHOW_VIEW_ON_SITE": True, # "view on site" button
"SHOW_BACK_BUTTON": False, # back button on change forms
# Theme
"THEME": None, # None (auto), "dark", or "light"
"BORDER_RADIUS": "6px", # global border radius
# Colors (OKLCH format)
"COLORS": {
"base": {
"50": "250 250 250",
"100": "244 245 245",
# ... full scale 50-950
"950": "10 10 10",
},
"primary": {
"50": "250 245 255",
"100": "243 232 255",
# ... full scale 50-950
"950": "59 7 100",
},
"font": {
"subtle-light": "var(--color-base-500)",
"subtle-dark": "var(--color-base-400)",
"default-light": "var(--color-base-700)",
"default-dark": "var(--color-base-200)",
"important-light": "var(--color-base-900)",
"important-dark": "var(--color-base-100)",
},
},
# Callbacks
"ENVIRONMENT": "myapp.utils.environment_callback",
"ENVIRONMENT_TITLE_PREFIX": None,
"DASHBOARD_CALLBACK": "myapp.views.dashboard_callback",
# Login page
"LOGIN": {
"image": lambda request: static("img/login-bg.jpg"),
"redirect_after": "/admin/",
"form": "myapp.forms.LoginForm",
},
# Custom assets
"STYLES": [lambda request: static("css/custom.css")],
"SCRIPTS": [lambda request: static("js/custom.js")],
# Language
"SHOW_LANGUAGES": False,
"LANGUAGE_FLAGS": {"en": "US", "de": "DE"},
# Sidebar
"SIDEBAR": { ... }, # see below
# Tabs
"TABS": [ ... ], # see below
# Command palette
"COMMAND": { ... }, # see below
}
Use Material Symbols names for SITE_SYMBOL and all navigation icons.
"SITE_SYMBOL": "speed", # displayed in sidebar when logo not set
SITE_LOGO - larger logo for sidebar headerSITE_ICON - smaller icon (fallback when no logo)lambda request: url_string or a dict with light/dark keysAdd links to the site header dropdown:
"SITE_DROPDOWN": [
{
"icon": "description",
"title": _("Documentation"),
"link": "https://docs.example.com",
},
{
"icon": "code",
"title": _("API Reference"),
"link": "/api/docs/",
},
],
"SIDEBAR": {
"show_search": True, # search bar in sidebar
"show_all_applications": True, # "All Applications" link
"command_search": True, # command palette integration
"navigation": [
{
"title": _("Main"),
"collapsible": False, # default: not collapsible
"items": [
{
"title": _("Dashboard"),
"icon": "dashboard",
"link": reverse_lazy("admin:index"),
},
{
"title": _("Users"),
"icon": "people",
"link": reverse_lazy("admin:auth_user_changelist"),
"badge": "myapp.utils.users_badge", # callable returning badge text
"badge_variant": "danger", # primary|success|info|warning|danger
"badge_style": "solid", # solid|outline
"permission": "myapp.utils.perm_check", # callable(request) -> bool
},
{
"title": _("Products"),
"icon": "inventory_2",
"active": "myapp.utils.products_active_callback", # custom active logic
"items": [ # nested subitems
{
"title": _("All Products"),
"link": reverse_lazy("admin:shop_product_changelist"),
},
{
"title": _("Categories"),
"link": reverse_lazy("admin:shop_category_changelist"),
},
],
},
],
},
{
"title": _("Settings"),
"collapsible": True,
"items": [ ... ],
},
],
},
| Field | Type | Description |
|---|---|---|
title |
str | Display text |
icon |
str | Material Symbols icon name |
link |
str/lazy | URL (use reverse_lazy) |
badge |
str/callable | Badge text or "dotted_path.to.callback" |
badge_variant |
str | primary, success, info, warning, danger |
badge_style |
str | solid, outline |
permission |
str/callable | "dotted_path.to.callback" returning bool |
active |
str/callable | Custom active state logic |
items |
list | Nested sub-navigation items |
collapsible |
bool | Group-level: allow collapse |
def users_badge(request):
return User.objects.filter(is_active=False).count() or None
def permission_callback(request):
return request.user.has_perm("myapp.view_sensitive")
def products_active_callback(request):
return request.path.startswith("/admin/shop/")
Tabs appear above the changelist, linking related models or filtered views:
"TABS": [
{
"models": [
"shop.product",
"shop.category",
{"name": "shop.order", "detail": True}, # show tabs on detail/change pages too
],
"items": [
{
"title": _("Products"),
"link": reverse_lazy("admin:shop_product_changelist"),
},
{
"title": _("Categories"),
"link": reverse_lazy("admin:shop_category_changelist"),
},
],
},
{
"page": "users", # custom page identifier
"models": ["auth.user"],
"items": [
{
"title": _("All Users"),
"link": reverse_lazy("admin:auth_user_changelist"),
"active": lambda request: "status" not in request.GET,
},
{
"title": _("Active"),
"link": lambda request: f"{reverse_lazy('admin:auth_user_changelist')}?is_active__exact=1",
},
{
"title": _("Staff"),
"link": lambda request: f"{reverse_lazy('admin:auth_user_changelist')}?is_staff__exact=1",
},
],
},
],
Models in the models list accept two formats:
"models": [
"app.model", # string: tabs on changelist only
{"name": "app.model", "detail": True}, # dict: also show tabs on change form
]
| Field | Type | Description |
|---|---|---|
title |
str | Tab label |
link |
str/callable | URL or lambda request: url |
permission |
str/callable | Visibility control |
active |
callable | lambda request: bool for custom active state |
Unfold uses OKLCH color space with a 50-950 scale. Override base and primary palettes:
"COLORS": {
"base": {
"50": "250 250 250",
"100": "245 245 245",
"200": "229 229 229",
"300": "212 212 212",
"400": "163 163 163",
"500": "115 115 115",
"600": "82 82 82",
"700": "64 64 64",
"800": "38 38 38",
"900": "23 23 23",
"950": "10 10 10",
},
"primary": {
"50": "238 242 255",
"100": "224 231 255",
"200": "199 210 254",
"300": "165 180 252",
"400": "129 140 248",
"500": "99 102 241",
"600": "79 70 229",
"700": "67 56 202",
"800": "55 48 163",
"900": "49 46 129",
"950": "30 27 75",
},
},
"BORDER_RADIUS": "6px", # applies globally
"THEME": "dark", # disables the light/dark toggle
Display a colored badge in the header (e.g., "Development", "Staging"):
"ENVIRONMENT": "myapp.utils.environment_callback",
Callback implementation:
def environment_callback(request):
"""Return (name, color_type) or None."""
if settings.DEBUG:
return _("Development"), "danger" # red badge
if "staging" in request.get_host():
return _("Staging"), "warning" # orange badge
return None # no badge in production
Color types: "danger", "warning", "info", "success".
Optional title prefix:
"ENVIRONMENT_TITLE_PREFIX": _("DEV"), # prepends to <title>
"LOGIN": {
"image": lambda request: static("img/login-bg.jpg"),
"redirect_after": "/admin/",
"form": "myapp.forms.LoginForm", # custom form class
},
Configure the Ctrl+K / Cmd+K command palette:
"COMMAND": {
"search_models": True, # Default: False. Also accepts list/tuple or callback
# "search_models": ["myapp.mymodel"], # specific models
# "search_models": "myapp.utils.search_models_callback", # callback
"search_callback": "myapp.utils.search_callback", # custom search hook
"show_history": True, # command history in localStorage
},
from unfold.dataclasses import SearchResult
def search_callback(request, search_term):
return [
SearchResult(
title="Some title",
description="Extra content",
link="https://example.com",
icon="database",
)
]
Load additional CSS/JS files:
"STYLES": [
lambda request: static("css/admin-overrides.css"),
],
"SCRIPTS": [
lambda request: static("js/admin-charts.js"),
],
"SHOW_LANGUAGES": True,
"LANGUAGE_FLAGS": {
"en": "US",
"de": "DE",
"fr": "FR",
},
See the Formula demo settings for a production-ready reference.