towerops/docs/references/ux-frontend-best-practices.md
Graham McIntie 1590f78bdc fix: input validation, SSRF, API hardening, and cookie security
- 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
2026-03-14 14:48:59 -05:00

66 KiB
Raw Blame History

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

  1. Design Systems
  2. Layout Patterns
  3. Data Display
  4. Forms & Input
  5. Navigation
  6. Accessibility
  7. Animation & Transitions
  8. Phoenix LiveView Specific
  9. Color & Typography
  10. 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-xs for 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 truncate and max-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 7090%, 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:

  1. Loading → skeleton (content areas) or spinner (actions)
  2. Error → message + retry action
  3. Empty → illustration + explanation + primary action
  4. 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-change with 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 (150200ms).

<!-- 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-wider for 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 &gt; 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