- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia) - Jason.decode! → Jason.decode with error handling for untrusted input - inspect() leak: replace with generic error messages, log details server-side - SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes - SSRF validation added to HTTP monitoring executor and integration credentials - GraphQL complexity limits: always applied, not just in prod - GraphQL introspection: also check GET query params, not just body - Stripe webhook: explicit nil/empty checks for signature and body - Cookie security: secure flag for session (prod), http_only+secure for remember_me - Honeybadger API key: read from env var with fallback - String.to_integer → Integer.parse with fallback for URL params - String.to_atom → whitelist map for HTTP methods - Gaiia webhook: remove secret_len and expected signature from log - Admin API: add rate limiting pipeline - to_atom_keys: per-key fallback instead of all-or-nothing rescue
66 KiB
Modern UX & Front-End Design Best Practices
Reference guide for B2B SaaS dashboard applications, with emphasis on ISP/network monitoring tools. All examples use Tailwind CSS. Phoenix LiveView patterns included where relevant.
Table of Contents
- Design Systems
- Layout Patterns
- Data Display
- Forms & Input
- Navigation
- Accessibility
- Animation & Transitions
- Phoenix LiveView Specific
- Color & Typography
- Anti-Patterns
1. Design Systems
Component Hierarchy
Organize components in three tiers:
| Tier | Examples | Characteristics |
|---|---|---|
| Primitives | Button, Badge, Input, Label | Stateless, no business logic, maximum reuse |
| Composites | Card, DataTable, FormGroup, Modal | Combine primitives, may have local state |
| Features | DevicePanel, AlertFeed, BandwidthChart | Domain-specific, connected to data |
components/
├── ui/ # Primitives — button, badge, input, tooltip
├── composite/ # Composites — card, data_table, form_group
└── features/ # Features — device_panel, alert_feed
Design Tokens
Define tokens in tailwind.config.js — never use raw hex in templates:
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
// Semantic tokens
surface: {
DEFAULT: 'var(--color-surface)', // white / gray-900
raised: 'var(--color-surface-raised)', // gray-50 / gray-800
overlay: 'var(--color-surface-overlay)', // white / gray-800
},
border: {
DEFAULT: 'var(--color-border)', // gray-200 / gray-700
strong: 'var(--color-border-strong)', // gray-300 / gray-600
},
status: {
healthy: '#10b981', // emerald-500
warning: '#f59e0b', // amber-500
critical: '#ef4444', // red-500
unknown: '#6b7280', // gray-500
maint: '#3b82f6', // blue-500
},
},
spacing: {
'sidebar': '16rem', // 256px collapsed
'sidebar-sm': '4rem', // 64px collapsed
'topbar': '4rem', // 64px
},
fontSize: {
'kpi': ['2rem', { lineHeight: '2.5rem', fontWeight: '700' }],
'metric': ['1.5rem', { lineHeight: '2rem', fontWeight: '600' }],
},
},
},
}
Tailwind Best Practices
Avoid class soup — extract components, not utilities:
<!-- ❌ Class soup — repeated everywhere -->
<button class="inline-flex items-center justify-center rounded-md bg-indigo-600
px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
Save
</button>
<!-- ✅ Extract to a component (Phoenix) -->
<.button variant="primary" size="md">Save</.button>
The component function:
# core_components.ex
attr :variant, :string, default: "primary", values: ~w(primary secondary ghost danger)
attr :size, :string, default: "md", values: ~w(sm md lg)
attr :disabled, :boolean, default: false
slot :inner_block, required: true
def button(assigns) do
~H"""
<button
class={[
# Base
"inline-flex items-center justify-center rounded-md font-medium",
"shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
"disabled:opacity-50 disabled:cursor-not-allowed",
# Size
@size == "sm" && "px-3 py-1.5 text-xs",
@size == "md" && "px-4 py-2 text-sm",
@size == "lg" && "px-6 py-3 text-base",
# Variant
@variant == "primary" && "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500",
@variant == "secondary" && "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-indigo-500",
@variant == "ghost" && "bg-transparent text-gray-600 hover:bg-gray-100 hover:text-gray-900 shadow-none",
@variant == "danger" && "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
]}
disabled={@disabled}
{@rest}
>
<%= render_slot(@inner_block) %>
</button>
"""
end
Dark Mode
Use Tailwind's dark: variant with CSS custom properties for seamless theming:
/* app.css */
:root {
--color-surface: theme('colors.white');
--color-surface-raised: theme('colors.gray.50');
--color-surface-overlay: theme('colors.white');
--color-border: theme('colors.gray.200');
--color-border-strong: theme('colors.gray.300');
--color-text-primary: theme('colors.gray.900');
--color-text-secondary: theme('colors.gray.500');
}
.dark {
--color-surface: theme('colors.gray.900');
--color-surface-raised: theme('colors.gray.800');
--color-surface-overlay: theme('colors.gray.800');
--color-border: theme('colors.gray.700');
--color-border-strong: theme('colors.gray.600');
--color-text-primary: theme('colors.gray.100');
--color-text-secondary: theme('colors.gray.400');
}
Toggle strategy:
// Respect system preference, allow manual override
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
2. Layout Patterns
App Shell
The canonical B2B SaaS layout: fixed sidebar + top bar + scrollable content area.
<div class="flex h-screen bg-surface text-[var(--color-text-primary)]">
<!-- Sidebar -->
<aside class="hidden lg:flex lg:flex-col lg:w-sidebar border-r border-border
bg-surface-raised flex-shrink-0">
<!-- Logo -->
<div class="h-topbar flex items-center px-6 border-b border-border">
<img src="/logo.svg" class="h-8 w-auto" alt="TowerOps" />
</div>
<!-- Nav -->
<nav class="flex-1 overflow-y-auto py-4 px-3 space-y-1">
<!-- Active item -->
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2
bg-indigo-50 text-indigo-700 font-medium
dark:bg-indigo-500/10 dark:text-indigo-400">
<svg class="h-5 w-5 shrink-0"><!-- icon --></svg>
<span>Dashboard</span>
</a>
<!-- Inactive item -->
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2
text-gray-600 hover:bg-gray-100 hover:text-gray-900
dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200">
<svg class="h-5 w-5 shrink-0"><!-- icon --></svg>
<span>Devices</span>
</a>
</nav>
<!-- Footer / user -->
<div class="border-t border-border p-4">
<div class="flex items-center gap-3">
<div class="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center
text-sm font-medium text-indigo-700">GM</div>
<div class="text-sm">
<p class="font-medium">Graham</p>
<p class="text-[var(--color-text-secondary)] text-xs">Admin</p>
</div>
</div>
</div>
</aside>
<!-- Main content area -->
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Top bar -->
<header class="h-topbar flex items-center justify-between px-6
border-b border-border bg-surface flex-shrink-0">
<!-- Mobile menu button -->
<button class="lg:hidden -ml-2 p-2 rounded-md text-gray-500 hover:text-gray-700">
<svg class="h-6 w-6"><!-- hamburger --></svg>
</button>
<!-- Breadcrumbs / page title -->
<div class="flex items-center gap-2 text-sm">
<span class="text-[var(--color-text-secondary)]">Network</span>
<span class="text-[var(--color-text-secondary)]">/</span>
<span class="font-medium">Devices</span>
</div>
<!-- Right actions -->
<div class="flex items-center gap-4">
<!-- Search trigger -->
<button class="flex items-center gap-2 rounded-lg border border-border
px-3 py-1.5 text-sm text-[var(--color-text-secondary)]
hover:bg-gray-50 dark:hover:bg-gray-800">
<svg class="h-4 w-4"><!-- search icon --></svg>
<span>Search...</span>
<kbd class="hidden sm:inline-flex items-center rounded border border-border
px-1.5 text-xs font-mono text-[var(--color-text-secondary)]">⌘K</kbd>
</button>
<!-- Notifications -->
<button class="relative p-2 rounded-md text-gray-500 hover:text-gray-700">
<svg class="h-5 w-5"><!-- bell --></svg>
<span class="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500"></span>
</button>
</div>
</header>
<!-- Scrollable content -->
<main class="flex-1 overflow-y-auto p-6">
<!-- Page content goes here -->
</main>
</div>
</div>
Collapsible Sidebar
<!-- Sidebar with collapse state -->
<aside
class="flex flex-col border-r border-border bg-surface-raised flex-shrink-0
transition-[width] duration-200 ease-in-out"
:class="collapsed ? 'w-sidebar-sm' : 'w-sidebar'"
>
<!-- Nav items adapt -->
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2">
<svg class="h-5 w-5 shrink-0"><!-- icon --></svg>
<span x-show="!collapsed" x-transition class="truncate">Dashboard</span>
</a>
<!-- Collapse toggle at bottom -->
<button
@click="collapsed = !collapsed"
class="m-3 p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<svg class="h-5 w-5 transition-transform" :class="collapsed && 'rotate-180'">
<!-- chevron-left -->
</svg>
</button>
</aside>
Mobile Sidebar (Slide-over)
<!-- Mobile overlay -->
<div x-show="mobileMenuOpen" class="lg:hidden fixed inset-0 z-40 flex">
<!-- Backdrop -->
<div
x-show="mobileMenuOpen"
x-transition:enter="transition-opacity duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition-opacity duration-300"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="mobileMenuOpen = false"
class="fixed inset-0 bg-black/50"
></div>
<!-- Panel -->
<aside
x-show="mobileMenuOpen"
x-transition:enter="transition-transform duration-300"
x-transition:enter-start="-translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transition-transform duration-300"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="-translate-x-full"
class="relative w-sidebar bg-surface-raised flex flex-col"
>
<!-- Same nav content as desktop sidebar -->
</aside>
</div>
Dashboard Grid
Use CSS Grid with responsive breakpoints for KPI cards and widgets:
<!-- KPI row -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="rounded-lg border border-border bg-surface p-5">
<p class="text-sm text-[var(--color-text-secondary)]">Total Devices</p>
<p class="mt-1 text-kpi">1,247</p>
<p class="mt-1 flex items-center gap-1 text-xs text-emerald-600">
<svg class="h-3 w-3"><!-- arrow-up --></svg>
+12 this week
</p>
</div>
<!-- ... more cards -->
</div>
<!-- Widget grid — mixed sizes -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Large chart — spans 2 columns -->
<div class="lg:col-span-2 rounded-lg border border-border bg-surface p-5">
<h3 class="text-sm font-medium mb-4">Bandwidth Usage (24h)</h3>
<div class="h-64"><!-- chart --></div>
</div>
<!-- Side panel -->
<div class="rounded-lg border border-border bg-surface p-5">
<h3 class="text-sm font-medium mb-4">Recent Alerts</h3>
<!-- alert list -->
</div>
</div>
Card Component
<!-- Standard card -->
<div class="rounded-lg border border-border bg-surface overflow-hidden">
<!-- Optional header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 class="text-sm font-medium">Device Health</h3>
<button class="text-xs text-indigo-600 hover:text-indigo-700 font-medium">View All</button>
</div>
<!-- Body -->
<div class="p-5">
<!-- content -->
</div>
<!-- Optional footer -->
<div class="px-5 py-3 bg-surface-raised border-t border-border">
<p class="text-xs text-[var(--color-text-secondary)]">Last updated 2 min ago</p>
</div>
</div>
Skeleton Loading
<!-- Skeleton card -->
<div class="rounded-lg border border-border bg-surface p-5 animate-pulse">
<div class="h-3 w-24 bg-gray-200 dark:bg-gray-700 rounded mb-3"></div>
<div class="h-8 w-16 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
<div class="h-2 w-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<!-- Skeleton table rows -->
<div class="space-y-3 animate-pulse">
<div class="flex gap-4">
<div class="h-4 w-1/4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 w-1/3 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 w-1/6 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 w-1/4 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<!-- repeat 5-8 times -->
</div>
Skeleton shimmer effect (optional CSS):
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton-shimmer {
background: linear-gradient(90deg,
theme('colors.gray.200') 25%,
theme('colors.gray.100') 50%,
theme('colors.gray.200') 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.dark .skeleton-shimmer {
background: linear-gradient(90deg,
theme('colors.gray.700') 25%,
theme('colors.gray.600') 50%,
theme('colors.gray.700') 75%
);
}
3. Data Display
Tables
Tables are the backbone of network monitoring UIs. Get them right.
<div class="rounded-lg border border-border bg-surface overflow-hidden">
<!-- Table toolbar -->
<div class="flex flex-col sm:flex-row items-start sm:items-center
justify-between gap-3 px-5 py-4 border-b border-border">
<!-- Search -->
<div class="relative w-full sm:w-72">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4
text-[var(--color-text-secondary)]"><!-- search --></svg>
<input
type="search"
placeholder="Search devices..."
class="w-full rounded-md border border-border bg-surface pl-9 pr-3 py-1.5
text-sm placeholder:text-[var(--color-text-secondary)]
focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
<!-- Filters + bulk actions -->
<div class="flex items-center gap-2">
<select class="rounded-md border border-border bg-surface px-3 py-1.5
text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500">
<option>All Status</option>
<option>Online</option>
<option>Offline</option>
<option>Warning</option>
</select>
<button class="rounded-md border border-border px-3 py-1.5 text-sm
hover:bg-gray-50 dark:hover:bg-gray-800">
Filters
<span class="ml-1 inline-flex items-center justify-center h-5 w-5
rounded-full bg-indigo-100 text-indigo-700 text-xs font-medium
dark:bg-indigo-500/20 dark:text-indigo-400">2</span>
</button>
</div>
</div>
<!-- Bulk action bar (shown when rows selected) -->
<div class="hidden flex items-center gap-3 px-5 py-2.5 bg-indigo-50
dark:bg-indigo-500/10 border-b border-indigo-100 dark:border-indigo-500/20">
<span class="text-sm font-medium text-indigo-700 dark:text-indigo-400">
3 selected
</span>
<button class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">Reboot</button>
<button class="text-sm text-indigo-600 hover:text-indigo-800 font-medium">Export</button>
<button class="text-sm text-red-600 hover:text-red-800 font-medium">Delete</button>
</div>
<!-- The table -->
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-border bg-surface-raised text-left">
<th class="px-5 py-3 w-10">
<input type="checkbox" class="rounded border-gray-300 text-indigo-600
focus:ring-indigo-500" />
</th>
<!-- Sortable column -->
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]
cursor-pointer hover:text-[var(--color-text-primary)] select-none">
<div class="flex items-center gap-1">
Hostname
<svg class="h-4 w-4"><!-- sort indicator --></svg>
</div>
</th>
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]">IP Address</th>
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]">Status</th>
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]">Uptime</th>
<th class="px-5 py-3 font-medium text-[var(--color-text-secondary)]">Last Seen</th>
<th class="px-5 py-3 w-10"></th> <!-- actions column -->
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-5 py-3">
<input type="checkbox" class="rounded border-gray-300 text-indigo-600" />
</td>
<td class="px-5 py-3 font-medium">core-router-01</td>
<td class="px-5 py-3 font-mono text-xs">10.0.1.1</td>
<td class="px-5 py-3">
<span class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5
text-xs font-medium bg-emerald-50 text-emerald-700
dark:bg-emerald-500/10 dark:text-emerald-400">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
Online
</span>
</td>
<td class="px-5 py-3">142d 7h</td>
<td class="px-5 py-3 text-[var(--color-text-secondary)]">2 min ago</td>
<td class="px-5 py-3">
<button class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700">
<svg class="h-4 w-4 text-gray-400"><!-- ellipsis --></svg>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between px-5 py-3 border-t border-border">
<p class="text-sm text-[var(--color-text-secondary)]">
Showing <span class="font-medium">1</span> to <span class="font-medium">25</span>
of <span class="font-medium">1,247</span>
</p>
<div class="flex items-center gap-1">
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50 disabled:opacity-50" disabled>Previous</button>
<button class="rounded-md px-3 py-1.5 text-sm bg-indigo-600 text-white">1</button>
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50">2</button>
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50">3</button>
<span class="px-2 text-gray-400">...</span>
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50">50</button>
<button class="rounded-md px-3 py-1.5 text-sm border border-border
hover:bg-gray-50">Next</button>
</div>
</div>
</div>
Table design rules:
- Left-align text, right-align numbers
- Use
font-mono text-xsfor IPs, MACs, serial numbers - Status columns: colored badge with dot indicator
- Always provide a row hover state
- Sticky header on long tables:
sticky top-0 z-10 - Avoid wrapping — use
truncateandmax-w-*with tooltips
Charts — Which Type When
| Data Type | Chart | Example |
|---|---|---|
| Time series | Line/Area | Bandwidth, latency, CPU over time |
| Composition | Stacked area/bar | Traffic by protocol over time |
| Comparison | Horizontal bar | Top 10 talkers by bandwidth |
| Distribution | Histogram | Latency distribution |
| Single value + trend | KPI card + sparkline | Current throughput |
| Part of whole | Donut (never pie) | Device status breakdown |
| Geographic | Map with pins/heatmap | Tower locations, coverage |
| Correlation | Scatter plot | Packet loss vs. distance |
Chart guidelines for monitoring dashboards:
- Default to 24h time range with auto-refresh
- Always show the y-axis scale and units (Mbps, ms, %)
- Use area fills with low opacity (
fill-opacity: 0.1) for single-line charts - Color thresholds: green < 70%, amber 70–90%, red > 90%
- Provide time range selector: 1h | 6h | 24h | 7d | 30d
- Tooltip should show exact timestamp + value
- Loading state: show axes + gray placeholder area
KPI Widget
<div class="rounded-lg border border-border bg-surface p-5">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-[var(--color-text-secondary)]">Packet Loss</p>
<!-- Icon or sparkline -->
<svg class="h-5 w-5 text-gray-400"><!-- icon --></svg>
</div>
<div class="mt-2 flex items-baseline gap-2">
<p class="text-kpi">0.02%</p>
<!-- Trend badge -->
<span class="inline-flex items-center gap-0.5 text-xs font-medium text-emerald-600">
<svg class="h-3 w-3"><!-- arrow-down (good for packet loss) --></svg>
-0.01%
</span>
</div>
<p class="mt-1 text-xs text-[var(--color-text-secondary)]">vs. 0.03% yesterday</p>
</div>
Empty / Error / Loading States
Every data view needs all three. Never show a blank screen.
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-16 px-4 text-center">
<div class="rounded-full bg-gray-100 dark:bg-gray-800 p-4 mb-4">
<svg class="h-8 w-8 text-gray-400"><!-- relevant icon --></svg>
</div>
<h3 class="text-sm font-medium mb-1">No devices found</h3>
<p class="text-sm text-[var(--color-text-secondary)] mb-4 max-w-sm">
Add your first device to start monitoring your network.
</p>
<button class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white
hover:bg-indigo-700">
Add Device
</button>
</div>
<!-- Error state -->
<div class="flex flex-col items-center justify-center py-16 px-4 text-center">
<div class="rounded-full bg-red-50 dark:bg-red-500/10 p-4 mb-4">
<svg class="h-8 w-8 text-red-500"><!-- exclamation --></svg>
</div>
<h3 class="text-sm font-medium mb-1">Failed to load devices</h3>
<p class="text-sm text-[var(--color-text-secondary)] mb-4 max-w-sm">
Something went wrong. Please try again or contact support if the problem persists.
</p>
<button class="rounded-md border border-border px-4 py-2 text-sm font-medium
hover:bg-gray-50">
Retry
</button>
</div>
<!-- Loading — prefer skeleton over spinner for content areas -->
<!-- Use spinners only for actions (buttons, small updates) -->
<div class="flex items-center justify-center py-16">
<svg class="animate-spin h-6 w-6 text-indigo-600" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4" fill="none"/>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
</div>
State hierarchy:
- Loading → skeleton (content areas) or spinner (actions)
- Error → message + retry action
- Empty → illustration + explanation + primary action
- Data → the actual content
4. Forms & Input
Validation Patterns
Inline validation — validate on blur, show errors immediately:
<div>
<label for="hostname" class="block text-sm font-medium mb-1.5">Hostname</label>
<input
id="hostname"
type="text"
class="w-full rounded-md border px-3 py-2 text-sm
border-red-500 focus:border-red-500 focus:ring-1 focus:ring-red-500"
aria-invalid="true"
aria-describedby="hostname-error"
value="invalid hostname!"
/>
<p id="hostname-error" class="mt-1.5 text-xs text-red-600 flex items-center gap-1">
<svg class="h-3.5 w-3.5 shrink-0"><!-- exclamation-circle --></svg>
Hostname must contain only lowercase letters, numbers, and hyphens.
</p>
</div>
<!-- Valid state -->
<div>
<label for="ip" class="block text-sm font-medium mb-1.5">IP Address</label>
<div class="relative">
<input
id="ip"
type="text"
class="w-full rounded-md border border-emerald-500 px-3 py-2 pr-9 text-sm
focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
value="10.0.1.100"
/>
<svg class="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4
text-emerald-500"><!-- check-circle --></svg>
</div>
</div>
Validation rules:
- Validate on blur (not on every keystroke)
- Show success states for complex fields (IP addresses, CIDR notation)
- Group related errors at the top of the form for long forms
- Never clear the user's input on error
- For LiveView: use
phx-changewith debounce for real-time validation
Radio Card Groups
Perfect for selecting device types, plan tiers, or monitoring profiles:
<fieldset>
<legend class="text-sm font-medium mb-3">Device Type</legend>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<!-- Selected card -->
<label class="relative flex cursor-pointer rounded-lg border-2 border-indigo-600
bg-indigo-50 dark:bg-indigo-500/10 p-4">
<input type="radio" name="device_type" value="router" class="sr-only"
checked aria-describedby="type-router-desc" />
<div class="flex flex-col">
<span class="text-sm font-medium">Router</span>
<span id="type-router-desc" class="mt-1 text-xs text-[var(--color-text-secondary)]">
Core routing equipment
</span>
</div>
<!-- Check indicator -->
<svg class="absolute top-3 right-3 h-5 w-5 text-indigo-600"><!-- check-circle --></svg>
</label>
<!-- Unselected card -->
<label class="relative flex cursor-pointer rounded-lg border-2 border-border
hover:border-gray-300 dark:hover:border-gray-600 p-4
transition-colors">
<input type="radio" name="device_type" value="switch" class="sr-only" />
<div class="flex flex-col">
<span class="text-sm font-medium">Switch</span>
<span class="mt-1 text-xs text-[var(--color-text-secondary)]">
Layer 2/3 switching
</span>
</div>
</label>
<!-- Unselected card -->
<label class="relative flex cursor-pointer rounded-lg border-2 border-border
hover:border-gray-300 dark:hover:border-gray-600 p-4
transition-colors">
<input type="radio" name="device_type" value="ap" class="sr-only" />
<div class="flex flex-col">
<span class="text-sm font-medium">Access Point</span>
<span class="mt-1 text-xs text-[var(--color-text-secondary)]">
Wireless access point
</span>
</div>
</label>
</div>
</fieldset>
Search & Filter Patterns
Combo filter bar:
<div class="flex flex-wrap items-center gap-2">
<!-- Active filters as pills -->
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1
text-xs font-medium text-indigo-700
dark:bg-indigo-500/10 dark:text-indigo-400">
Status: Online
<button class="ml-0.5 hover:text-indigo-900">
<svg class="h-3 w-3"><!-- x --></svg>
</button>
</span>
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-50 px-3 py-1
text-xs font-medium text-indigo-700
dark:bg-indigo-500/10 dark:text-indigo-400">
Type: Router
<button class="ml-0.5 hover:text-indigo-900">
<svg class="h-3 w-3"><!-- x --></svg>
</button>
</span>
<!-- Clear all -->
<button class="text-xs text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]
underline">
Clear all
</button>
</div>
Auto-Save Indicator
<!-- In form header or toolbar -->
<div class="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<!-- Saving -->
<svg class="animate-spin h-3 w-3"><!-- spinner --></svg>
<span>Saving...</span>
<!-- Saved -->
<svg class="h-3 w-3 text-emerald-500"><!-- check --></svg>
<span>Saved</span>
<!-- Error -->
<svg class="h-3 w-3 text-red-500"><!-- x --></svg>
<span class="text-red-600">Failed to save</span>
<button class="underline text-red-600">Retry</button>
</div>
Confirmation Dialogs
Use for destructive actions only. Never confirm routine saves.
<!-- Confirmation modal -->
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="w-full max-w-md rounded-lg bg-surface shadow-xl">
<div class="p-6">
<!-- Icon -->
<div class="mx-auto h-12 w-12 rounded-full bg-red-50 dark:bg-red-500/10
flex items-center justify-center mb-4">
<svg class="h-6 w-6 text-red-600"><!-- exclamation-triangle --></svg>
</div>
<h3 class="text-lg font-semibold text-center mb-2">Delete Device</h3>
<p class="text-sm text-[var(--color-text-secondary)] text-center">
Are you sure you want to delete <span class="font-medium">core-router-01</span>?
All monitoring data will be permanently removed. This action cannot be undone.
</p>
</div>
<div class="flex gap-3 px-6 pb-6">
<button class="flex-1 rounded-md border border-border px-4 py-2 text-sm
font-medium hover:bg-gray-50">
Cancel
</button>
<button class="flex-1 rounded-md bg-red-600 px-4 py-2 text-sm font-medium
text-white hover:bg-red-700">
Delete
</button>
</div>
</div>
</div>
Rules for confirmation dialogs:
- Destructive button on the right, cancel on the left
- Name the action specifically ("Delete Device" not "Are you sure?")
- Include the resource name in the message
- Red color for destructive action button
- For high-stakes actions, require typing the resource name
5. Navigation
Breadcrumbs
<nav aria-label="Breadcrumb" class="flex items-center gap-2 text-sm">
<a href="/" class="text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)]">Home</a>
<svg class="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0">
<!-- chevron-right -->
</svg>
<a href="/network" class="text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)]">Network</a>
<svg class="h-4 w-4 text-gray-300 dark:text-gray-600 shrink-0">
<!-- chevron-right -->
</svg>
<span class="font-medium" aria-current="page">core-router-01</span>
</nav>
Tabs
<div class="border-b border-border">
<nav class="-mb-px flex gap-6" aria-label="Tabs">
<!-- Active tab -->
<a href="#"
class="border-b-2 border-indigo-600 pb-3 text-sm font-medium text-indigo-600
dark:text-indigo-400"
aria-current="page">
Overview
</a>
<!-- Inactive tab -->
<a href="#"
class="border-b-2 border-transparent pb-3 text-sm font-medium
text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)] hover:border-gray-300">
Interfaces
</a>
<a href="#"
class="border-b-2 border-transparent pb-3 text-sm font-medium
text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)] hover:border-gray-300">
Alerts
<!-- Badge for count -->
<span class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium
text-red-700 dark:bg-red-500/10 dark:text-red-400">3</span>
</a>
<a href="#"
class="border-b-2 border-transparent pb-3 text-sm font-medium
text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)] hover:border-gray-300">
Configuration
</a>
</nav>
</div>
Command Palette (Cmd+K)
Essential for power users in network monitoring tools:
<!-- Command palette overlay -->
<div class="fixed inset-0 z-50 flex items-start justify-center pt-[20vh] p-4 bg-black/50">
<div class="w-full max-w-xl rounded-xl bg-surface shadow-2xl border border-border
overflow-hidden">
<!-- Search input -->
<div class="flex items-center gap-3 px-4 border-b border-border">
<svg class="h-5 w-5 text-[var(--color-text-secondary)] shrink-0">
<!-- search icon -->
</svg>
<input
type="text"
placeholder="Search devices, commands, pages..."
class="w-full border-0 bg-transparent py-4 text-sm
placeholder:text-[var(--color-text-secondary)]
focus:outline-none focus:ring-0"
autofocus
/>
<kbd class="shrink-0 rounded border border-border px-1.5 py-0.5
text-xs font-mono text-[var(--color-text-secondary)]">ESC</kbd>
</div>
<!-- Results -->
<div class="max-h-80 overflow-y-auto py-2">
<!-- Group heading -->
<p class="px-4 py-1.5 text-xs font-medium text-[var(--color-text-secondary)]
uppercase tracking-wider">
Devices
</p>
<!-- Result item (highlighted) -->
<div class="mx-2 flex items-center gap-3 rounded-lg px-3 py-2.5
bg-indigo-50 dark:bg-indigo-500/10 cursor-pointer">
<span class="h-2 w-2 rounded-full bg-emerald-500 shrink-0"></span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">core-router-01</p>
<p class="text-xs text-[var(--color-text-secondary)]">10.0.1.1 · Router</p>
</div>
<kbd class="text-xs text-[var(--color-text-secondary)]">↵</kbd>
</div>
<!-- Result item (normal) -->
<div class="mx-2 flex items-center gap-3 rounded-lg px-3 py-2.5
hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer">
<span class="h-2 w-2 rounded-full bg-emerald-500 shrink-0"></span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">core-router-02</p>
<p class="text-xs text-[var(--color-text-secondary)]">10.0.1.2 · Router</p>
</div>
</div>
<!-- Group heading -->
<p class="px-4 py-1.5 mt-2 text-xs font-medium text-[var(--color-text-secondary)]
uppercase tracking-wider">
Actions
</p>
<div class="mx-2 flex items-center gap-3 rounded-lg px-3 py-2.5
hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer">
<svg class="h-5 w-5 text-[var(--color-text-secondary)]"><!-- icon --></svg>
<p class="text-sm">Add new device</p>
<div class="ml-auto flex gap-1">
<kbd class="rounded border border-border px-1.5 py-0.5 text-xs font-mono
text-[var(--color-text-secondary)]">⌘</kbd>
<kbd class="rounded border border-border px-1.5 py-0.5 text-xs font-mono
text-[var(--color-text-secondary)]">N</kbd>
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center gap-4 px-4 py-2.5 border-t border-border
text-xs text-[var(--color-text-secondary)]">
<span class="flex items-center gap-1">
<kbd class="rounded border border-border px-1 text-xs">↑↓</kbd> Navigate
</span>
<span class="flex items-center gap-1">
<kbd class="rounded border border-border px-1 text-xs">↵</kbd> Open
</span>
<span class="flex items-center gap-1">
<kbd class="rounded border border-border px-1 text-xs">ESC</kbd> Close
</span>
</div>
</div>
</div>
Notifications
Toast notifications — for transient feedback:
<!-- Toast container — fixed to bottom-right -->
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 w-96">
<!-- Success toast -->
<div class="flex items-start gap-3 rounded-lg border border-border bg-surface
p-4 shadow-lg" role="alert">
<svg class="h-5 w-5 text-emerald-500 shrink-0 mt-0.5"><!-- check-circle --></svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium">Device added</p>
<p class="mt-0.5 text-xs text-[var(--color-text-secondary)]">
core-switch-03 is now being monitored.
</p>
</div>
<button class="shrink-0 text-gray-400 hover:text-gray-600">
<svg class="h-4 w-4"><!-- x --></svg>
</button>
</div>
<!-- Error toast -->
<div class="flex items-start gap-3 rounded-lg border border-red-200 dark:border-red-500/30
bg-red-50 dark:bg-red-500/10 p-4 shadow-lg" role="alert">
<svg class="h-5 w-5 text-red-500 shrink-0 mt-0.5"><!-- x-circle --></svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-red-800 dark:text-red-400">Connection failed</p>
<p class="mt-0.5 text-xs text-red-600 dark:text-red-400/80">
Could not reach 10.0.1.5. Check SNMP credentials.
</p>
</div>
<button class="shrink-0 text-red-400 hover:text-red-600">
<svg class="h-4 w-4"><!-- x --></svg>
</button>
</div>
</div>
Toast rules:
- Auto-dismiss success toasts after 5 seconds
- Error toasts persist until dismissed
- Max 3 visible toasts; queue the rest
- Position: bottom-right for desktop, top-center for mobile
Banner notifications — for system-wide alerts:
<!-- Warning banner -->
<div class="flex items-center gap-3 bg-amber-50 dark:bg-amber-500/10 px-4 py-2.5
border-b border-amber-200 dark:border-amber-500/20">
<svg class="h-4 w-4 text-amber-600 shrink-0"><!-- exclamation-triangle --></svg>
<p class="text-sm text-amber-800 dark:text-amber-300">
<span class="font-medium">Scheduled maintenance:</span>
Core network upgrade on March 15 at 02:00 UTC.
<a href="#" class="underline font-medium">Learn more</a>
</p>
<button class="ml-auto shrink-0 text-amber-600 hover:text-amber-800">
<svg class="h-4 w-4"><!-- x --></svg>
</button>
</div>
Notification badge:
<!-- Sidebar nav item with badge -->
<a href="#" class="flex items-center justify-between rounded-lg px-3 py-2 text-sm
text-gray-600 hover:bg-gray-100">
<div class="flex items-center gap-3">
<svg class="h-5 w-5"><!-- bell --></svg>
<span>Alerts</span>
</div>
<span class="inline-flex items-center justify-center h-5 min-w-5 rounded-full
bg-red-600 px-1.5 text-xs font-medium text-white">
12
</span>
</a>
Onboarding
Setup checklist:
<div class="rounded-lg border border-border bg-surface p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-medium">Getting Started</h3>
<span class="text-xs text-[var(--color-text-secondary)]">2 of 4 complete</span>
</div>
<!-- Progress bar -->
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700 mb-4">
<div class="h-1.5 rounded-full bg-indigo-600 transition-all duration-500"
style="width: 50%"></div>
</div>
<ul class="space-y-3">
<!-- Completed -->
<li class="flex items-center gap-3 text-sm">
<div class="h-5 w-5 rounded-full bg-emerald-100 dark:bg-emerald-500/20
flex items-center justify-center shrink-0">
<svg class="h-3 w-3 text-emerald-600"><!-- check --></svg>
</div>
<span class="text-[var(--color-text-secondary)] line-through">Create your account</span>
</li>
<!-- Completed -->
<li class="flex items-center gap-3 text-sm">
<div class="h-5 w-5 rounded-full bg-emerald-100 dark:bg-emerald-500/20
flex items-center justify-center shrink-0">
<svg class="h-3 w-3 text-emerald-600"><!-- check --></svg>
</div>
<span class="text-[var(--color-text-secondary)] line-through">Install the agent</span>
</li>
<!-- Current step -->
<li class="flex items-center gap-3 text-sm">
<div class="h-5 w-5 rounded-full border-2 border-indigo-600
flex items-center justify-center shrink-0">
<div class="h-2 w-2 rounded-full bg-indigo-600"></div>
</div>
<a href="#" class="font-medium text-indigo-600 hover:text-indigo-700">
Add your first device
</a>
</li>
<!-- Pending -->
<li class="flex items-center gap-3 text-sm">
<div class="h-5 w-5 rounded-full border-2 border-gray-300 dark:border-gray-600
shrink-0"></div>
<span class="text-[var(--color-text-secondary)]">Configure alerting</span>
</li>
</ul>
</div>
Progressive disclosure: Show complexity only when needed.
<!-- Toggle advanced options -->
<div class="border-t border-border pt-4 mt-4">
<button
class="flex items-center gap-2 text-sm font-medium text-[var(--color-text-secondary)]
hover:text-[var(--color-text-primary)]"
@click="showAdvanced = !showAdvanced"
>
<svg class="h-4 w-4 transition-transform" :class="showAdvanced && 'rotate-90'">
<!-- chevron-right -->
</svg>
Advanced Settings
</button>
<div x-show="showAdvanced" x-collapse class="mt-4 space-y-4">
<!-- Advanced fields here -->
</div>
</div>
6. Accessibility
ARIA Essentials
<!-- Live region for real-time status updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
Device core-router-01 status changed to offline
</div>
<!-- Status badge — announce the full meaning -->
<span role="status" class="inline-flex items-center gap-1.5 rounded-full
bg-emerald-50 px-2.5 py-1 text-xs font-medium text-emerald-700">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500" aria-hidden="true"></span>
Online
</span>
<!-- Icon-only buttons MUST have labels -->
<button aria-label="Delete device" class="p-2 rounded-md hover:bg-gray-100">
<svg class="h-5 w-5" aria-hidden="true"><!-- trash --></svg>
</button>
<!-- Sortable table headers -->
<th scope="col" aria-sort="ascending">
<button class="flex items-center gap-1">
Hostname
<svg aria-hidden="true" class="h-4 w-4"><!-- sort-asc --></svg>
</button>
</th>
<!-- Modal dialog -->
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Delete Device</h2>
<!-- content -->
</div>
<!-- Tabs -->
<div role="tablist" aria-label="Device details">
<button role="tab" aria-selected="true" aria-controls="panel-overview"
id="tab-overview">Overview</button>
<button role="tab" aria-selected="false" aria-controls="panel-interfaces"
id="tab-interfaces" tabindex="-1">Interfaces</button>
</div>
<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">
<!-- content -->
</div>
Keyboard Navigation
| Component | Keys | Behavior |
|---|---|---|
| Modal | Escape |
Close |
| Tabs | ← → |
Switch tabs |
| Dropdown | ↑ ↓ Enter Escape |
Navigate, select, close |
| Table | Space |
Toggle row selection |
| Command palette | ⌘K / Ctrl+K |
Open |
| Toast | Auto-focus close button | Dismiss |
Focus Management
<!-- Focus trap in modal (use a library or JS hook) -->
<!-- First focusable element gets focus on open -->
<!-- Tab wraps within the modal -->
<!-- Focus returns to trigger element on close -->
<!-- Skip link (first element in body) -->
<a href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
focus:z-50 focus:rounded-md focus:bg-indigo-600 focus:px-4 focus:py-2
focus:text-white focus:text-sm focus:font-medium">
Skip to main content
</a>
<!-- Focus ring styling — visible only on keyboard navigation -->
<!-- Tailwind handles this with focus-visible: -->
<button class="rounded-md px-4 py-2 focus:outline-none
focus-visible:ring-2 focus-visible:ring-indigo-500
focus-visible:ring-offset-2">
Action
</button>
Color Contrast
WCAG 2.1 AA minimum contrast ratios:
| Element | Ratio | Example |
|---|---|---|
| Normal text (< 18px) | 4.5:1 | gray-600 on white = 5.4:1 ✅ |
| Large text (≥ 18px bold / 24px) | 3:1 | gray-500 on white = 4.6:1 ✅ |
| UI components & graphics | 3:1 | Border, icons, form controls |
Don't rely on color alone:
- ❌ Red text for errors, green text for success (color blind users lose info)
- ✅ Red text + error icon + descriptive text
- ✅ Status dot + text label ("Online", "Offline")
- ✅ Chart lines with different patterns/shapes, not just colors
<!-- ✅ Status uses color + icon + text -->
<span class="inline-flex items-center gap-1.5 text-xs font-medium text-red-700">
<svg class="h-4 w-4" aria-hidden="true"><!-- x-circle --></svg>
Critical
</span>
<!-- ✅ For charts, use patterns or distinct shapes alongside color -->
7. Animation & Transitions
CSS Transitions
Use for state changes, not entrance animations. Keep them fast (150–200ms).
<!-- Standard transition classes -->
<button class="transition-colors duration-150 ease-in-out
bg-indigo-600 hover:bg-indigo-700">Save</button>
<div class="transition-all duration-200 ease-out
hover:shadow-md hover:-translate-y-0.5">
<!-- Card with lift effect -->
</div>
<!-- Sidebar collapse -->
<aside class="transition-[width] duration-200 ease-in-out">
Duration guidelines:
- Micro-interactions (hover, focus):
duration-150 - Component transitions (expand, collapse):
duration-200 - Page/panel transitions:
duration-300 - Never exceed 500ms for UI transitions
Skeleton Screens
Prefer skeletons over spinners for content loading:
<!-- Skeleton pulse for a stats card -->
<div class="rounded-lg border border-border bg-surface p-5">
<div class="animate-pulse space-y-3">
<div class="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-8 w-24 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-2.5 w-16 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
When to use each:
- Skeleton: Loading content areas, tables, cards, pages
- Spinner: Submitting forms, button loading states, small inline updates
- Progress bar: File uploads, multi-step processes with known completion
- Nothing: If load time < 200ms, show nothing (avoids flash)
Optimistic UI
Update the UI immediately, reconcile when the server responds:
User clicks "Acknowledge Alert"
→ Immediately: badge changes to "Acknowledged", toast shows "Alert acknowledged"
→ Server responds 200: done
→ Server responds error: revert badge, show error toast "Failed to acknowledge alert. Retry?"
Micro-Interactions
<!-- Notification bell with shake on new alert -->
<button class="relative p-2 rounded-md hover:bg-gray-100
group transition-colors">
<svg class="h-5 w-5 text-gray-500 group-hover:text-gray-700
transition-colors"><!-- bell --></svg>
<!-- Animated ping on new notification -->
<span class="absolute top-1 right-1 flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full
rounded-full bg-red-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500"></span>
</span>
</button>
<!-- Expanding row in table -->
<tr class="cursor-pointer" @click="expanded = !expanded">
<!-- row content -->
</tr>
<tr x-show="expanded"
x-transition:enter="transition-all duration-200 ease-out"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition-all duration-150 ease-in"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2">
<td colspan="7" class="px-5 py-4 bg-surface-raised">
<!-- Expanded detail -->
</td>
</tr>
<!-- Counter/number animation (use JS for this) -->
<!-- Animate from old value to new value over ~400ms -->
Reduced Motion
Always respect the user preference:
<!-- Tailwind's motion-safe / motion-reduce -->
<div class="motion-safe:animate-pulse motion-reduce:opacity-70">
<!-- skeleton -->
</div>
<div class="motion-safe:transition-transform motion-safe:hover:-translate-y-0.5">
<!-- card -->
</div>
/* In CSS when Tailwind classes aren't enough */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
8. Phoenix LiveView Specific
LiveView + Tailwind Patterns
Component structure:
# lib/towerops_web/components/ui/status_badge.ex
defmodule ToweropsWeb.Components.UI.StatusBadge do
use Phoenix.Component
@status_styles %{
"healthy" => "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400",
"warning" => "bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400",
"critical" => "bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-400",
"unknown" => "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
"maint" => "bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400"
}
@dot_colors %{
"healthy" => "bg-emerald-500",
"warning" => "bg-amber-500",
"critical" => "bg-red-500",
"unknown" => "bg-gray-400",
"maint" => "bg-blue-500"
}
attr :status, :string, required: true, values: Map.keys(@status_styles)
attr :label, :string, default: nil
def status_badge(assigns) do
assigns =
assigns
|> assign(:style, @status_styles[assigns.status])
|> assign(:dot, @dot_colors[assigns.status])
|> assign_new(:label, fn -> Phoenix.Naming.humanize(assigns.status) end)
~H"""
<span class={["inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium", @style]}>
<span class={["h-1.5 w-1.5 rounded-full", @dot]} aria-hidden="true"></span>
<%= @label %>
</span>
"""
end
end
JS Hooks for Client-Side Interactivity
// assets/js/hooks/index.js
export const Hooks = {
// Chart hook — render/update charts client-side
Chart: {
mounted() {
this.chart = createChart(this.el, JSON.parse(this.el.dataset.config));
},
updated() {
const data = JSON.parse(this.el.dataset.series);
this.chart.updateSeries(data);
},
destroyed() {
this.chart?.destroy();
}
},
// Copy to clipboard
Clipboard: {
mounted() {
this.el.addEventListener("click", () => {
const text = this.el.dataset.copy;
navigator.clipboard.writeText(text).then(() => {
// Push event back to LiveView for toast
this.pushEvent("copied", { text });
});
});
}
},
// Command palette (Cmd+K)
CommandPalette: {
mounted() {
this.handleKeydown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
this.pushEvent("toggle_command_palette", {});
}
if (e.key === "Escape") {
this.pushEvent("close_command_palette", {});
}
};
document.addEventListener("keydown", this.handleKeydown);
},
destroyed() {
document.removeEventListener("keydown", this.handleKeydown);
}
},
// Auto-resize textarea
AutoResize: {
mounted() {
this.resize = () => {
this.el.style.height = "auto";
this.el.style.height = this.el.scrollHeight + "px";
};
this.el.addEventListener("input", this.resize);
this.resize();
}
},
// Preserve scroll position on LiveView updates
ScrollPreserve: {
mounted() {
this.scrollTop = 0;
},
beforeUpdate() {
this.scrollTop = this.el.scrollTop;
},
updated() {
this.el.scrollTop = this.scrollTop;
}
},
// Dark mode toggle (persists to localStorage)
DarkMode: {
mounted() {
this.el.addEventListener("click", () => {
const isDark = document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", isDark ? "dark" : "light");
this.pushEvent("theme_changed", { theme: isDark ? "dark" : "light" });
});
}
},
// Infinite scroll
InfiniteScroll: {
mounted() {
this.observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
this.pushEvent("load_more", {});
}
},
{ rootMargin: "200px" }
);
this.observer.observe(this.el);
},
destroyed() {
this.observer?.disconnect();
}
}
};
Live Navigation UX
# Use live_redirect for same-LiveView navigation (no page reload)
# Use push_navigate for cross-LiveView navigation
# In router.ex — group under live_session for shared layout
live_session :authenticated, on_mount: [ToweropsWeb.UserAuth] do
live "/dashboard", DashboardLive
live "/devices", DevicesLive.Index
live "/devices/:id", DevicesLive.Show
live "/alerts", AlertsLive.Index
end
# Loading indicator during live navigation
# In root layout:
<div
class="fixed top-0 left-0 right-0 h-0.5 bg-indigo-600 z-50
transition-all duration-300 ease-out"
style="width: 0%"
phx-hook="NavigationProgress"
></div>
// Navigation progress hook
NavigationProgress: {
mounted() {
this.width = 0;
window.addEventListener("phx:page-loading-start", () => {
this.width = 70;
this.el.style.width = this.width + "%";
this.el.style.opacity = "1";
});
window.addEventListener("phx:page-loading-stop", () => {
this.el.style.width = "100%";
setTimeout(() => {
this.el.style.opacity = "0";
setTimeout(() => {
this.el.style.width = "0%";
}, 300);
}, 200);
});
}
}
LiveView Form Patterns
# Form with real-time validation
def render(assigns) do
~H"""
<.form
for={@form}
phx-change="validate"
phx-submit="save"
phx-debounce="300"
class="space-y-6"
>
<div>
<.label for={@form[:hostname]}>Hostname</.label>
<.input
field={@form[:hostname]}
type="text"
placeholder="e.g., core-router-01"
phx-debounce="blur"
/>
</div>
<div>
<.label for={@form[:ip_address]}>IP Address</.label>
<.input
field={@form[:ip_address]}
type="text"
placeholder="e.g., 10.0.1.1"
phx-debounce="blur"
/>
</div>
<!-- Conditional fields based on selection -->
<div>
<.label for={@form[:device_type]}>Device Type</.label>
<.input field={@form[:device_type]} type="select"
options={[{"Router", "router"}, {"Switch", "switch"}, {"AP", "ap"}]} />
</div>
<%= if @form[:device_type].value == "ap" do %>
<div phx-mounted={JS.transition("fade-in-scale", time: 200)}>
<.label for={@form[:ssid]}>SSID</.label>
<.input field={@form[:ssid]} type="text" />
</div>
<% end %>
<div class="flex items-center justify-end gap-3 pt-4 border-t border-border">
<.button type="button" variant="secondary" phx-click="cancel">Cancel</.button>
<.button type="submit" phx-disable-with="Saving...">Save Device</.button>
</div>
</.form>
"""
end
def handle_event("validate", %{"device" => params}, socket) do
form =
%Device{}
|> Device.changeset(params)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, form: form)}
end
def handle_event("save", %{"device" => params}, socket) do
case Devices.create_device(params) do
{:ok, device} ->
{:noreply,
socket
|> put_flash(:info, "Device #{device.hostname} created successfully.")
|> push_navigate(to: ~p"/devices/#{device}")}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
Real-Time Updates with PubSub
# Subscribe on mount
def mount(_params, _session, socket) do
if connected?(socket) do
ToweropsWeb.Endpoint.subscribe("devices:status")
ToweropsWeb.Endpoint.subscribe("alerts:new")
end
{:ok, assign(socket, devices: Devices.list_devices())}
end
# Handle broadcasts
def handle_info(%{topic: "devices:status", payload: %{device_id: id, status: status}}, socket) do
devices =
Enum.map(socket.assigns.devices, fn device ->
if device.id == id, do: %{device | status: status}, else: device
end)
{:noreply, assign(socket, devices: devices)}
end
# Publish from context
def update_device_status(device, status) do
device
|> Device.status_changeset(%{status: status})
|> Repo.update()
|> case do
{:ok, device} ->
ToweropsWeb.Endpoint.broadcast("devices:status", "status_changed", %{
device_id: device.id,
status: device.status
})
{:ok, device}
error ->
error
end
end
LiveView real-time update with CSS transition:
# Highlight row briefly when data changes
<tr id={"device-#{device.id}"}
class="hover:bg-gray-50 dark:hover:bg-gray-800/50
transition-colors duration-500"
phx-hook="FlashUpdate">
<!-- When this element updates, the hook adds a highlight class briefly -->
</tr>
FlashUpdate: {
updated() {
this.el.classList.add("bg-indigo-50", "dark:bg-indigo-500/5");
setTimeout(() => {
this.el.classList.remove("bg-indigo-50", "dark:bg-indigo-500/5");
}, 1500);
}
}
9. Color & Typography
Gray Scales
Use a single gray scale consistently. Tailwind's gray (the default, formerly coolGray) works well for UI-heavy apps:
gray-50: #f9fafb — subtle backgrounds
gray-100: #f3f4f6 — hover states, skeleton fills
gray-200: #e5e7eb — borders, dividers
gray-300: #d1d5db — disabled borders, secondary icons
gray-400: #9ca3af — placeholder text, disabled text
gray-500: #6b7280 — secondary text (light mode)
gray-600: #4b5563 — primary body text
gray-700: #374151 — dark mode borders
gray-800: #1f2937 — dark mode surfaces
gray-900: #111827 — dark mode base, headings
gray-950: #030712 — darkest background
Accent Colors
Pick one primary + semantic status colors:
// tailwind.config.js
colors: {
primary: {
50: '#eef2ff', // indigo-50
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1', // indigo-500 — primary accent
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4e',
},
// Status colors — use sparingly, with purpose
success: colors.emerald,
warning: colors.amber,
danger: colors.red,
info: colors.blue,
}
Dark Mode Color Mapping
| Element | Light | Dark |
|---|---|---|
| Page background | white / gray-50 |
gray-900 / gray-950 |
| Card/surface | white |
gray-800 |
| Raised surface | gray-50 |
gray-700 |
| Border | gray-200 |
gray-700 |
| Primary text | gray-900 |
gray-100 |
| Secondary text | gray-500 |
gray-400 |
| Accent bg | indigo-50 |
indigo-500/10 |
| Accent text | indigo-700 |
indigo-400 |
| Status badges | emerald-50 / emerald-700 |
emerald-500/10 / emerald-400 |
Key principle: In dark mode, use lower opacity backgrounds (bg-indigo-500/10) instead of dark shade equivalents. It adapts better.
Typography Scale
/* Font stack — system fonts for speed */
font-family: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
/* For monospace (IPs, code, configs) */
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
| Use | Tailwind | Size | Weight |
|---|---|---|---|
| Page title | text-xl font-semibold |
20px | 600 |
| Section heading | text-lg font-semibold |
18px | 600 |
| Card heading | text-sm font-medium |
14px | 500 |
| Body text | text-sm |
14px | 400 |
| Small/caption | text-xs |
12px | 400 |
| KPI number | text-kpi (custom) |
32px | 700 |
| Metric number | text-metric (custom) |
24px | 600 |
| Monospace data | font-mono text-xs |
12px | 400 |
| Badge/label | text-xs font-medium |
12px | 500 |
Rules:
- 14px (
text-sm) is the base for B2B SaaS — not 16px. Dashboard density matters. - Use
font-medium(500) for emphasis,font-semibold(600) for headings - Avoid
font-bold(700) except for KPI numbers - Never use ALL CAPS for more than 2-3 word labels
tracking-widerfor uppercase labels:text-xs font-medium uppercase tracking-wider
10. Anti-Patterns
❌ Modal Overuse
Problem: Using modals for everything — forms, confirmations, details, settings.
Why it's bad:
- Blocks the user from referencing underlying content
- Stacking modals creates confusion
- Mobile modals are painful
- Interrupts flow for routine actions
Instead:
| Action | Use |
|---|---|
| Quick edit (name, status) | Inline edit |
| Create/edit form | Slide-over panel or dedicated page |
| Details view | Expandable row or side panel |
| Confirmation | Only for destructive + irreversible actions |
| Settings | Dedicated page with sections |
<!-- ✅ Slide-over panel instead of modal for forms -->
<div class="fixed inset-y-0 right-0 z-40 w-full max-w-lg
border-l border-border bg-surface shadow-xl
transform transition-transform duration-300"
:class="open ? 'translate-x-0' : 'translate-x-full'">
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 class="text-lg font-semibold">Edit Device</h2>
<button @click="open = false" class="p-2 rounded-md hover:bg-gray-100">
<svg class="h-5 w-5"><!-- x --></svg>
</button>
</div>
<div class="overflow-y-auto p-6" style="height: calc(100vh - 65px)">
<!-- form content -->
</div>
</div>
❌ Notification Fatigue
Problem: Alerting on everything, alert storms, redundant notifications.
Symptoms:
- Users ignore all alerts
- Alert count badge permanently shows 99+
- Multiple channels notifying for the same event
Rules:
- Deduplicate: Group related alerts (if 20 devices go down because a switch died, show 1 alert about the switch)
- Severity levels: Only push-notify on Critical. Show Warning in-app. Log Info.
- Snooze/acknowledge: Let users silence specific alerts
- Escalation, not repetition: Alert once, escalate if no response after N minutes
- Smart defaults: Don't enable all alerts by default — progressive opt-in
❌ Settings Bloat
Problem: Exposing every configuration option to every user.
Instead:
- Smart defaults that work for 80% of users
- Role-based visibility: Admins see advanced settings, operators see what they need
- Progressive disclosure: Basic settings visible, "Advanced" collapsed
- Contextual settings: Put them where they're relevant, not in a mega settings page
<!-- ❌ Mega settings page with 50 fields -->
<!-- ✅ Settings organized into clear sections with smart defaults -->
<nav class="w-48 shrink-0 space-y-1">
<a class="block rounded-md px-3 py-2 text-sm bg-gray-100 font-medium">General</a>
<a class="block rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">Notifications</a>
<a class="block rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">SNMP</a>
<a class="block rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">Integrations</a>
<a class="block rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-50">API Keys</a>
</nav>
❌ Vanity Metrics
Problem: Dashboards full of numbers that look impressive but don't help anyone.
"Total API calls: 14,203,847" — So what?
Better:
- Show metrics that drive action: "3 devices offline", "Latency > 100ms on 2 links"
- Include context: "Packet loss 0.5% (↑ 0.3% from baseline)"
- Add thresholds: Color/icon change when metrics leave normal range
- Provide drill-down: Click the KPI to see the underlying data
<!-- ❌ Vanity metric -->
<div class="p-5">
<p class="text-sm text-gray-500">Total Packets Processed</p>
<p class="text-3xl font-bold">847,293,471</p>
</div>
<!-- ✅ Actionable metric -->
<div class="p-5">
<p class="text-sm text-gray-500">Devices Needing Attention</p>
<p class="text-3xl font-bold text-amber-600">7</p>
<div class="mt-2 space-y-1">
<p class="text-xs text-red-600">2 offline > 30 min</p>
<p class="text-xs text-amber-600">3 high packet loss</p>
<p class="text-xs text-amber-600">2 nearing capacity</p>
</div>
<a href="#" class="mt-2 inline-block text-xs font-medium text-indigo-600
hover:text-indigo-700">View all →</a>
</div>
❌ Other Common Anti-Patterns
| Anti-Pattern | Problem | Better Approach |
|---|---|---|
| Loading spinner everywhere | User has no sense of what's coming | Skeleton screens that mirror actual layout |
| Infinite scroll on data tables | Can't bookmark position, loses context | Paginated with page size selector |
| Auto-refresh entire page | Loses user state (scroll, selection) | LiveView partial updates via PubSub |
| Hamburger menu on desktop | Hides primary navigation | Always-visible sidebar on desktop |
| Tooltip for essential info | Mobile users can't hover | Show inline or in a visible label |
| Disabled buttons with no explanation | User doesn't know why they can't act | Tooltip explaining why, or hide the action |
| "Are you sure?" for everything | Confirmation blindness | Only for destructive actions; use undo for the rest |
| Giant forms | Abandonment, cognitive overload | Multi-step wizard or progressive disclosure |
| Inconsistent empty states | Some pages blank, some have messages | Every view handles empty, loading, and error |
Quick Reference: Status Color System for Network Monitoring
<!-- Consistent status indicators across the entire app -->
<!-- Online/Healthy -->
<span class="text-emerald-600 dark:text-emerald-400">●</span>
<!-- CSS: bg-emerald-500 for dots, bg-emerald-50 / bg-emerald-500/10 for badges -->
<!-- Warning -->
<span class="text-amber-500 dark:text-amber-400">●</span>
<!-- Critical/Offline -->
<span class="text-red-500 dark:text-red-400">●</span>
<!-- Unknown/Unreachable -->
<span class="text-gray-400 dark:text-gray-500">●</span>
<!-- Maintenance -->
<span class="text-blue-500 dark:text-blue-400">●</span>
<!-- Degraded -->
<span class="text-orange-500 dark:text-orange-400">●</span>
Apply this everywhere: sidebar nav icons, table rows, map pins, KPI cards, device headers, alert badges. Consistency is everything.
Last updated: 2026-03-13