Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

4 min read

CSS Frameworks

A theme pack paints the surface — colors, type, motion. A CSS framework decides what class="…" your form fields and built-in components emit (form-control vs form-select vs a Tailwind utility chain). djust separates the two concerns: pick a theme pack for the look, pick a CSS framework for the markup, and the same Python code renders correctly under either.

This page covers the framework abstraction: the four shipped adapters, the class-name registry, how theme tokens flow into Bootstrap classes via the bridge, and how to add a fifth (Bulma, UIKit, your own).

The shipped adapters

Set the active adapter in LIVEVIEW_CONFIG:

LIVEVIEW_CONFIG = {
    'css_framework': 'tailwind',   # 'bootstrap5' | 'bootstrap4' | 'tailwind' | None
}
ValueWhat it emitsUse when
'bootstrap5'form-control, form-select, mb-3, btn btn-primaryDefault. Modern Bootstrap.
'bootstrap4'form-control, custom-select, form-group, btn btn-primaryLegacy projects, government / NYC Core Framework.
'tailwind'Long utility chains tuned for the v3/v4 default config.Tailwind projects.
None / 'plain'Empty class strings; semantic HTML only.No CSS framework, or you supply your own.

FormMixin, the built-in components, and the {% form %} template tag all delegate to the active adapter — no per-handler wiring.

The two layers

1. FrameworkAdapter — render-time logic

Each framework has rendering quirks (Bootstrap 5 wraps inputs differently than Bootstrap 4; Tailwind wants utility classes on the <input> itself, not a wrapper). djust models that with the FrameworkAdapter ABC:

class FrameworkAdapter(ABC):
    @abstractmethod
    def render_field(self, field, field_name, value, errors, **kwargs) -> str: ...

    @abstractmethod
    def render_errors(self, errors, **kwargs) -> str: ...

    @abstractmethod
    def get_field_class(self, field, has_errors=False) -> str: ...

Concrete subclasses live in djust.frameworks:

  • Bootstrap4Adapterform-group wrapper, custom-select for <select>, custom checkbox/radio markup.
  • Bootstrap5Adaptermb-3 wrapper, form-select, form-check patterns.
  • TailwindAdapter — utilities directly on the input; mb-4 wrapper.
  • PlainAdapter — empty class strings; the markup stays semantic.
from djust.frameworks import get_adapter

adapter = get_adapter()                 # current active (default: bootstrap5)
adapter = get_adapter('tailwind')       # explicit
html = adapter.render_field(my_form['email'], 'email', None, errors=[])

2. The class-name registry — what classes get emitted

For each framework, a flat dict maps abstract class roles to actual CSS class strings. Bootstrap 5's defaults:

'bootstrap5': {
    'field_class':            'form-control',
    'field_class_invalid':    'form-control is-invalid',
    'select_class':           'form-select',
    'error_class':            'invalid-feedback',
    'help_text_class':        'form-text',
    'label_class':            'form-label',
    'checkbox_class':         'form-check-input',
    'checkbox_label_class':   'form-check-label',
    'checkbox_wrapper_class': 'form-check',
    'radio_class':            'form-check-input',
    'radio_wrapper_class':    'form-check',
    'field_wrapper_class':    'mb-3',
    'button_primary_class':   'btn btn-primary',
    'button_secondary_class': 'btn btn-secondary',
}

Bootstrap 4's are similar with the differences (custom-select, custom-control-input, form-group); Tailwind's are utility chains; Plain is empty strings. Adapters resolve via config.get_framework_class('field_class'), so changing the active framework swaps the values without code changes.

Overriding individual classes

Two ways. Settings:

LIVEVIEW_CONFIG = {
    'css_framework': 'tailwind',
    'tailwind': {
        'field_class': 'block w-full rounded-lg border-zinc-700 bg-zinc-900 text-zinc-100',
        'field_wrapper_class': 'mb-6',
        'button_primary_class': 'rounded-md bg-emerald-500 px-4 py-2 font-semibold text-white hover:bg-emerald-600',
    },
}

Or programmatically at startup:

from djust.config import get_config
config = get_config()
config.set('tailwind.button_primary_class', 'btn btn--brand')

Override only the slots you care about; the rest fall back to the shipped defaults.

The Bootstrap bridge — theme tokens flow into Bootstrap classes

The theme pack's color tokens (--primary, --card, --border, …) need to reach Bootstrap classes (.btn-primary, .card, .form-control) without you editing Bootstrap's source. djust's pack_css_generator.py emits a small bridge stylesheet that wires them up:

/* What pack_css_generator emits when css_framework == 'bootstrap5' */
.btn-primary {
    --bs-btn-bg:           hsl(var(--primary));
    --bs-btn-border-color: hsl(var(--primary));
    --bs-btn-color:        hsl(var(--primary-foreground));
}
.form-control {
    color:            hsl(var(--foreground));
    background-color: hsl(var(--input, var(--card)));
    border-color:     hsl(var(--border));
}
.form-control:focus {
    border-color: hsl(var(--ring, var(--primary)));
    box-shadow:   0 0 0 var(--form-focus-ring-width, 0.25rem)
                  hsl(var(--ring, var(--primary)) / var(--form-focus-ring-opacity, 0.25));
}
.card {
    background-color: hsl(var(--card));
    color:            hsl(var(--card-foreground));
    border-color:     hsl(var(--border));
}

The result: visitors using bootstrap5 get theme-aware Bootstrap. Switch the active theme pack and .btn-primary recolors immediately without rebuilding any CSS.

For Tailwind there's no bridge needed because you already declare brand aliases inside @theme (see Tailwind) that resolve through the same runtime tokens.

Authoring a custom adapter

For frameworks djust doesn't ship — Bulma, UIKit, Pico, Open Props, an in-house design system — subclass BaseAdapter, add the framework's class-name dict to LIVEVIEW_CONFIG, register the adapter at startup:

# my_app/css_adapters.py
from djust.frameworks import BaseAdapter

class BulmaAdapter(BaseAdapter):
    """Render fields with Bulma classes."""

    required_marker = ' <span class="has-text-danger">*</span>'
    help_text_tag = 'p'
    help_text_class = 'help'
    error_wrapper = True
# settings.py
LIVEVIEW_CONFIG = {
    'css_framework': 'bulma',
    'bulma': {
        'field_class':            'input',
        'field_class_invalid':    'input is-danger',
        'select_class':           'select',
        'error_class':            'help is-danger',
        'help_text_class':        'help',
        'label_class':            'label',
        'checkbox_class':         'checkbox',
        'checkbox_wrapper_class': 'control',
        'field_wrapper_class':    'field',
        'button_primary_class':   'button is-primary',
        'button_secondary_class': 'button is-light',
    },
}
# my_app/apps.py
class MyAppConfig(AppConfig):
    name = 'my_app'

    def ready(self):
        from djust.frameworks import register_adapter
        from .css_adapters import BulmaAdapter
        register_adapter('bulma', BulmaAdapter())

That's it — FormMixin, the built-in components, and {% form %} all start emitting Bulma classes.

The split with theme packs

These two layers are orthogonal:

ConcernLives inExample
What classes my form fields getcss_framework (this page)<input class="form-control"> vs <input class="block w-full rounded-md…">
What colors / typography those classes paintTheme pack (Tokens)--primary: 28 85% 55% (rust) vs 268 80% 60% (purple)

You can pair any of the four frameworks with any of the 60+ theme packs. pack='dracula' + css_framework='bootstrap5' is a perfectly sensible combination — the Bootstrap bridge will render .btn-primary in Dracula's pink.

Common pitfalls

"I switched the framework but the form still looks the same"

The css_framework setting is read at process start. Restart the dev server (make dev) after changing it. The active value is also cached on config — you can verify in a Python shell:

from djust.config import get_config
print(get_config().get('css_framework'))

"My Tailwind utilities don't apply"

Tailwind needs to know about the classes at build time. The shipped tailwind.field_class value uses utilities from the default config (block, w-full, rounded-md, border-gray-300, …). If your project's Tailwind config is trimmed or extended differently, override the tailwind.field_class setting to match what your build can generate.

"The Bootstrap bridge isn't loading"

The bridge CSS is emitted by {% theme_css %} (which the shipped base template includes in <head>). The tag lives in the theme_tags library, so a custom base template needs {% load theme_tags %} once before the first call:

{% load theme_tags %}
<head>
    ...
    {% theme_css %}
</head>

Without the tag, the bridge isn't there and you're back to vanilla Bootstrap colors. Add the tag, or copy the relevant rules from pack_css_generator.py into your stylesheet.

See also

  • Tailwind — the brand-alias pattern for utility-class composition specifically.
  • Tokens — the variables the Bootstrap bridge reads from.
  • CSS Frameworks (framework guide) — the user-facing setup guide for installing Tailwind / Bootstrap with djust.
  • Source: djust.frameworks — the four adapter classes and the registry.

Spotted a typo or want to improve this page? Edit on GitHub →