Modern Django Frontend Architecture¶
Philosophy: Server-Rendered with Progressive Enhancement¶
The modern Django frontend (circa 2025) represents a return to server-rendered templates enhanced with lightweight JavaScript libraries. This approach combines the simplicity and SEO benefits of traditional server-side rendering with the interactivity expectations of modern web applications.
Core Principles¶
Server-First Architecture: The server generates complete HTML pages. JavaScript enhances but does not replace this foundation. Pages remain functional even when JavaScript fails or is disabled.
Progressive Enhancement: Begin with semantic HTML that works universally. Layer on CSS for presentation. Add JavaScript for enhanced interactivity. Each layer improves the experience without breaking the foundation.
Minimal JavaScript Footprint: Rather than shipping megabytes of framework code, modern Django frontends use lightweight libraries that add specific capabilities without architectural overhead.
Locality of Behavior: Interactive behavior lives near the HTML it affects, making code easier to understand and maintain. An HTMX attribute on a button directly shows what happens when clicked.
The Technology Stack¶
TailwindCSS: Utility-First Styling¶
TailwindCSS provides low-level utility classes that compose into custom designs without writing CSS. This approach offers several advantages for Django templates:
- No context switching: Style elements directly in templates without jumping between HTML and CSS files
- No naming anxiety: Utility classes eliminate decisions about class naming conventions
- Responsive by default: Built-in responsive prefixes make mobile-first design straightforward
- Consistent spacing: Predefined spacing scale ensures visual consistency
<!-- Traditional approach -->
<div class="card">
<h2 class="card-title">Title</h2>
<p class="card-body">Content</p>
</div>
<!-- TailwindCSS approach -->
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-bold text-gray-900 mb-2">Title</h2>
<p class="text-gray-700">Content</p>
</div>
HTMX: HTML-Driven Interactivity¶
HTMX extends HTML with attributes that enable AJAX requests, WebSockets, and server-sent events without writing JavaScript. The server returns HTML fragments that replace portions of the page.
Key Capabilities:
- Any element can trigger HTTP requests
- Responses replace targeted elements
- Supports all HTTP verbs (GET, POST, PUT, DELETE, PATCH)
- Built-in CSS transitions for smooth updates
- Progress indicators and loading states
<!-- Search that updates results on input -->
<input
type="text"
hx-get="/api/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
placeholder="Search..."
>
<div id="results"></div>
Alpine.js: Reactive Component State¶
Alpine.js provides reactive data binding for complex UI components. Think of it as jQuery for the reactive web—lightweight but powerful for specific use cases.
Use Cases:
- Dropdown menus and modals
- Form validation before submission
- Client-side filtering and sorting
- Toast notifications
- Tabs and accordions
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open" x-transition>
Expandable content
</div>
</div>
TailwindCSS Configuration¶
Setup and Configuration¶
The tailwind.config.js file defines which files Tailwind scans for classes and customizes the design system:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./poseidon/templates/**/*.html", // Django templates
"./poseidon/static/**/*.js", // JavaScript files
],
theme: {
extend: {
colors: {
primary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
},
spacing: {
'128': '32rem',
'144': '36rem',
},
},
},
plugins: [
require('@tailwindcss/forms'), // Better form styling
require('@tailwindcss/typography'), // Prose classes
],
safelist: [
// Classes generated dynamically that Tailwind might not detect
'bg-red-500',
'bg-green-500',
'bg-blue-500',
],
}
The Forms Plugin¶
The @tailwindcss/forms plugin provides better default styling for form elements:
<!-- Forms plugin automatically styles these -->
<input type="text" class="rounded-md border-gray-300 shadow-sm
focus:border-indigo-500 focus:ring-indigo-500">
<select class="rounded-md border-gray-300 shadow-sm
focus:border-indigo-500 focus:ring-indigo-500">
<option>Option 1</option>
</select>
Color System¶
Define semantic color scales in the theme configuration. Use numeric scales (50-900) where 50 is lightest and 900 is darkest:
<!-- Background progression -->
<div class="bg-blue-50">Lightest</div>
<div class="bg-blue-100">Lighter</div>
<div class="bg-blue-500">Base</div>
<div class="bg-blue-900">Darkest</div>
<!-- Text colors -->
<p class="text-gray-600">Secondary text</p>
<p class="text-gray-900">Primary text</p>
Responsive Design¶
Tailwind uses mobile-first breakpoints. Apply utilities at specific breakpoints with prefixes:
<!-- Mobile: stack, Desktop: grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>Item 1</div>
<div>Item 2</div>
<div>Item 3</div>
</div>
<!-- Hide on mobile, show on desktop -->
<div class="hidden lg:block">Desktop sidebar</div>
<!-- Responsive padding -->
<div class="px-4 sm:px-6 lg:px-8">Content</div>
HTMX Patterns for Django¶
Basic Request-Response Pattern¶
The fundamental HTMX pattern: trigger a request, receive HTML, swap it into the page.
<!-- Button triggers POST, replaces self with response -->
<button
hx-post="/api/toggle"
hx-swap="outerHTML"
class="px-4 py-2 bg-blue-500 text-white rounded"
>
Click Me
</button>
Django View:
def toggle_view(request):
if request.method == 'POST':
# Process the toggle
new_state = toggle_something()
# Return HTML fragment
return render(request, 'partials/toggle_button.html', {
'state': new_state
})
Common Attributes¶
hx-get, hx-post, hx-put, hx-delete: Specify the HTTP method and URL
hx-target: CSS selector for where to place the response (default: the element itself)
hx-swap: How to insert the response (innerHTML, outerHTML, beforebegin, afterend, etc.)
hx-trigger: What event triggers the request
<!-- Trigger on input with debounce -->
<input hx-get="/search"
hx-trigger="keyup changed delay:500ms">
<!-- Trigger on click (default for buttons) -->
<button hx-post="/action">Action</button>
<!-- Trigger on page load -->
<div hx-get="/initial-data" hx-trigger="load"></div>
hx-include: Include additional form fields in the request
Form Submission Pattern¶
HTMX handles form submissions elegantly, including file uploads:
<form hx-post="{% url 'clickup_issue:create' %}"
hx-encoding="multipart/form-data"
enctype="multipart/form-data">
{% csrf_token %}
<input type="text" name="title" required>
<textarea name="description"></textarea>
<input type="file" name="attachments" multiple>
<button type="submit">Submit</button>
</form>
Django View:
def create_issue(request):
if request.method == 'POST':
form = IssueForm(request.POST, request.FILES)
if form.is_valid():
issue = form.save()
# Return success message
return render(request, 'partials/success.html', {
'message': 'Issue created successfully'
})
else:
# Return form with errors
return render(request, 'partials/form_errors.html', {
'errors': form.errors
})
Search and Filter Pattern¶
Real-time search that updates results as the user types:
<input
type="text"
name="query"
hx-get="{% url 'search_lists' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#search-results"
hx-include="this"
placeholder="Search..."
class="w-full p-3 border rounded-md"
>
<div id="search-results" class="mt-4">
<!-- Results load here -->
</div>
Django View:
def search_lists(request):
query = request.GET.get('query', '')
results = ClickUpList.objects.filter(
name__icontains=query
)[:20]
return render(request, 'partials/search_results.html', {
'results': results
})
Loading States and Indicators¶
HTMX provides CSS classes during requests for loading indicators:
<button hx-post="/process">
<span class="htmx-indicator">
<svg class="animate-spin h-5 w-5">...</svg>
</span>
<span class="button-text">Process</span>
</button>
<style>
.htmx-request .htmx-indicator { display: inline; }
.htmx-request .button-text { display: none; }
.htmx-indicator { display: none; }
</style>
Modal Pattern with HTMX¶
Load modal content dynamically:
<!-- Trigger button -->
<button
hx-get="/modals/delete/123"
hx-target="#modal-container"
class="text-red-600 hover:text-red-800"
>
Delete
</button>
<!-- Modal container -->
<div id="modal-container"></div>
Modal Template (partials/delete_modal.html):
<div class="fixed inset-0 z-50 overflow-auto bg-black bg-opacity-50
flex items-center justify-center">
<div class="bg-white max-w-md mx-auto rounded-lg shadow-lg p-6">
<h3 class="text-lg font-semibold mb-4">Confirm Delete</h3>
<p class="mb-6">Are you sure you want to delete "{{ item.name }}"?</p>
<div class="flex justify-end space-x-4">
<button
hx-delete="{{ delete_url }}"
hx-target="#row-{{ item.pk }}"
hx-swap="outerHTML"
class="bg-red-600 text-white px-4 py-2 rounded"
>
Delete
</button>
<button
onclick="document.querySelector('#modal-container').innerHTML = ''"
class="bg-gray-500 text-white px-4 py-2 rounded"
>
Cancel
</button>
</div>
</div>
</div>
Event System¶
HTMX triggers custom events you can listen to:
<!-- Trigger toast on successful submission -->
<script>
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.successful) {
showToast('Success!', 'success');
}
});
</script>
Alpine.js Patterns¶
Component State Management¶
Alpine.js excels at managing component-level state:
<div x-data="{
count: 0,
increment() { this.count++ },
decrement() { this.count-- }
}">
<button @click="decrement">-</button>
<span x-text="count"></span>
<button @click="increment">+</button>
</div>
Toast Notification System¶
A complete toast system using Alpine stores:
<!-- Toast component -->
<div x-data class="fixed top-4 right-4 z-50 space-y-2">
<template x-for="toast in $store.toasts.items" :key="toast.id">
<div
x-transition:enter="transition ease-out duration-300"
x-transition:leave="transition ease-in duration-300"
class="w-auto max-w-[50vw] p-4 border-l-4 rounded shadow-lg"
:class="{
'bg-green-500 border-green-700 text-white': toast.level === 'success',
'bg-blue-100 border-blue-500 text-blue-700': toast.level === 'info',
'bg-red-100 border-red-500 text-red-700': toast.level === 'error',
'bg-yellow-100 border-yellow-500 text-yellow-700': toast.level === 'warning'
}"
>
<div class="flex items-center">
<template x-if="toast.level === 'success'">
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M9 12l2 2l4 -4m6 2a9 9 0 1 1 -18 0a9 9 0 0 1 18 0z" />
</svg>
</template>
<div class="flex flex-col">
<div x-text="toast.message" class="mb-1"></div>
<div class="text-xs text-gray-500" x-text="toast.timestamp"></div>
</div>
</div>
</div>
</template>
</div>
<!-- Alpine store setup -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.store('toasts', {
items: [],
add({ message, level }, duration = 10000) {
const id = Date.now()
const timestamp = new Date().toLocaleString('en-US', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const toast = { id, message, level, timestamp }
this.items.push(toast)
setTimeout(() => this.remove(id), duration)
},
remove(id) {
this.items = this.items.filter(toast => toast.id !== id)
}
})
})
</script>
<!-- HTMX integration -->
<script>
window.addEventListener("toastEvent", function(evt){
Alpine.store('toasts').add({
message: evt.detail.message,
level: evt.detail.level
});
})
</script>
Triggering from Django:
# In your view, add to response headers
response = render(request, 'template.html')
response['HX-Trigger'] = json.dumps({
'toastEvent': {
'message': 'Operation successful',
'level': 'success'
}
})
return response
Dropdown/Select Component¶
Custom dropdown with search:
<div x-data="{
open: false,
selected: null,
options: ['Option 1', 'Option 2', 'Option 3'],
filter: '',
get filteredOptions() {
return this.options.filter(o =>
o.toLowerCase().includes(this.filter.toLowerCase())
)
}
}">
<button @click="open = !open" class="px-4 py-2 border rounded">
<span x-text="selected || 'Select option'"></span>
</button>
<div x-show="open" @click.outside="open = false"
class="absolute mt-1 bg-white border rounded shadow-lg">
<input x-model="filter" type="text" placeholder="Search..."
class="w-full p-2 border-b">
<template x-for="option in filteredOptions" :key="option">
<div @click="selected = option; open = false"
class="p-2 hover:bg-gray-100 cursor-pointer"
x-text="option">
</div>
</template>
</div>
</div>
Tabs Component¶
Multi-tab interface with Alpine:
<div x-data="{
currentTab: localStorage.getItem('activeTab') || 'tab1',
setTab(tab) {
this.currentTab = tab;
localStorage.setItem('activeTab', tab);
}
}">
<!-- Tab buttons -->
<div class="border-b border-gray-200">
<nav class="flex space-x-4">
<button
@click="setTab('tab1')"
:class="currentTab === 'tab1'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'"
class="px-3 py-2 border-b-2 font-medium"
>
Tab 1
</button>
<button
@click="setTab('tab2')"
:class="currentTab === 'tab2'
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'"
class="px-3 py-2 border-b-2 font-medium"
>
Tab 2
</button>
</nav>
</div>
<!-- Tab panels -->
<div class="mt-4">
<div x-show="currentTab === 'tab1'">
<p>Tab 1 content</p>
</div>
<div x-show="currentTab === 'tab2'">
<p>Tab 2 content</p>
</div>
</div>
</div>
Form Wizard with Validation¶
Multi-step form with client-side validation:
<div x-data="{
currentStep: 1,
formData: {
name: '',
email: '',
message: ''
},
errors: {},
validate(step) {
this.errors = {};
if (step === 1 && !this.formData.name) {
this.errors.name = 'Name is required';
return false;
}
if (step === 2 && !this.formData.email) {
this.errors.email = 'Email is required';
return false;
}
return true;
},
nextStep() {
if (this.validate(this.currentStep)) {
this.currentStep++;
}
},
prevStep() {
this.currentStep--;
}
}">
<!-- Progress indicator -->
<div class="mb-8">
<div class="flex items-center">
<template x-for="step in 3" :key="step">
<div class="flex items-center flex-1">
<div
:class="currentStep >= step
? 'bg-indigo-600 text-white'
: 'bg-gray-300 text-gray-600'"
class="w-8 h-8 rounded-full flex items-center justify-center"
x-text="step"
></div>
<div x-show="step < 3" class="flex-1 h-1 bg-gray-300 mx-2"></div>
</div>
</template>
</div>
</div>
<!-- Step 1 -->
<div x-show="currentStep === 1">
<input x-model="formData.name" type="text" placeholder="Name"
class="w-full p-2 border rounded">
<p x-show="errors.name" class="text-red-500 text-sm" x-text="errors.name"></p>
<button @click="nextStep" class="mt-4 px-4 py-2 bg-indigo-600 text-white rounded">
Next
</button>
</div>
<!-- Step 2 -->
<div x-show="currentStep === 2">
<input x-model="formData.email" type="email" placeholder="Email"
class="w-full p-2 border rounded">
<p x-show="errors.email" class="text-red-500 text-sm" x-text="errors.email"></p>
<button @click="prevStep" class="mt-4 px-4 py-2 bg-gray-300 rounded">Back</button>
<button @click="nextStep" class="mt-4 px-4 py-2 bg-indigo-600 text-white rounded">
Next
</button>
</div>
<!-- Step 3 -->
<div x-show="currentStep === 3">
<textarea x-model="formData.message" placeholder="Message"
class="w-full p-2 border rounded"></textarea>
<button @click="prevStep" class="mt-4 px-4 py-2 bg-gray-300 rounded">Back</button>
<button type="submit" class="mt-4 px-4 py-2 bg-green-600 text-white rounded">
Submit
</button>
</div>
</div>
Template Organization¶
Base Template Structure¶
Django templates use inheritance to create consistent layouts:
<!-- base/base.html -->
{% load static %}
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}My Site{% endblock %}</title>
<!-- TailwindCSS -->
<link rel="stylesheet"
href="{% static 'css/poseidon-tailwind.css' %}?v={{ TAILWIND_STATIC_VERSION }}">
<!-- HTMX -->
<script src="{% static 'poseidon/js/htmx-2.0.4.min.js' %}"></script>
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% block extra_head_content %}{% endblock %}
</head>
<body class="h-full">
{% include 'base/sidebar.html' %}
<main class="lg:pl-72">
<div class="px-4 py-10 sm:px-6 lg:px-8 lg:py-6">
{% block content %}{% endblock %}
</div>
</main>
{% include 'base/partials/toast.html' %}
{% block extra_scripts %}{% endblock %}
</body>
</html>
Partials Directory Structure¶
Organize reusable components in partials directories:
templates/
├── base/
│ ├── base.html
│ ├── base_iframe.html
│ ├── sidebar.html
│ └── partials/
│ ├── toast.html
│ ├── modal.html
│ └── loading.html
├── backstage/
│ ├── base.html
│ ├── sidebar.html
│ └── clickup_issue/
│ ├── form.html
│ └── partials/
│ ├── search_results.html
│ └── selected_list.html
└── widgets/
├── button.html
├── input.html
└── select.html
Component Include Pattern¶
Create reusable components with the include tag:
<!-- widgets/button.html -->
<button
type="{{ type|default:'button' }}"
class="px-4 py-2 rounded font-medium
{% if variant == 'primary' %}bg-indigo-600 text-white hover:bg-indigo-700
{% elif variant == 'secondary' %}bg-gray-200 text-gray-900 hover:bg-gray-300
{% elif variant == 'danger' %}bg-red-600 text-white hover:bg-red-700
{% endif %}"
{{ attrs }}
>
{% if icon %}
<svg class="w-5 h-5 {% if slot %}mr-2{% endif %}" fill="none" stroke="currentColor">
{% include icon %}
</svg>
{% endif %}
{{ slot }}
</button>
Usage:
Material Design 3 Guidelines¶
Color System¶
Material Design 3 uses a semantic color system with roles:
- Primary: Brand color for primary actions
- Secondary: Supporting brand color
- Tertiary: Accent color for highlights
- Error: Error states and critical actions
- Surface: Background colors for components
<!-- Primary action -->
<button class="bg-primary-600 text-white hover:bg-primary-700">
Primary Action
</button>
<!-- Secondary action -->
<button class="bg-secondary-200 text-secondary-900 hover:bg-secondary-300">
Secondary Action
</button>
<!-- Error state -->
<div class="bg-error-50 border border-error-200 text-error-700 p-4 rounded">
Error message
</div>
Elevation and Shadows¶
Material Design uses elevation to show hierarchy:
<!-- Level 1: subtle elevation -->
<div class="shadow-sm">Card</div>
<!-- Level 2: moderate elevation -->
<div class="shadow-md">Raised card</div>
<!-- Level 3: high elevation -->
<div class="shadow-lg">Modal</div>
<!-- Level 4: highest elevation -->
<div class="shadow-xl">Popup</div>
Typography Scale¶
Material Design defines a type scale for consistency:
<!-- Display: largest text -->
<h1 class="text-4xl font-bold">Display</h1>
<!-- Headline: section headers -->
<h2 class="text-2xl font-semibold">Headline</h2>
<!-- Title: component headers -->
<h3 class="text-xl font-medium">Title</h3>
<!-- Body: primary content -->
<p class="text-base">Body text</p>
<!-- Label: form labels, captions -->
<span class="text-sm font-medium">Label</span>
<!-- Caption: secondary information -->
<span class="text-xs text-gray-500">Caption</span>
Component Variants¶
Material Design 3 defines standard variants for components:
Button Variants:
<!-- Filled: highest emphasis -->
<button class="bg-indigo-600 text-white px-4 py-2 rounded-md
hover:bg-indigo-700 shadow-sm">
Filled
</button>
<!-- Outlined: medium emphasis -->
<button class="border-2 border-indigo-600 text-indigo-600 px-4 py-2 rounded-md
hover:bg-indigo-50">
Outlined
</button>
<!-- Text: lowest emphasis -->
<button class="text-indigo-600 px-4 py-2 rounded-md hover:bg-indigo-50">
Text
</button>
Card Variants:
<!-- Elevated: with shadow -->
<div class="bg-white rounded-lg shadow-md p-6">
<h3 class="text-xl font-semibold">Elevated Card</h3>
</div>
<!-- Filled: with background -->
<div class="bg-gray-100 rounded-lg p-6">
<h3 class="text-xl font-semibold">Filled Card</h3>
</div>
<!-- Outlined: with border -->
<div class="bg-white border border-gray-200 rounded-lg p-6">
<h3 class="text-xl font-semibold">Outlined Card</h3>
</div>
Component Patterns¶
Button Component¶
A comprehensive button with multiple states:
<button
type="{{ type|default:'button' }}"
class="inline-flex items-center justify-center px-4 py-2
border rounded-md font-medium shadow-sm
transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
{% if variant == 'primary' %}
bg-indigo-600 text-white border-transparent
hover:bg-indigo-700 focus:ring-indigo-500
{% elif variant == 'secondary' %}
bg-white text-gray-700 border-gray-300
hover:bg-gray-50 focus:ring-indigo-500
{% elif variant == 'danger' %}
bg-red-600 text-white border-transparent
hover:bg-red-700 focus:ring-red-500
{% endif %}
{% if size == 'sm' %}
text-sm px-3 py-1.5
{% elif size == 'lg' %}
text-lg px-6 py-3
{% endif %}"
{% if disabled %}disabled{% endif %}
{{ attrs }}
>
{% if loading %}
<svg class="animate-spin h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{% endif %}
{% if icon %}
<svg class="w-5 h-5 {% if slot %}mr-2{% endif %}"
fill="none" stroke="currentColor">
{% include icon %}
</svg>
{% endif %}
{{ slot }}
</button>
Form Input Component¶
Accessible form input with validation states:
<div class="mb-4">
<label for="{{ id }}"
class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}
{% if required %}<span class="text-red-500">*</span>{% endif %}
</label>
<input
type="{{ type|default:'text' }}"
id="{{ id }}"
name="{{ name }}"
value="{{ value }}"
placeholder="{{ placeholder }}"
{% if required %}required{% endif %}
{% if disabled %}disabled{% endif %}
class="mt-1 block w-full rounded-md shadow-sm
{% if error %}
border-red-300 text-red-900 placeholder-red-300
focus:ring-red-500 focus:border-red-500
{% else %}
border-gray-300
focus:ring-indigo-500 focus:border-indigo-500
{% endif %}
disabled:bg-gray-100 disabled:cursor-not-allowed"
aria-describedby="{% if error %}{{ id }}-error{% endif %}
{% if help_text %}{{ id }}-help{% endif %}"
aria-invalid="{% if error %}true{% else %}false{% endif %}"
>
{% if error %}
<p class="mt-1 text-sm text-red-600" id="{{ id }}-error">
{{ error }}
</p>
{% endif %}
{% if help_text %}
<p class="mt-1 text-sm text-gray-500" id="{{ id }}-help">
{{ help_text }}
</p>
{% endif %}
</div>
Modal Component¶
Accessible modal dialog:
<div x-data="{ open: {{ open|default:'false' }} }"
x-show="open"
x-cloak
@keydown.escape.window="open = false"
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<!-- Backdrop -->
<div x-show="open"
x-transition:enter="transition-opacity ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="open = false"
class="fixed inset-0 bg-black bg-opacity-50">
</div>
<!-- Modal panel -->
<div class="flex min-h-screen items-center justify-center p-4">
<div x-show="open"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@click.stop
class="relative bg-white rounded-lg shadow-xl max-w-lg w-full p-6">
<!-- Close button -->
<button @click="open = false"
class="absolute top-4 right-4 text-gray-400
hover:text-gray-600"
aria-label="Close">
<svg class="w-6 h-6" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Modal content -->
<h3 id="modal-title" class="text-lg font-semibold text-gray-900 mb-4">
{{ title }}
</h3>
<div class="text-gray-700">
{{ content }}
</div>
<div class="mt-6 flex justify-end space-x-3">
<button @click="open = false"
class="px-4 py-2 bg-gray-200 text-gray-900 rounded-md
hover:bg-gray-300">
Cancel
</button>
<button class="px-4 py-2 bg-indigo-600 text-white rounded-md
hover:bg-indigo-700">
Confirm
</button>
</div>
</div>
</div>
</div>
Card Component¶
Flexible card component with header, body, and footer:
<div class="{% if variant == 'elevated' %}shadow-md
{% elif variant == 'outlined' %}border border-gray-200
{% elif variant == 'filled' %}bg-gray-50
{% endif %}
bg-white rounded-lg overflow-hidden">
{% if image %}
<img src="{{ image }}" alt="{{ image_alt }}"
class="w-full h-48 object-cover">
{% endif %}
<div class="p-6">
{% if title %}
<h3 class="text-xl font-semibold text-gray-900 mb-2">
{{ title }}
</h3>
{% endif %}
{% if subtitle %}
<p class="text-sm text-gray-500 mb-4">{{ subtitle }}</p>
{% endif %}
<div class="text-gray-700">
{{ content }}
</div>
</div>
{% if footer %}
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50">
{{ footer }}
</div>
{% endif %}
</div>
Accessibility (ARIA Attributes)¶
Semantic HTML First¶
Always use the most semantic HTML element available:
<!-- Good: semantic button -->
<button type="button">Click me</button>
<!-- Bad: div pretending to be button -->
<div onclick="handleClick()">Click me</div>
<!-- Good: semantic nav -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
</ul>
</nav>
ARIA Roles¶
Use ARIA roles when semantic HTML isn't sufficient:
<!-- Alert role for important messages -->
<div role="alert" class="bg-red-100 border border-red-400 p-4">
<p>Error: Please fix the form errors</p>
</div>
<!-- Dialog role for modals -->
<div role="dialog" aria-labelledby="dialog-title" aria-modal="true">
<h2 id="dialog-title">Confirmation Required</h2>
</div>
<!-- Navigation role -->
<div role="navigation" aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li>Current Page</li>
</ol>
</div>
ARIA States and Properties¶
Communicate dynamic states to assistive technologies:
<!-- Expanded state for accordion -->
<button
aria-expanded="{{ expanded|default:'false' }}"
aria-controls="panel-{{ id }}"
@click="expanded = !expanded"
>
Toggle Panel
</button>
<div id="panel-{{ id }}" x-show="expanded">
Panel content
</div>
<!-- Selected state for tabs -->
<button
role="tab"
aria-selected="{{ active|default:'false' }}"
aria-controls="tabpanel-{{ id }}"
>
Tab Label
</button>
<!-- Disabled state -->
<button disabled aria-disabled="true">
Disabled Button
</button>
<!-- Loading state -->
<button aria-busy="true">
<span class="sr-only">Loading...</span>
<svg class="animate-spin">...</svg>
</button>
Focus Management¶
Ensure keyboard users can navigate effectively:
<!-- Skip to main content link -->
<a href="#main-content"
class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4
focus:z-50 focus:px-4 focus:py-2 focus:bg-white focus:text-indigo-600">
Skip to main content
</a>
<!-- Visible focus indicators -->
<button class="px-4 py-2 bg-indigo-600 text-white rounded
focus:outline-none focus:ring-2 focus:ring-indigo-500
focus:ring-offset-2">
Accessible Button
</button>
<!-- Focus trap in modal -->
<div x-data="{ open: false }"
@keydown.tab="trapFocus($event)"
@keydown.shift.tab="trapFocus($event)">
<!-- Modal content -->
</div>
Screen Reader Text¶
Provide context for screen reader users:
<!-- Icon-only button with screen reader text -->
<button>
<svg class="w-6 h-6" aria-hidden="true">
<path d="..."/>
</svg>
<span class="sr-only">Delete item</span>
</button>
<!-- Loading indicator -->
<div role="status" aria-live="polite">
<svg class="animate-spin" aria-hidden="true">...</svg>
<span class="sr-only">Loading...</span>
</div>
<!-- Form errors -->
<div role="alert" aria-live="assertive">
<p id="email-error">Email is required</p>
</div>
<input type="email" aria-describedby="email-error" aria-invalid="true">
Form Accessibility¶
Ensure forms are accessible to all users:
<form>
<!-- Associated label -->
<label for="email" class="block text-sm font-medium">
Email Address
<span class="text-red-500" aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-describedby="email-help email-error"
aria-invalid="{% if error %}true{% else %}false{% endif %}"
class="mt-1 block w-full"
>
<p id="email-help" class="text-sm text-gray-500">
We'll never share your email
</p>
{% if error %}
<p id="email-error" class="text-sm text-red-600" role="alert">
{{ error }}
</p>
{% endif %}
<!-- Fieldset for related inputs -->
<fieldset>
<legend class="text-sm font-medium">Notification Preferences</legend>
<div>
<input type="checkbox" id="email-notif" name="email_notifications">
<label for="email-notif">Email notifications</label>
</div>
<div>
<input type="checkbox" id="sms-notif" name="sms_notifications">
<label for="sms-notif">SMS notifications</label>
</div>
</fieldset>
</form>
Asset Pipeline¶
Static Files Configuration¶
Django's static files system collects assets for production:
# settings.py
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'poseidon' / 'static',
]
# In production, use WhiteNoise or CDN
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
Collecting Static Files¶
The collectstatic management command gathers all static files:
# Collect static files for production
python manage.py collectstatic --noinput
# Clear and recollect
python manage.py collectstatic --clear --noinput
Cache Busting¶
Use versioning to bust browser caches when assets change:
# In context processor
def tailwind_version(request):
return {
'TAILWIND_STATIC_VERSION': settings.TAILWIND_STATIC_VERSION
}
<!-- Template usage -->
<link rel="stylesheet"
href="{% static 'css/poseidon-tailwind.css' %}?v={{ TAILWIND_STATIC_VERSION }}">
Manifest Static Files Storage¶
Django's manifest storage creates hashed filenames:
This generates:
Templates automatically reference the hashed versions:
{% load static %}
<!-- Automatically uses hashed filename in production -->
<link rel="stylesheet" href="{% static 'css/style.css' %}">
CDN Integration¶
Serve static files from a CDN in production:
# settings.py
STATIC_URL = 'https://cdn.example.com/static/'
# Or use django-storages for S3
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
AWS_STORAGE_BUCKET_NAME = 'my-bucket'
AWS_S3_CUSTOM_DOMAIN = 'cdn.example.com'
STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/static/'
Development Workflow¶
TailwindCSS Watch Mode¶
During development, run Tailwind in watch mode to rebuild CSS on changes:
// package.json
{
"scripts": {
"tailwind:watch": "tailwindcss -i ./src/input.css -o ./static/css/poseidon-tailwind.css --watch",
"tailwind:build": "tailwindcss -i ./src/input.css -o ./static/css/poseidon-tailwind.css --minify"
}
}
Run the watch command:
Input CSS File¶
Create a minimal input file that imports Tailwind:
/* src/input.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom component classes */
@layer components {
.btn-primary {
@apply px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700;
}
.card {
@apply bg-white rounded-lg shadow-md p-6;
}
}
/* Custom utilities */
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}
Development Server Setup¶
Run both Django and Tailwind in development:
# Terminal 1: Django development server
python manage.py runserver
# Terminal 2: Tailwind watch mode
npm run tailwind:watch
Or use a process manager like Honcho:
Hot Reload with HTMX¶
HTMX responses automatically update the page without full reload:
<!-- Form that submits and updates in-place -->
<form hx-post="/update" hx-swap="outerHTML">
<input type="text" name="value">
<button type="submit">Update</button>
</form>
The server returns the updated HTML, and HTMX swaps it in—no page refresh needed.
Browser DevTools Tips¶
Tailwind DevTools: Inspect which Tailwind classes apply to elements. The class names are preserved in production.
HTMX Debug Mode: Enable verbose logging:
Alpine DevTools: Install the browser extension to inspect Alpine state:
# Chrome
https://chrome.google.com/webstore/detail/alpinejs-devtools/
# Firefox
https://addons.mozilla.org/en-US/firefox/addon/alpinejs-devtools/
Production Build¶
Production TailwindCSS¶
Build minified CSS for production:
# Build minified CSS
npm run tailwind:build
# Or using the CLI directly
npx tailwindcss -i ./src/input.css -o ./static/css/poseidon-tailwind.css --minify
Purging Unused CSS¶
Tailwind automatically removes unused classes in production based on the content configuration:
// tailwind.config.js
module.exports = {
content: [
"./poseidon/templates/**/*.html",
"./poseidon/static/**/*.js",
],
// Purging is automatic in production builds
}
Optimization Checklist¶
CSS Optimization:
- Run Tailwind build with
--minify - Enable gzip compression on web server
- Use CDN for static file delivery
- Implement cache-control headers
JavaScript Optimization:
- Use production builds of HTMX and Alpine
- Enable gzip compression
- Implement cache-control headers
- Consider using a CDN
Django Static Files:
# settings.py for production
DEBUG = False
# Collect static files
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Use ManifestStaticFilesStorage for cache busting
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage'
# Or use WhiteNoise for serving static files
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware', # Add after SecurityMiddleware
# ... other middleware
]
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
Best Practices¶
Progressive Enhancement¶
Build for the lowest common denominator, then enhance:
<!-- Works without JavaScript -->
<form method="post" action="/search">
{% csrf_token %}
<input type="text" name="query">
<button type="submit">Search</button>
</form>
<!-- Enhanced with HTMX when JavaScript available -->
<form method="post" action="/search"
hx-post="/search" hx-target="#results">
{% csrf_token %}
<input type="text" name="query">
<button type="submit">Search</button>
</form>
Minimize JavaScript Dependencies¶
Prefer HTML and CSS solutions over JavaScript when possible:
<!-- CSS-only disclosure widget -->
<details class="border rounded-lg">
<summary class="px-4 py-2 cursor-pointer hover:bg-gray-50">
Click to expand
</summary>
<div class="px-4 py-2 border-t">
Expanded content
</div>
</details>
<!-- Use Alpine only when interactivity requires it -->
<div x-data="{ count: 0 }">
<button @click="count++">Increment</button>
<span x-text="count"></span>
</div>
Component Composition¶
Build complex interfaces from small, reusable components:
<!-- Base button component -->
{% include 'widgets/button.html' with variant='primary' slot='Save' %}
<!-- Compose into form -->
<form hx-post="/save">
{% include 'widgets/input.html' with label='Name' name='name' %}
{% include 'widgets/input.html' with label='Email' name='email' type='email' %}
{% include 'widgets/button.html' with variant='primary' slot='Submit' type='submit' %}
</form>
Performance Considerations¶
Lazy Loading: Load content only when needed with HTMX:
<div hx-get="/expensive-content" hx-trigger="revealed">
<div class="animate-pulse bg-gray-200 h-32"></div>
</div>
Debouncing: Prevent excessive requests:
Request Batching: Combine multiple updates:
<div hx-get="/dashboard-data" hx-trigger="load">
<!-- Single request loads all dashboard data -->
</div>
Testing Strategies¶
Template Testing: Test templates render correctly:
def test_button_component(self):
template = Template(
"{% include 'widgets/button.html' with variant='primary' slot='Click' %}"
)
html = template.render(Context())
assert 'bg-indigo-600' in html
assert 'Click' in html
HTMX Testing: Test HTMX endpoints return correct HTML:
def test_search_endpoint(self):
response = self.client.get('/search', {'query': 'test'})
assert response.status_code == 200
assert 'hx-swap-oob' not in response.content.decode()
assert '<div id="results">' in response.content.decode()
Accessibility Testing: Use automated tools and manual testing:
# Install axe-core for accessibility testing
npm install --save-dev axe-core
# Run axe in browser console
axe.run(document, (err, results) => {
console.log(results.violations);
});
Code Organization¶
Template Structure:
templates/
├── base/ # Base layouts
├── partials/ # Reusable components
├── widgets/ # Form widgets
├── pages/ # Full page templates
└── emails/ # Email templates
Static Files Structure:
static/
├── css/
│ └── poseidon-tailwind.css
├── js/
│ ├── htmx-2.0.4.min.js
│ └── custom.js
├── img/
└── fonts/
Version Control¶
Ignore Generated Files:
Commit Source Files:
Conclusion¶
Modern Django frontend development combines server-rendered templates with lightweight JavaScript enhancement. This approach delivers:
- Fast initial page loads: Server renders complete HTML
- SEO friendly: Full content available to crawlers
- Progressive enhancement: Works without JavaScript, better with it
- Developer experience: Minimal build tooling, clear code organization
- User experience: Fast interactions, smooth transitions, accessible interfaces
The stack of Django templates, TailwindCSS, HTMX, and Alpine.js provides a productive development experience while maintaining simplicity and performance. Focus on semantic HTML, enhance with CSS utilities, add interactivity with HTMX, and reach for Alpine only when client-side state is necessary.
This architecture serves production applications handling millions of requests while remaining maintainable by small teams. The key is embracing the web platform's strengths rather than fighting them with heavy JavaScript frameworks.