← The Vision

How to Customize Your OpenWebUI Chat: Complete Visual & Functional Tailoring Guide

From CSS theming and custom JavaScript to model parameters and pipeline overrides — a definitive step-by-step manual for making OpenWebUI truly yours

·
AI Generated Featured Image

Why Customize OpenWebUI?

A clean and stylish workspace featuring dual monitors, a lamp, and office supplies.
Photo by Lee Campbell on Pexels

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
⚠️ Important

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

css
: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:

css
@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):

css
/* 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

Close-up of HTML code displayed on a computer screen in dark mode, focusing on programming concepts.
Photo by César Gaviria on Pexels

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:

javascript
// 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:

javascript
// 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:

javascript
// 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

ProfileTemperatureTop PTop KRepeat PenaltyMax TokensBest For
Precise / Coding0.10.95401.14096Code generation, refactoring, logic-heavy tasks
Balanced Creative0.70.9501.054096General chat, writing, brainstorming
High Creativity1.00.95601.04096Storytelling, poetry, divergent thinking
Analytical / Reasoning0.30.9401.158192Math, logic puzzles, step-by-step reasoning
Concise / Summary0.20.85301.22048Summarization, extraction, classification

Quick Model Switcher with Presets (JavaScript)

Inject a floating palette that applies parameter presets instantly:

javascript
// 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

python
# 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

python
# 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

A close-up view of an RGB backlit gaming keyboard with a vibrant purple glow, perfect for tech and gaming enthusiasts.
Photo by Alena Darmel on Pexels

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
# 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

bash
#!/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

bash
# 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:

css
/* 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:

  1. Prototype in DevTools: Use the Styles pane to test CSS variables and selectors live. Changes persist until refresh.
  2. Save to Custom CSS/JS fields: Copy working rules to Admin Panel → Settings → Interface.
  3. Test across viewports: Mobile, tablet, desktop — OpenWebUI is responsive; your CSS must be too.
  4. Verify accessibility: Run Lighthouse or axe-core. Ensure contrast ratios, focus order, and ARIA labels survive your changes.
  5. Version control your configs: Store custom.css, custom.js, and pipeline scripts in a Git repo. Tag releases.
💡 Pro Tip

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
SPREAD THE SIGNAL

Know someone who would love this?

Share this journey and keep the conversation moving.

YOUR TAKE

What did this spark in you?

Every perspective adds another layer to the story.

Select an echo above to share your reflection.
Join 1 reader who shared their reaction. Top echo: Loved It (1)
CONTINUE THE CONVERSATION

Want to say more?

Tell us which idea stayed with you.

Comments (0)

Leave a Comment

Security Verification
Loading code…
Type the random letters and numbers shown above.

Comments are moderated and will appear after approval.

Album artwork
NOW PLAYING:
HK Media Radio
Tap play to tune in
PREV: HK Broadcast Stream • UP NEXT: Live Music Mix