Why Customize OpenWebUI?
OpenWebUI has rapidly become the de facto standard interface for self-hosted large language models. Out of the box, it offers a polished, responsive chat experience with model management, RAG pipelines, and multi-user support. But the default aesthetic and behavior are designed for the broadest possible audience — not for your specific cognitive workflow, visual preferences, or automation needs.
Customization transforms OpenWebUI from a generic tool into a force multiplier. Whether you want a distraction-free writing environment, a high-contrast accessibility mode, a cyberpunk hacker aesthetic, or a branded interface for your team, every layer is accessible: CSS variables for theming, JavaScript injection for behavioral changes, model parameter presets for consistent outputs, and pipeline scripts for custom logic.
Prerequisites & Safety First
Before diving in, ensure you have:
- Administrative access to your OpenWebUI instance (Docker, Kubernetes, or bare metal)
- Basic familiarity with CSS, JavaScript, and JSON
- A backup strategy — export your config via Settings → General → Export before making changes
- Browser developer tools open (F12) for live experimentation
Custom CSS/JS persists across updates only if stored in the database (via Admin Panel → Settings → Interface). Direct file edits in the container will be lost on rebuild. Always use the built-in customization fields for production changes.
Layer 1: CSS Variable Theming — The Foundation
OpenWebUI uses a comprehensive CSS custom property (variable) system. The fastest way to reskin the entire interface is overriding these variables in Admin Panel → Settings → Interface → Custom CSS.
Core Color Palette Variables
:root {
/* Primary brand colors */
--primary: #00d4aa;
--primary-hover: #00b894;
--primary-focus: rgba(0, 212, 170, 0.25);
/* Surface hierarchy */
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-hover: #30363d;
/* Text hierarchy */
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
/* Borders & dividers */
--border-primary: #30363d;
--border-focus: #00d4aa;
/* Semantic states */
--success: #3fb950;
--warning: #d29922;
--error: #f85149;
--info: #58a6ff;
/* Chat bubbles */
--bubble-user-bg: #1f6feb;
--bubble-user-text: #ffffff;
--bubble-assistant-bg: #21262d;
--bubble-assistant-text: #e6edf3;
--bubble-system-bg: #2d1b00;
--bubble-system-text: #d29922;
/* Code blocks */
--code-bg: #0d1117;
--code-border: #30363d;
--code-text: #e6edf3;
/* Scrollbars */
--scrollbar-thumb: #30363d;
--scrollbar-track: transparent;
--scrollbar-thumb-hover: #484f58;
/* Spacing & radii */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Transitions */
--transition-fast: 120ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
}
/* Dark mode forced (optional) */
@media (prefers-color-scheme: light) {
:root { color-scheme: dark; }
}
Typography Customization
Swap the default Inter font for something with more character — JetBrains Mono for code-heavy workflows, Geist for modern minimalism, or a variable font for weight granularity:
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
--font-sans: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
--font-size-base: 15px;
--line-height-base: 1.6;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-bold: 600;
}
/* Apply to chat messages */
.chat-message .message-content {
font-family: var(--font-sans);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}
/* Code blocks get mono */
.code-block, pre, code {
font-family: var(--font-mono) !important;
font-size: 0.9em;
line-height: 1.7;
}
Advanced: Custom Theme Variants
Create switchable themes using data attributes on — then toggle via a small JS snippet (see Layer 2):
/* Cyberpunk Neon Theme */
html[data-theme="cyberpunk"] {
--primary: #ff00ff;
--primary-hover: #cc00cc;
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--bg-tertiary: #1a1a2e;
--text-primary: #e0e0ff;
--bubble-user-bg: #ff00ff;
--bubble-assistant-bg: #1a1a2e;
--border-primary: #ff00ff40;
--border-focus: #ff00ff;
--scrollbar-thumb: #ff00ff60;
text-shadow: 0 0 1px rgba(255, 0, 255, 0.3);
}
/* High Contrast Accessibility Theme */
html[data-theme="high-contrast"] {
--primary: #ffff00;
--bg-primary: #000000;
--bg-secondary: #000000;
--bg-tertiary: #111111;
--text-primary: #ffffff;
--text-secondary: #cccccc;
--border-primary: #ffffff;
--border-focus: #ffff00;
--bubble-user-bg: #0000ff;
--bubble-assistant-bg: #111111;
--bubble-user-text: #ffffff;
--bubble-assistant-text: #ffffff;
--focus-ring: 3px solid #ffff00;
}
/* Sepia Reading Mode */
html[data-theme="sepia"] {
--primary: #8b6914;
--bg-primary: #fdf6e3;
--bg-secondary: #f5ebd3;
--bg-tertiary: #ede0c0;
--text-primary: #3c2f1a;
--text-secondary: #5c4f3a;
--border-primary: #d4c4a0;
--bubble-user-bg: #8b6914;
--bubble-user-text: #fdf6e3;
--bubble-assistant-bg: #ede0c0;
--bubble-assistant-text: #3c2f1a;
}
Layer 2: JavaScript Injection — Behavioral Customization
Custom JavaScript runs in the browser context after the app loads. Use Admin Panel → Settings → Interface → Custom JavaScript. This layer enables keyboard shortcuts, auto-save, custom model selectors, and UI enhancements.
Theme Switcher Implementation
Add a floating theme toggle to the header:
// Theme Switcher for OpenWebUI
(function() {
const themes = ['default', 'cyberpunk', 'high-contrast', 'sepia'];
const storageKey = 'openwebui-custom-theme';
// Load saved theme
const savedTheme = localStorage.getItem(storageKey) || 'default';
document.documentElement.setAttribute('data-theme', savedTheme);
// Create toggle button
const btn = document.createElement('button');
btn.id = 'theme-toggle';
btn.setAttribute('aria-label', 'Switch theme');
btn.style.cssText = `
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
width: 48px; height: 48px; border-radius: 50%;
background: var(--bg-tertiary); border: 1px solid var(--border-primary);
color: var(--text-primary); cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transition: all var(--transition-normal);
`;
btn.innerHTML = '🎨';
btn.title = `Current: ${savedTheme}`;
btn.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'default';
const nextIndex = (themes.indexOf(current) + 1) % themes.length;
const nextTheme = themes[nextIndex];
document.documentElement.setAttribute('data-theme', nextTheme);
localStorage.setItem(storageKey, nextTheme);
btn.title = `Current: ${nextTheme}`;
btn.style.transform = 'rotate(360deg) scale(1.1)';
setTimeout(() => btn.style.transform = '', 300);
});
btn.addEventListener('mouseenter', () => {
btn.style.borderColor = 'var(--primary)';
btn.style.transform = 'scale(1.05)';
});
btn.addEventListener('mouseleave', () => {
btn.style.borderColor = 'var(--border-primary)';
btn.style.transform = 'scale(1)';
});
// Wait for app to mount
const observer = new MutationObserver(() => {
if (document.querySelector('header, nav, .app-header, [data-testid="header"]')) {
document.body.appendChild(btn);
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
})();
Keyboard Shortcuts for Power Users
Add custom hotkeys without touching core code:
// Custom Keyboard Shortcuts
(function() {
const shortcuts = new Map([
// Ctrl/Cmd + Shift + N = New Chat
['ctrl+shift+n', () => document.querySelector('[data-testid="new-chat"], button:has(svg[data-icon="plus"])')?.click()],
// Ctrl/Cmd + Shift + E = Export Chat
['ctrl+shift+e', () => document.querySelector('[data-testid="export-chat"], button:has(svg[data-icon="download"])')?.click()],
// Ctrl/Cmd + Shift + M = Toggle Model Selector
['ctrl+shift+m', () => document.querySelector('[data-testid="model-selector"], .model-selector')?.click()],
// Ctrl/Cmd + / = Focus Search
['ctrl+/', (e) => { e.preventDefault(); document.querySelector('input[type="search"], [data-testid="search-input"]')?.focus(); }],
// Escape = Close Modals / Deselect
['escape', () => document.querySelector('[data-testid="close-modal"], .modal-close, button[aria-label="Close"]')?.click()],
]);
document.addEventListener('keydown', (e) => {
// Ignore if typing in input/textarea
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName) || e.target.isContentEditable) return;
const key = [
e.ctrlKey || e.metaKey ? 'ctrl' : null,
e.shiftKey ? 'shift' : null,
e.altKey ? 'alt' : null,
e.key.toLowerCase()
].filter(Boolean).join('+');
if (shortcuts.has(key)) {
e.preventDefault();
shortcuts.get(key)();
}
});
// Show cheatsheet on Ctrl+Shift+?
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === '?') {
e.preventDefault();
const sheet = [...shortcuts.entries()].map(([k, v]) => `${k.replace(/\+/g, ' + ')}`).join('');
alert('Custom Shortcuts:\n' + [...shortcuts.keys()].join('\n'));
}
});
})();
Auto-Save Drafts to localStorage
Never lose a prompt again — persist drafts per chat:
// Auto-save Draft Persistence
(function() {
const STORAGE_PREFIX = 'owui_draft_';
let currentChatId = null;
let saveTimer = null;
const textareaSelector = 'textarea[data-testid="chat-input"], #chat-input, .chat-input textarea';
function getChatId() {
// Try to derive from URL or DOM
const path = window.location.pathname;
const match = path.match(/\/chat\/([a-f0-9-]+)/);
return match ? match[1] : 'default';
}
function saveDraft(text) {
if (!text.trim()) return;
const id = getChatId();
localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify({
text,
timestamp: Date.now()
}));
}
function loadDraft() {
const id = getChatId();
const stored = localStorage.getItem(STORAGE_PREFIX + id);
if (stored) {
try { return JSON.parse(stored).text; } catch { return ''; }
}
return '';
}
function clearDraft() {
const id = getChatId();
localStorage.removeItem(STORAGE_PREFIX + id);
}
// Watch for chat input
const observer = new MutationObserver(() => {
const textarea = document.querySelector(textareaSelector);
if (textarea && textarea !== window._draftTextarea) {
window._draftTextarea = textarea;
// Load existing draft
const draft = loadDraft();
if (draft && !textarea.value) {
textarea.value = draft;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
// Debounced save
textarea.addEventListener('input', () => {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => saveDraft(textarea.value), 500);
});
// Clear on successful send
const form = textarea.closest('form');
form?.addEventListener('submit', () => {
setTimeout(() => {
if (!textarea.value) clearDraft();
}, 100);
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
// Handle navigation changes (SPA)
let lastPath = location.pathname;
new MutationObserver(() => {
if (location.pathname !== lastPath) {
lastPath = location.pathname;
// Re-trigger observation for new chat
observer.takeRecords();
}
}).observe(document, { subtree: true, childList: true });
})();
Layer 3: Model Parameter Presets — Consistent Outputs
OpenWebUI allows per-model parameter configuration via the Admin Panel. For reproducible results across sessions, define parameter presets in Admin Panel → Settings → Models → [Model] → Parameters.
Recommended Preset Profiles
| Profile | Temperature | Top P | Top K | Repeat Penalty | Max Tokens | Best For |
|---|---|---|---|---|---|---|
| Precise / Coding | 0.1 | 0.95 | 40 | 1.1 | 4096 | Code generation, refactoring, logic-heavy tasks |
| Balanced Creative | 0.7 | 0.9 | 50 | 1.05 | 4096 | General chat, writing, brainstorming |
| High Creativity | 1.0 | 0.95 | 60 | 1.0 | 4096 | Storytelling, poetry, divergent thinking |
| Analytical / Reasoning | 0.3 | 0.9 | 40 | 1.15 | 8192 | Math, logic puzzles, step-by-step reasoning |
| Concise / Summary | 0.2 | 0.85 | 30 | 1.2 | 2048 | Summarization, extraction, classification |
Quick Model Switcher with Presets (JavaScript)
Inject a floating palette that applies parameter presets instantly:
// Model Parameter Preset Palette
(function() {
const presets = {
'🎯 Precise': { temperature: 0.1, top_p: 0.95, top_k: 40, repeat_penalty: 1.1 },
'⚖️ Balanced': { temperature: 0.7, top_p: 0.9, top_k: 50, repeat_penalty: 1.05 },
'🎨 Creative': { temperature: 1.0, top_p: 0.95, top_k: 60, repeat_penalty: 1.0 },
'🧠 Reasoning': { temperature: 0.3, top_p: 0.9, top_k: 40, repeat_penalty: 1.15 },
'📝 Concise': { temperature: 0.2, top_p: 0.85, top_k: 30, repeat_penalty: 1.2 },
};
const panel = document.createElement('div');
panel.id = 'preset-panel';
panel.style.cssText = `
position: fixed; top: 80px; right: 24px; z-index: 9998;
background: var(--bg-secondary); border: 1px solid var(--border-primary);
border-radius: var(--radius-lg); padding: 12px; min-width: 180px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
font-family: var(--font-sans); font-size: 13px;
display: none; flex-direction: column; gap: 6px;
`;
panel.innerHTML = 'Parameter Presets';
Object.entries(presets).forEach(([label, params]) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText = `
width: 100%; text-align: left; padding: 8px 12px;
background: transparent; border: 1px solid transparent;
border-radius: var(--radius-md); color: var(--text-primary);
cursor: pointer; transition: all var(--transition-fast);
`;
btn.addEventListener('mouseenter', () => {
btn.style.background = 'var(--bg-hover)';
btn.style.borderColor = 'var(--border-primary)';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = 'transparent';
btn.style.borderColor = 'transparent';
});
btn.addEventListener('click', () => applyPreset(params));
panel.appendChild(btn);
});
// Toggle hotkey: Ctrl+Shift+P
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
panel.style.display = panel.style.display === 'none' ? 'flex' : 'none';
}
});
// Click outside to close
document.addEventListener('click', (e) => {
if (!panel.contains(e.target) && !e.target.closest('#preset-toggle')) {
panel.style.display = 'none';
}
});
function applyPreset(params) {
// OpenWebUI stores params in localStorage per model
// This simulates the UI change — actual application requires model reload
const modelSelect = document.querySelector('[data-testid="model-selector"], .model-selector select');
if (modelSelect) {
const modelId = modelSelect.value;
const key = `openwebui.model.${modelId}.params`;
localStorage.setItem(key, JSON.stringify(params));
// Trigger UI update
window.dispatchEvent(new StorageEvent('storage', { key, newValue: JSON.stringify(params) }));
showToast(`Applied: ${JSON.stringify(params)}`);
}
}
function showToast(msg) {
const toast = document.createElement('div');
toast.textContent = msg;
toast.style.cssText = `
position: fixed; bottom: 80px; right: 24px; z-index: 9999;
background: var(--bg-tertiary); border: 1px solid var(--primary);
color: var(--primary); padding: 12px 16px; border-radius: var(--radius-md);
font-size: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
animation: slideIn 0.2s ease;
`;
document.body.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s'; setTimeout(() => toast.remove(), 300); }, 2000);
}
// Add keyframes
const style = document.createElement('style');
style.textContent = '@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }';
document.head.appendChild(style);
document.body.appendChild(panel);
})();
Layer 4: Pipeline Scripting — Custom Logic & RAG
Pipelines (Admin Panel → Settings → Pipelines) let you inject Python logic between user input and model output. This is where OpenWebUI becomes a programmable platform.
Example: Automatic Code Language Detection
# pipeline_code_detector.py
# Place in openwebui/pipelines/ or upload via Admin Panel
from typing import List, Dict, Any
import re
class Pipeline:
def __init__(self):
self.name = "Code Language Detector"
self.description = "Auto-detects programming language in user messages and adds context hint"
async def on_startup(self):
print(f"[{self.name}] Pipeline loaded")
async def on_shutdown(self):
pass
async def inlet(self, body: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
"""Process incoming message before sending to model"""
messages = body.get("messages", [])
if not messages:
return body
last_user_msg = None
for msg in reversed(messages):
if msg.get("role") == "user":
last_user_msg = msg.get("content", "")
break
if not last_user_msg:
return body
# Detect code blocks without language specifiers
code_blocks = re.findall(r'```(\w*)\n([\s\S]*?)```', last_user_msg)
detected_langs = []
for lang, code in code_blocks:
if not lang.strip():
detected = self._detect_language(code)
if detected:
detected_langs.append(f"Detected {detected} in unnamed code block")
if detected_langs:
# Prepend system hint
hint = "Note: " + "; ".join(detected_langs) + ". Please use appropriate syntax."
system_msg = {"role": "system", "content": hint}
body["messages"] = [system_msg] + messages
return body
def _detect_language(self, code: str) -> str:
"""Simple heuristic language detection"""
code = code.strip()
# Python
if re.search(r'^\s*(def|class|import|from|async def|await|with|lambda)\b', code, re.MULTILINE):
return "Python"
# JavaScript/TypeScript
if re.search(r'\b(const|let|var|async|await|=>|console\.log|export|import)\b', code):
return "JavaScript/TypeScript"
# Rust
if re.search(r'\b(fn|let|mut|struct|impl|trait|match|async)\b', code) and ';' in code:
return "Rust"
# Go
if re.search(r'\b(func|package|import|:=|go |defer)\b', code):
return "Go"
# SQL
if re.search(r'^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)\b', code, re.IGNORECASE | re.MULTILINE):
return "SQL"
# Bash
if re.search(r'^\s*(#!/|sudo|apt|yum|docker|kubectl|git |npm |pip )\b', code, re.MULTILINE):
return "Bash"
return ""
async def outlet(self, body: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
"""Process model response before returning to user"""
return body
Example: Response Length Controller
# pipeline_length_control.py
# Enforces max response length per model or user preference
from typing import Dict, Any
class Pipeline:
def __init__(self):
self.name = "Response Length Controller"
self.default_limits = {
"default": 2000,
"coding": 4000,
"summary": 500,
"creative": 3000,
}
async def inlet(self, body: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
# Check for length hint in last user message
messages = body.get("messages", [])
for msg in reversed(messages):
if msg.get("role") == "user":
content = msg.get("content", "").lower()
# Look for [length:X] or [mode:X] tags
import re
length_match = re.search(r'\[length:(\d+)\]', content)
mode_match = re.search(r'\[mode:(\w+)\]', content)
max_tokens = None
if length_match:
max_tokens = int(length_match.group(1))
elif mode_match:
mode = mode_match.group(1)
max_tokens = self.default_limits.get(mode, 2000)
if max_tokens:
# Inject into model params
body.setdefault("model_params", {})["max_tokens"] = max_tokens
# Strip the tag from user message
msg["content"] = re.sub(r'\[length:\d+\]|\[mode:\w+\]', '', msg["content"]).strip()
break
return body
async def outlet(self, body: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
return body
Layer 5: Docker-Level Customization — Persistent Builds
For teams deploying OpenWebUI at scale, bake customizations into a custom Docker image. This ensures every replica starts with your theme, pipelines, and config.
Dockerfile Template
# Dockerfile.custom
FROM ghcr.io/open-webui/open-webui:main
# Install additional system deps if needed
USER root
RUN apk add --no-cache git curl
# Copy custom assets
COPY custom.css /app/frontend/public/custom.css
COPY custom.js /app/frontend/public/custom.js
COPY pipelines/ /app/backend/pipelines/custom/
# Inject CSS/JS into index.html at build time
# (OpenWebUI's index.html is generated at runtime, so we use a startup script instead)
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Create non-root user
RUN adduser -D -u 1000 openwebui && \
chown -R openwebui:openwebui /app /docker-entrypoint.sh
USER openwebui
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["open-webui", "serve"]
Entrypoint Script for Runtime Injection
#!/bin/sh
# docker-entrypoint.sh
# Inject custom CSS/JS into the built frontend
# OpenWebUI serves static files from /app/frontend/build (or similar)
# This runs before the main process starts
CUSTOM_CSS="/app/frontend/public/custom.css"
CUSTOM_JS="/app/frontend/public/custom.js"
INDEX_HTML="/app/frontend/build/index.html"
if [ -f "$CUSTOM_CSS" ] && [ -f "$INDEX_HTML" ]; then
# Inject CSS link before
sed -i "s||\n|" "$INDEX_HTML"
fi
if [ -f "$CUSTOM_JS" ] && [ -f "$INDEX_HTML" ]; then
# Inject JS before
sed -i "s||\n|" "$INDEX_HTML"
fi
# Execute original entrypoint
exec "$@"
Build & Deploy
# Build custom image
docker build -t myorg/openwebui:custom-v1.0 -f Dockerfile.custom .
# Run with persistent config
docker run -d \
--name openwebui \
-p 3000:8080 \
-v openwebui-data:/app/backend/data \
-v ./pipelines:/app/backend/pipelines/custom \
--restart unless-stopped \
myorg/openwebui:custom-v1.0
Layer 6: Advanced UI Surgery — CSS Selectors Reference
When CSS variables aren't enough, target specific components. Use browser DevTools (F12 → Elements → Copy → Copy selector) to find stable selectors. Here are battle-tested ones:
/* Hide specific UI elements */
[data-testid="model-selector"] { display: none !important; } /* Hide model picker */
[data-testid="new-chat"] { display: none !important; } /* Hide new chat button */
.sidebar { display: none !important; } /* Hide entire sidebar */
.chat-header .model-badge { display: none !important; } /* Hide model badge in header */
/* Compact message spacing */
.chat-message { margin-bottom: 8px !important; }
.message-content { padding: 12px 16px !important; }
/* Custom scrollbar for chat area */
.chat-messages::-webkit-scrollbar { width: 8px; }
.chat-messages::-webkit-scrollbar-track { background: transparent; }
.chat-messages::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.chat-messages::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
/* Focus rings for accessibility */
*:focus-visible {
outline: 2px solid var(--primary) !important;
outline-offset: 2px !important;
}
/* Animated typing indicator */
.typing-indicator span {
animation: bounce 1.4s infinite ease-in-out both;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Custom code block header */
.code-block-header {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; background: var(--code-bg); border-bottom: 1px solid var(--code-border);
}
.code-block-header .language {
font-family: var(--font-mono); font-size: 0.75rem;
text-transform: uppercase; color: var(--text-muted);
}
.code-block-header .copy-btn {
background: var(--bg-hover); border: 1px solid var(--border-primary);
color: var(--text-secondary); padding: 4px 8px; border-radius: var(--radius-sm);
cursor: pointer; font-size: 0.75rem; transition: all var(--transition-fast);
}
.code-block-header .copy-btn:hover {
background: var(--primary); color: var(--bg-primary); border-color: var(--primary);
}
/* Message actions on hover */
.chat-message .message-actions { opacity: 0; transition: opacity var(--transition-fast); }
.chat-message:hover .message-actions { opacity: 1; }
/* Reduced motion respect */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Debugging & Iteration Workflow
Customization is iterative. Follow this loop:
- Prototype in DevTools: Use the Styles pane to test CSS variables and selectors live. Changes persist until refresh.
- Save to Custom CSS/JS fields: Copy working rules to Admin Panel → Settings → Interface.
- Test across viewports: Mobile, tablet, desktop — OpenWebUI is responsive; your CSS must be too.
- Verify accessibility: Run Lighthouse or axe-core. Ensure contrast ratios, focus order, and ARIA labels survive your changes.
- Version control your configs: Store
custom.css,custom.js, and pipeline scripts in a Git repo. Tag releases.
OpenWebUI's frontend is a SvelteKit app. If you need deep structural changes (reordering components, adding new routes), fork the official repo, modify frontend/src, and build your own Docker image. The customization layers above cover 95% of use cases without forking.
Conclusion: Your Interface, Your Rules
OpenWebUI's architecture — CSS variables for theming, JavaScript injection for behavior, model parameters for output control, pipelines for logic, and Docker for deployment — creates a rare thing in self-hosted software: a platform that bends completely to the operator without forking.
Start small: pick a color palette, add a theme switcher, define two parameter presets. Ship that. Then layer on auto-save drafts, a pipeline for your domain, a custom Docker image for your team. Each iteration compounds.
The best interface isn't the one with the most features — it's the one that disappears, leaving only the conversation between you and the model. Customize until it disappears.
"The best tool is the one you forget you're using — because it fits your hand so perfectly you only feel the work."
— Hesham Kholef
Comments (0)