Portal UI Guide
Last Updated: 2026-01-12 Status: Implemented Plan Reference: 033-portal-use-updates.md, 056-portal-ui-enhancement.md, 091-portal-ui-improvement.md
Overview
The Portal UI provides a consistent, responsive, and accessible user interface for both administrators and clients. It uses Tailwind CSS for styling, Blade components for reusability, and Alpine.js for interactivity.
Plan 056 Enhancements (2026-01-10):
- Full dark mode support with system preference detection
- Enhanced mobile responsiveness with utility classes
- Skeleton loading states and animations
- Skip links for accessibility (WCAG 2.1 AA)
- Theme toggle component with light/dark/system options
- Sticky navigation with improved mobile experience
Plan 091 Enhancements (2026-01-12):
- Mobile bottom sheet navigation for portal (
<x-portal.mobile-nav>) - Dark mode toggle button component (
<x-dark-mode-toggle>) - Accessible form input component (
<x-form.accessible-input>) - Staggered animations for lists (
.stagger-children) - Slide-in-right animation utility
Table of Contents
- Dark Mode
- Layouts
- Components
- Theme Toggle
- Responsive Design
- Loading States
- Accessibility
- Animations
- Navigation
- Forms
- Related Features
Dark Mode
How It Works
Dark mode is implemented using Tailwind CSS's class strategy and a JavaScript ThemeService.
- Tailwind Config:
darkMode: 'class'enables class-based dark mode - ThemeService: Manages theme state, localStorage, and system preference detection
- CSS Variables: Theme colors defined as CSS custom properties
ThemeService
Located at resources/js/services/ThemeService.js:
// Get current theme
window.themeService.getCurrentTheme();
// Set theme ('light', 'dark', or 'system')
window.themeService.setTheme('dark');
// Toggle between light and dark
window.themeService.toggleTheme();
// Check if dark mode is active
window.themeService.isDark();
// Listen for theme changes
document.addEventListener('themechange', (e) => {
console.log('Theme changed to:', e.detail.theme);
});
CSS Theme Variables
Defined in resources/css/theme.css:
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f9fafb;
--color-text-primary: #111827;
--color-border: #e5e7eb;
--color-accent: #2563eb;
}
.dark {
--color-bg-primary: #111827;
--color-bg-secondary: #1f2937;
--color-text-primary: #f9fafb;
--color-border: #374151;
--color-accent: #3b82f6;
}
Using Dark Mode Classes
All components support dark mode with the dark: prefix:
<div class="bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
<p class="text-gray-600 dark:text-gray-400">Subtitle text</p>
<div class="border border-gray-200 dark:border-gray-700">
Content with border
</div>
</div>
Flash Prevention
The layout includes an inline script to prevent flash of incorrect theme:
<script>
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
})();
</script>
Layouts
Available Layouts
| Layout | Path | Purpose | Dark Mode |
|---|---|---|---|
app | layouts/app.blade.php | Authenticated users | Yes |
guest | layouts/guest.blade.php | Login/register | Yes |
Layout Features
Both layouts include:
- Skip links for accessibility
- Theme toggle component
- Meta theme-color for mobile browsers
- Flash prevention script
- Alpine.js cloak style
Layout Structure
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="scroll-smooth">
<head>
<meta name="theme-color" content="#ffffff">
{{-- Flash prevention script --}}
<script>/* theme detection */</script>
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>[x-cloak] { display: none !important; }</style>
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<x-skip-links />
<nav id="main-navigation">
@include('layouts.navigation')
</nav>
<main id="main-content">
{{ $slot }}
</main>
</body>
</html>
Components
UI Components
Located in resources/views/components/ui/:
Button
<x-ui.button variant="primary" size="md">Save</x-ui.button>
<x-ui.button variant="danger" :loading="$saving">Delete</x-ui.button>
<x-ui.button variant="ghost" href="/back">Cancel</x-ui.button>
Variants: primary, secondary, outline, danger, success, warning, ghost, link
Sizes: xs, sm, md, lg, xl
Card
<x-ui.card>
<x-slot name="header">Card Title</x-slot>
Card content
<x-slot name="footer">Footer</x-slot>
</x-ui.card>
<x-ui.card :hover="true" :padding="false">
Hoverable card without default padding
</x-ui.card>
Badge
<x-ui.badge variant="success">Active</x-ui.badge>
<x-ui.badge variant="danger" size="sm">Overdue</x-ui.badge>
Variants: default, primary, success, warning, danger, info
Input
<x-ui.input name="email" type="email" :error="$errors->has('email')" />
Theme Toggle
Usage
{{-- Basic usage --}}
<x-theme-toggle />
{{-- With custom class --}}
<x-theme-toggle class="me-4" />
Features
- Light/Dark/System mode options
- Dropdown menu interface
- Persists preference to localStorage
- Respects system preference when set to "System"
- Updates meta theme-color for mobile browsers
Alpine.js Component
The theme toggle uses the themeToggle Alpine component:
Alpine.data('themeToggle', () => ({
theme: 'system',
open: false,
setTheme(value) {
// Handles theme switching
},
toggleTheme() {
// Quick toggle between light/dark
},
get isDark() {
return document.documentElement.classList.contains('dark');
}
}));
Responsive Design
Breakpoints
| Breakpoint | Min Width | Use Case |
|---|---|---|
sm | 640px | Mobile landscape |
md | 768px | Tablets |
lg | 1024px | Laptops |
xl | 1280px | Desktops |
2xl | 1536px | Large screens |
Utility Classes
Located in resources/css/responsive.css:
/* Container responsive */
.container-responsive { /* Responsive container with breakpoints */ }
/* Card grid - 1 col mobile, 2 tablet, 3-4 desktop */
.card-grid { @apply grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4; }
/* Stats grid - 2 mobile, 4 desktop */
.stats-grid { @apply grid-cols-2 md:grid-cols-4; }
/* Dashboard layout - stacked mobile, sidebar desktop */
.dashboard-layout { @apply flex-col lg:flex-row; }
/* Responsive text sizing */
.text-responsive-lg { @apply text-base sm:text-lg md:text-xl; }
/* Stack on mobile */
.stack-mobile { @apply flex-col sm:flex-row; }
Responsive Table
<x-responsive-table :headers="$headers" :mobileCardView="true">
@foreach($items as $item)
<tr>
<td>{{ $item->name }}</td>
<td>{{ $item->status }}</td>
</tr>
@endforeach
<x-slot name="cards">
@foreach($items as $item)
<x-mobile-card :title="$item->name">
<x-mobile-card-row label="Status" :value="$item->status" />
</x-mobile-card>
@endforeach
</x-slot>
</x-responsive-table>
Loading States
Skeleton Components
Located in resources/views/components/skeletons/:
Card Skeleton
<x-skeletons.card :lines="3" :avatar="true" />
Table Row Skeleton
<x-skeletons.table-row :columns="5" />
Text Skeleton
<x-skeletons.text :lines="3" width="3/4" />
Stat Skeleton
<x-skeletons.stat />
Loading Overlay
<x-loading-overlay message="Saving changes...">
<div x-data="{ loading: false }">
{{-- Content that can be overlaid --}}
</div>
</x-loading-overlay>
Spinner
<x-spinner size="md" color="blue" />
<x-spinner size="sm" color="white" />
Loading Dots
<x-loading-dots text="Loading" size="md" />
Accessibility
Skip Links
Automatically included in layouts:
<x-skip-links />
Provides quick navigation:
- "Skip to main content" ->
#main-content - "Skip to navigation" ->
#main-navigation
Focus Management
The FocusService (resources/js/services/FocusService.js) provides:
// Trap focus in a modal
const release = window.focusService.trapFocus(modalElement);
release(); // Call to release trap
// Save and restore focus
window.focusService.saveFocus();
// ... after modal closes
window.focusService.restoreFocus();
// Focus first invalid field
window.focusService.focusFirstInvalid(form);
// Announce to screen readers
window.focusService.announce('Item saved', 'polite');
Keyboard Detection
// Automatically adds class when using keyboard
document.body.classList.contains('using-keyboard')
ARIA Attributes
Components include proper ARIA attributes:
<button aria-label="Toggle theme"
:aria-expanded="open"
aria-haspopup="true">
Reduced Motion
CSS respects user preference:
@media (prefers-reduced-motion: reduce) {
.skeleton,
.hover-lift {
animation: none !important;
transition: none !important;
}
}
Animations
CSS Animation Classes
Located in resources/css/animations.css:
| Class | Effect |
|---|---|
skeleton | Shimmer loading effect |
hover-lift | Lift on hover with shadow |
press-effect | Scale down on click |
pulse-dot | Pulsing notification indicator |
spin | Continuous rotation |
shake | Error shake animation |
Tailwind Animations
Configured in tailwind.config.js:
animation: {
'shimmer': 'shimmer 1.5s infinite',
'fade-in': 'fadeIn 150ms ease-out',
'slide-down': 'slideDown 200ms ease-out',
'scale-in': 'scaleIn 150ms ease-out',
}
Transition Utilities
.transition-default { @apply transition-all duration-150 ease-in-out; }
.transition-slow { @apply transition-all duration-300 ease-in-out; }
.transition-fast { @apply transition-all duration-75 ease-in-out; }
Navigation
Admin Navigation
The navigation is sticky and dark-mode aware:
<nav class="bg-white dark:bg-gray-800 border-b border-gray-100 dark:border-gray-700 sticky top-0 z-40">
Admin Items: Dashboard, Clients, Projects, Invoices, Reports, More...
Portal Items: Dashboard, Projects, Invoices
Theme Toggle in Navigation
<div class="hidden sm:flex sm:items-center sm:ms-6">
<x-theme-toggle class="me-2" />
<x-realtime.notification-bell />
{{-- User dropdown --}}
</div>
Mobile Navigation
Responsive hamburger menu with smooth transitions:
<button @click="open = !open" class="...">
<svg :class="{'hidden': open, 'inline-flex': !open}"><!-- hamburger --></svg>
<svg :class="{'hidden': !open, 'inline-flex': open}"><!-- close --></svg>
</button>
Forms
Input Components
Standard input with dark mode:
<x-ui.input
name="email"
type="email"
:error="$errors->has('email')"
:disabled="$readonly"
/>
Form Grid
<div class="form-grid">
<x-ui.input name="first_name" />
<x-ui.input name="last_name" />
</div>
<div class="form-grid-3">
<x-ui.input name="city" />
<x-ui.input name="state" />
<x-ui.input name="zip" />
</div>
Technical Architecture
CSS Files
| File | Purpose |
|---|---|
resources/css/app.css | Main entry, imports others |
resources/css/theme.css | Theme variables, dark mode |
resources/css/responsive.css | Responsive utilities |
resources/css/animations.css | Animations, transitions |
JavaScript Services
| Service | Purpose |
|---|---|
ThemeService.js | Theme management, localStorage |
FocusService.js | Focus trapping, accessibility |
Component Directories
components/
├── ui/ # Enhanced UI components
│ ├── button.blade.php
│ ├── card.blade.php
│ ├── badge.blade.php
│ └── input.blade.php
├── skeletons/ # Loading skeletons
│ ├── card.blade.php
│ ├── table-row.blade.php
│ ├── text.blade.php
│ └── stat.blade.php
├── theme-toggle.blade.php
├── skip-links.blade.php
├── loading-overlay.blade.php
├── loading-dots.blade.php
├── spinner.blade.php
├── responsive-table.blade.php
├── mobile-card.blade.php
└── mobile-card-row.blade.php
Related Features
Dependencies
| Feature | Relationship |
|---|---|
| Tailwind CSS | Styling framework |
| Alpine.js | JavaScript interactivity |
| Vite | Asset bundling |
| Blade | Template engine |
Complementary Features
| Feature | Description |
|---|---|
| Admin Dashboard | Admin UI |
| Client Dashboard | Portal UI |
| Realtime | Live updates |
Best Practices
For Dark Mode
- Always include dark variants for colors
- Test both modes during development
- Use CSS variables for consistent theming
- Consider image variants for dark mode
For Accessibility
- Use semantic HTML (
nav,main,article) - Include skip links in layouts
- Ensure keyboard navigation works
- Maintain WCAG 2.1 AA contrast ratios
- Test with screen readers
For Performance
- Purge unused CSS in production
- Use skeleton states for perceived performance
- Minimize JavaScript bundle size
- Use SVG icons instead of icon fonts
Troubleshooting
| Issue | Solution |
|---|---|
| Dark mode flashes | Ensure inline script is before CSS |
| Theme not persisting | Check localStorage access |
| Skeleton not animating | Check reduced-motion setting |
| Focus trap not working | Verify focusable elements exist |
See Also
- Admin Dashboard - Admin pages
- Client Dashboard - Portal pages
- Blade Components Guide - Component patterns