---
title: "CSS Frameworks"
slug: css-frameworks
section: guides
order: 11
level: beginner
description: "Set up Tailwind CSS or Bootstrap 5 with djust, configure the css_framework setting, and integrate CSS builds into CI/CD."
---

# CSS Frameworks

djust ships with built-in support for Tailwind CSS and Bootstrap 5. The framework you choose affects how djust renders form fields and components automatically — no manual widget styling required.

## Quick Setup

One command sets up and builds your CSS:

```bash
# Tailwind CSS (recommended)
python manage.py djust_setup_css tailwind

# Bootstrap (manual install — see below)
python manage.py djust_setup_css bootstrap

# No CSS framework (plain HTML)
python manage.py djust_setup_css none
```

## Configuring the CSS Framework

Add `css_framework` to `LIVEVIEW_CONFIG` in `settings.py`:

```python
LIVEVIEW_CONFIG = {
    'css_framework': 'tailwind',   # 'tailwind', 'bootstrap5', or None
}
```

| Value | Description |
|---|---|
| `'bootstrap5'` | Bootstrap 5 classes on forms and components (default) |
| `'bootstrap4'` | Bootstrap 4 classes — for legacy projects, NYC Core Framework, government sites |
| `'tailwind'` | Tailwind utility classes on forms and components |
| `None` / `'plain'` | Unstyled plain HTML |

This setting controls how `FormMixin` and djust's built-in components render their fields. You can still use any CSS framework for your own templates — this only affects the auto-generated widget HTML.

---

## Tailwind CSS

### One-command Setup

```bash
python manage.py djust_setup_css tailwind
```

This command:
1. Creates `static/css/input.css` with `@import "tailwindcss"` and `@source` directives for your template directories
2. Creates `tailwind.config.js` (Tailwind v3) if it doesn't exist
3. Detects your `TEMPLATES` directories automatically
4. Runs the Tailwind CLI to compile `static/css/output.css`

### Prerequisites

The command auto-detects the Tailwind CLI. Install it via npm:

```bash
npm install -D tailwindcss
```

Or use the [standalone CLI](https://tailwindcss.com/blog/standalone-cli) (no Node required):

```bash
# macOS ARM
curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-macos-arm64
chmod +x tailwindcss-macos-arm64
```

### `settings.py` Requirements

`STATICFILES_DIRS` must be configured so the command knows where to write the output CSS:

```python
BASE_DIR = Path(__file__).resolve().parent.parent

STATICFILES_DIRS = [BASE_DIR / "static"]
```

### Add CSS to Base Template

After running the setup command, link the compiled CSS in your base template:

```html
{% load static %}
<link rel="stylesheet" href="{% static 'css/output.css' %}">
```

### Watch Mode (Development)

Auto-rebuild when templates change:

```bash
python manage.py djust_setup_css tailwind --watch
```

### Tailwind v3 vs v4

| Version | Config file | Input CSS |
|---|---|---|
| **v4** (recommended) | No config needed | `@import "tailwindcss"; @source "../templates/";` |
| **v3** | `tailwind.config.js` with `content:` paths | `@tailwind base; @tailwind components; @tailwind utilities;` |

`djust_setup_css` always writes a v4-style `input.css`, and it also writes a v3-style `tailwind.config.js` if none exists. It does not detect your Tailwind version. On Tailwind v3, replace the contents of `input.css` with the three `@tailwind` directives.

---

## Bootstrap 5

### Manual Setup

Bootstrap does not require a build step if you use the CDN:

```html
<!-- In your base template <head> -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
```

Configure djust to use Bootstrap 5 styling:

```python
LIVEVIEW_CONFIG = {
    'css_framework': 'bootstrap5',
}
```

### npm Install

For a self-hosted setup:

```bash
npm install bootstrap
```

Then import in your CSS entry point:

```css
@import "bootstrap/dist/css/bootstrap.min.css";
```

### Bootstrap + Sass Compilation

For custom theming with Sass variables:

```bash
npm install bootstrap sass
```

```scss
// static/scss/main.scss
$primary: #6366f1;   // Override Bootstrap variables
$border-radius: 0.5rem;

@import "bootstrap/scss/bootstrap";
```

Compile:

```bash
npx sass static/scss/main.scss static/css/main.css
```

---

## Bootstrap 4

Bootstrap 4 still ships across many legacy projects, government sites,
and frameworks like NYC Core. djust's `Bootstrap4Adapter` (added in
v0.5.0) renders form widgets with the BS4 class set instead of BS5.

```python
# settings.py
LIVEVIEW_CONFIG = {
    'css_framework': 'bootstrap4',
}
```

The adapter handles the four BS4-specific class differences from BS5:

| Element type | BS4 class | BS5 class |
|---|---|---|
| `<select>` | `custom-select` | `form-select` |
| Checkbox/radio wrapper | `custom-control custom-checkbox` | `form-check` |
| Form group wrapper | `form-group` | `mb-3` |
| Help text | `form-text text-muted` | `form-text` |

Install Bootstrap 4 the same way you would for any Bootstrap project —
bring your own Bootstrap 4 CSS via npm/CDN. djust does not bundle it.

### Radio buttons (separate config keys)

Radio inputs use their own config keys rather than reusing the
checkbox classes (BS4 and BS5 style them differently). Override them in a
custom adapter, or in `LIVEVIEW_CONFIG` under the framework key (see below):

| Key | Bootstrap 4 default | Bootstrap 5 default |
|---|---|---|
| `radio_class` | `custom-control-input` | `form-check-input` |
| `radio_label_class` | `custom-control-label` | `form-check-label` |
| `radio_wrapper_class` | `custom-control custom-radio` | `form-check` |

If a radio key is not set, djust falls back to the corresponding
checkbox key — so existing Bootstrap 5 projects don't need to define
radio classes explicitly.

Top-level keys such as `LIVEVIEW_CONFIG = {'radio_class': ...}` are ignored:
classes are looked up as `<framework>.<key>`. `LIVEVIEW_CONFIG` is also merged
shallowly, so setting `'bootstrap4': {...}` replaces the whole Bootstrap 4
class map. Copy the full default map when you override one key:

```python
LIVEVIEW_CONFIG = {
    "css_framework": "bootstrap4",
    "bootstrap4": {
        # The full default map, with radio_class changed:
        "field_class": "form-control",
        "field_class_invalid": "form-control is-invalid",
        "select_class": "custom-select",
        "error_class": "invalid-feedback",
        "error_class_block": "invalid-feedback d-block",
        "help_text_class": "form-text text-muted",
        "label_class": "",
        "checkbox_class": "custom-control-input",
        "checkbox_label_class": "custom-control-label",
        "checkbox_wrapper_class": "custom-control custom-checkbox",
        "radio_class": "my-radio",
        "radio_label_class": "custom-control-label",
        "radio_wrapper_class": "custom-control custom-radio",
        "field_wrapper_class": "form-group",
        "button_primary_class": "btn btn-primary",
        "button_secondary_class": "btn btn-secondary",
    },
}
```

### Select widgets

`ChoiceField` rendered with `widget=Select` reads its class from
`select_class` (default `form-select` for BS5, `custom-select` for
BS4) instead of the generic `field_class` used for `<input>` fields.
This keeps native dropdowns from picking up the wrong borders/padding.

---

## Theme tokens → framework classes

djust's theming system exposes design tokens as CSS variables
(`--primary`, `--border`, `--background`, …). The
`{% theme_framework_overrides %}` template tag emits a `<style>` block
that maps those tokens onto the active framework's class selectors so
a theme switch automatically restyles Bootstrap components without
editing Bootstrap source:

```django
{% load theme_tags %}
<head>
  …
  {% theme_framework_overrides %}
</head>
```

Example output (Bootstrap 5, abridged):

```css
.form-control { color: hsl(var(--foreground)); background-color: hsl(var(--input, var(--card))); border-color: hsl(var(--border)); }
.btn-primary { --bs-btn-bg: hsl(var(--primary)); --bs-btn-border-color: hsl(var(--primary)); --bs-btn-color: hsl(var(--primary-foreground)); }
.alert-info { background-color: hsl(var(--info) / 0.1); border-color: hsl(var(--info) / 0.3); color: hsl(var(--info)); }
```

The tokens are HSL triplets, so every rule wraps them in `hsl(...)`.

The tag emits nothing unless a theme pack is active. It is also a no-op
when `css_framework='tailwind'` or `None` — Tailwind projects manage their
palette via `@theme` in `input.css`, so the bridge would just duplicate work.

---

## How djust Uses the CSS Framework

When `css_framework` is set, djust's `FormMixin` and built-in components automatically apply framework classes to rendered widgets.

### Form Field Rendering

```python
# In your LiveView
class ContactView(FormMixin, LiveView):
    form_class = ContactForm
```

With `css_framework='bootstrap5'`, a required `EmailField` renders as:

```html
<div class="mb-3">
    <label for="id_email" class="form-label">Email <span class="text-danger">*</span></label>
    <input name="email" id="id_email" class="form-control" required="required"
           dj-change="validate_field" type="email" value="" maxlength="320" />
</div>
```

With `css_framework='tailwind'`, fields render as:

```html
<div class="mb-4">
    <label for="id_email" class="block text-sm font-medium text-gray-700">Email <span class="text-red-600">*</span></label>
    <input name="email" id="id_email"
           class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
           required="required" dj-change="validate_field" type="email" value="" maxlength="320" />
</div>
```

With `css_framework=None`, fields render as unstyled plain HTML.

### Custom Adapter

Register a custom adapter to override framework classes:

```python
from djust.frameworks import BaseAdapter, register_adapter

class MyAdapter(BaseAdapter):
    # Static HTML only — never interpolate user data here
    required_marker = ' <span class="required">*</span>'
    help_text_class = "hint"

register_adapter('my_theme', MyAdapter())

# settings.py
LIVEVIEW_CONFIG = {'css_framework': 'my_theme'}
```

---

## Production Builds

### Minified CSS

```bash
# Tailwind
python manage.py djust_setup_css tailwind --minify

# Sass/Bootstrap
npx sass static/scss/main.scss static/css/main.min.css --style compressed
```

### CI/CD Integration

Add the CSS build step before `collectstatic` in your CI pipeline:

```yaml
# GitHub Actions example
- name: Install Node dependencies
  run: npm ci

- name: Build CSS
  run: python manage.py djust_setup_css tailwind --minify

- name: Collect static files
  run: python manage.py collectstatic --noinput
```

### Docker

```dockerfile
# In your Dockerfile — before collectstatic
RUN npm ci
RUN python manage.py djust_setup_css tailwind --minify
RUN python manage.py collectstatic --noinput
```

### Makefile Targets

```makefile
css:
	python manage.py djust_setup_css tailwind

css-watch:
	python manage.py djust_setup_css tailwind --watch

css-prod:
	python manage.py djust_setup_css tailwind --minify
```

---

## Demo utility classes

The djust demo project ships a tiny `utilities.css` you can crib
from. The most-reached-for class:

| Class | Equivalent |
|---|---|
| `.flex-between` | `display: flex; align-items: center; justify-content: space-between;` |

Use it on card headers or any flex container that needs a title on
the left and an action widget on the right — saves repeating the
same three flex declarations across templates. It's not a djust
runtime requirement, just a convention worth borrowing.

---

## Common Pitfalls

Four ways CSS goes wrong in production. Three of them have a system check that
catches them, so `python manage.py check` finds them before a deploy does.

### Tailwind CDN in production

`<script src="https://cdn.tailwindcss.com"></script>` is a development
convenience only: roughly 300 KB uncompressed, render-blocking, with no
tree-shaking. Compile instead. **Caught by `djust.C010`.**

### Loading client.js by hand

Adding `<script src="{% static 'djust/client.js' %}">` duplicates what djust
auto-injects for every LiveView page — double initialisation and a race
between the two. Remove the tag. **Caught by `djust.C012`.**

### Deploying without compiling CSS

The development fallback does not exist in production, so the page ships
unstyled. Run the build before deploying. **Caught by `djust.C011`, which
warns when `output.css` is missing.**

### Template paths that don't match

If `tailwind.config.js` content globs don't match the real template
directories, the build purges classes that are actually used — the utilities
go missing and the result can be *larger*, not smaller. No system check
covers this one. Use
`djust_setup_css` to auto-detect the directories, or check the globs by hand.

## CI/CD Integration

### GitHub Actions

```yaml
name: Build CSS
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install dependencies
        run: |
          pip install -r requirements.txt
          npm install -D tailwindcss

      - name: Build CSS
        run: python manage.py djust_setup_css tailwind --minify

      - name: System checks
        run: python manage.py check
```

### Docker

```dockerfile
FROM python:3.12-slim

RUN apt-get update && apt-get install -y nodejs npm

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .

RUN npm install -D tailwindcss
RUN python manage.py djust_setup_css tailwind --minify
RUN python manage.py check

CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
```

## Custom Adapters

There is **no** `djust.css.CSSAdapter` module and **no** `DJUST_CSS_ADAPTER`
setting. Two real customization points exist:

**Generated files.** `djust_setup_css tailwind` writes `tailwind.config.js`
and the input CSS only when they are absent, so pre-create or edit those files
to change content globs, theme, or plugins — the build respects them and
prints the command it ran.

**Form-field classes.** The simplest route is to subclass
`djust.frameworks.BaseAdapter` and override class attributes, as shown in
[Custom Adapter](#custom-adapter) above. To take over rendering completely,
subclass `djust.frameworks.FrameworkAdapter` and implement its three abstract
methods with these signatures:

```python
from djust.frameworks import FrameworkAdapter, register_adapter

class MyAdapter(FrameworkAdapter):
    def render_field(self, field, field_name, value, errors, **kwargs): ...
    def render_errors(self, errors, **kwargs): ...
    def get_field_class(self, field, has_errors=False): ...

register_adapter("myui", MyAdapter())
```

## Troubleshooting

**`STATICFILES_DIRS not configured` error**

Add to `settings.py`:
```python
STATICFILES_DIRS = [BASE_DIR / "static"]
```

**Tailwind CLI not found**

Install via npm (`npm install -D tailwindcss`) or download the standalone binary. See [Tailwind installation](https://tailwindcss.com/docs/installation).

**Classes not being applied**

Check that your `input.css` has `@source` directives (v4) or `content:` paths (v3) pointing to your template directories. Run `djust_setup_css tailwind` again to regenerate.

**Bootstrap JS not loading**

Bootstrap's JavaScript (dropdowns, modals) must be loaded separately. Add the `bootstrap.bundle.min.js` script tag or import it via npm.
