OptimisticPanel provides optimistic UI components for Phoenix LiveView applications, featuring modal dialogs and sliding panels with smooth animations, focus management, and accessibility support.
- 🚀 Optimistic UI: Instant responsiveness while waiting for server confirmation
- 🎯 Focus Management: Built-in focus trapping using Phoenix LiveView's
focus_wrap
- ♿ Accessibility: Full ARIA support, keyboard navigation, and screen reader compatibility
- 🎨 Smooth Animations: CSS transitions with configurable durations and ghost animations
- 🏗️ State Management: Robust JavaScript state machine handling complex interaction scenarios
- 📱 Responsive: Works seamlessly across different screen sizes and devices
Add optimistic_panel
to your list of dependencies in mix.exs
:
def deps do
[
{:optimistic_panel, "~> 0.1.0"}
]
end
Add the OptimisticPanel hook to your Phoenix LiveView application:
// assets/js/app.js
import OptimisticPanel from "optimistic_panel/assets/js/hooks/optimistic_panel.js"
let Hooks = {
OptimisticPanel: OptimisticPanel,
// ... your other hooks
}
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: Hooks
})
In your LiveView modules, import the components you need:
defmodule MyAppWeb.SomePageLive do
use MyAppWeb, :live_view
# Import the components
import OptimisticPanel.OptimisticModalComponent
import OptimisticPanel.OptimisticSlideoverComponent
# ... rest of your LiveView
end
<.optimistic_modal
id="user-modal"
if={@user_form}
on_close={JS.dispatch("close-panel", to: "#user-modal") |> JS.push("close_user_form")}
duration="300"
close_on_escape="true"
>
<:main :let={on_close}>
<div class="bg-white p-6 rounded-lg">
<h2 class="text-xl font-bold mb-4">Edit User</h2>
<.form for={@user_form} phx-submit="save_user">
<input type="text" name="name" placeholder="Name" class="w-full p-2 border rounded mb-4" />
<div class="flex gap-2">
<button type="button" phx-click={on_close} class="px-4 py-2 bg-gray-200 rounded">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded">
Save
</button>
</div>
</.form>
</div>
</:main>
<:loading>
<div class="bg-white p-6 rounded-lg">
<div class="animate-spin h-8 w-8 border-4 border-blue-500 border-t-transparent rounded-full mx-auto"></div>
<p class="text-center mt-2">Loading...</p>
</div>
</:loading>
</.optimistic_modal>
<.optimistic_slideover
id="nav-panel"
if={@show_navigation}
slide_from="left"
on_close={JS.dispatch("close-panel", to: "#nav-panel") |> JS.push("close_nav")}
overlay_opacity="40"
duration="250"
>
<:main :let={on_close}>
<div class="h-full bg-white shadow-xl">
<div class="p-4 border-b">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">Navigation</h2>
<button phx-click={on_close} class="p-2 hover:bg-gray-100 rounded">Ă—</button>
</div>
</div>
<nav class="p-4">
<ul class="space-y-2">
<li><a href="/" class="block p-2 hover:bg-gray-100 rounded">Home</a></li>
<li><a href="/about" class="block p-2 hover:bg-gray-100 rounded">About</a></li>
<li><a href="/contact" class="block p-2 hover:bg-gray-100 rounded">Contact</a></li>
</ul>
</nav>
</div>
</:main>
<:loading>
<div class="h-full bg-white shadow-xl p-4">
<div class="animate-pulse">Loading navigation...</div>
</div>
</:loading>
</.optimistic_slideover>
Both components support these common attributes:
Attribute | Type | Default | Description |
---|---|---|---|
id |
string |
auto-generated | Unique identifier for the panel |
if |
any |
false |
Condition to show the panel |
on_close |
JS |
auto-generated | JavaScript command to execute when closing |
duration |
string |
"300" |
Animation duration in milliseconds |
close_on_escape |
string |
"true" |
Whether to close on escape key press |
close_on_overlay_click |
string |
"true" |
Whether to close when clicking the overlay |
Attribute | Type | Default | Description |
---|---|---|---|
slide_from |
string |
"left" |
Direction to slide from: "left" , "right" , "top" , "bottom" |
overlay_opacity |
string |
"50" |
Overlay opacity percentage (0-100) |
lock_body_scroll |
string |
"true" |
Whether to prevent body scrolling when open |
respect_reduced_motion |
string |
"true" |
Whether to respect user's reduced motion preferences |
The behavior of the panels depends on how you configure the on_close
attribute:
Include JS.dispatch("close-panel", to: "#panel-id")
in your on_close
command:
on_close={
JS.dispatch("close-panel", to: "#my-modal")
|> JS.push("handle_close")
}
Benefits:
- Panel closes immediately for responsive feel
- Server processes the close action afterward
- Smooth, app-like user experience
Use only server calls without the dispatch:
on_close={JS.push("handle_close")}
Characteristics:
- Panel waits for server response before closing
- More predictable but less responsive
- Useful when server validation is critical
The components use a sophisticated JavaScript state machine with these states:
closed
- Panel is hiddenopening
- Panel is animating in, waiting for serveropeningServerArrived
- Server confirmed while openingopen
- Panel is fully open and interactiveclosing
- Panel is closing optimisticallyclosingWaitingForServer
- Waiting for server close confirmationclosingWaitingForServerStateToOpen
- Server confirmed close, but new open requested
This state machine handles complex scenarios like:
- User closes panel before server responds to open
- User opens panel again while close is pending
- Network delays and race conditions
- Seamless transitions between states
OptimisticPanel components are built with accessibility in mind:
- Automatic focus trapping using Phoenix LiveView's
focus_wrap
- Focus moves to first interactive element when opened
- Focus returns to trigger element when closed
- Tab navigation stays within the panel
role="dialog"
andaria-modal="true"
aria-labelledby
andaria-describedby
supportaria-hidden
management during animationsaria-live
regions for loading states
- Escape key closes the panel (configurable)
- Tab and Shift+Tab cycle through interactive elements
- Enter and Space activate buttons and links
- Semantic HTML structure
- Descriptive labels and roles
- Status announcements for state changes
You can customize the animation duration and timing:
<.optimistic_modal
id="slow-modal"
if={@show_modal}
duration="600" # Slower animation
>
<!-- content -->
</.optimistic_modal>
You can have multiple panels open simultaneously:
<.optimistic_modal id="modal-1" if={@modal_1}>
<!-- modal content -->
</.optimistic_modal>
<.optimistic_slideover id="panel-1" if={@panel_1} slide_from="left">
<!-- slideover content -->
</.optimistic_slideover>
<.optimistic_slideover id="panel-2" if={@panel_2} slide_from="right">
<!-- another slideover -->
</.optimistic_slideover>
Disable certain behaviors based on conditions:
<.optimistic_modal
id="important-modal"
if={@critical_action}
close_on_escape="false" # Prevent accidental closure
close_on_overlay_click="false"
>
<!-- critical content that shouldn't be accidentally closed -->
</.optimistic_modal>
In your LiveView module:
defmodule MyAppWeb.PageLive do
use MyAppWeb, :live_view
import OptimisticPanel.OptimisticModalComponent
def mount(_params, _session, socket) do
{:ok, assign(socket, user_form: nil)}
end
def handle_event("open_user_form", %{"user_id" => user_id}, socket) do
user = Users.get_user!(user_id)
form = to_form(Users.change_user(user))
{:noreply, assign(socket, user_form: form)}
end
def handle_event("close_user_form", _params, socket) do
{:noreply, assign(socket, user_form: nil)}
end
def handle_event("save_user", %{"user" => user_params}, socket) do
case Users.update_user(socket.assigns.user_form.data, user_params) do
{:ok, _user} ->
{:noreply, assign(socket, user_form: nil)}
{:error, changeset} ->
{:noreply, assign(socket, user_form: to_form(changeset))}
end
end
end
A common pattern is to use OptimisticPanel with nested LiveComponents. The key is passing the on_close
callback:
<.optimistic_modal
id="user-modal"
if={@user_form}
on_close={JS.dispatch("close-panel", to: "#user-modal") |> JS.push("close_form")}
>
<:main :let={on_close}>
<.live_component
module={MyAppWeb.UserFormComponent}
id="user-form"
on_close={on_close}
user={@user}
/>
</:main>
</.optimistic_modal>
In your LiveComponent, use the on_close
callback:
def render(assigns) do
~H"""
<div class="bg-white p-6 rounded-lg">
<h2>Edit User</h2>
<.form phx-target={@myself} phx-submit="save">
<!-- form fields -->
<button type="button" phx-click={@on_close}>Cancel</button>
<button type="submit">Save</button>
</.form>
</div>
"""
end
The :loading
slot is displayed when if
is truthy but :main
content is not yet ready:
def handle_event("open_user_form", %{"user_id" => user_id}, socket) do
# Show loading immediately
socket = assign(socket, user_form: :loading)
# Fetch data asynchronously
send(self(), {:load_user, user_id})
{:noreply, socket}
end
def handle_info({:load_user, user_id}, socket) do
user = Users.get_user!(user_id)
form = to_form(Users.change_user(user))
{:noreply, assign(socket, user_form: form)}
end
- Check the
if
condition: Ensure the condition evaluates to a truthy value - Verify JavaScript hook: Make sure OptimisticPanel hook is registered
- Check for CSS conflicts: Ensure no CSS is hiding the panel
- Inspect element: Use browser dev tools to see if the panel exists in DOM
- No focusable elements: Ensure your panel content has interactive elements
- CSS interference: Check that focusable elements aren't hidden or disabled
- Tab order: Verify tabindex values don't interfere with natural tab flow
- Check duration format: Use string values like
"300"
, not integers - CSS transitions: Ensure your CSS doesn't override the component's transitions
- Reduced motion: Component respects
prefers-reduced-motion
settings
- Network delays: Long server responses can cause state conflicts
- Rapid interactions: Very fast user interactions might cause race conditions
- Check browser console: The hook logs state transitions for debugging
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature
) - Commit your changes (
git commit -m 'Add some amazing feature'
) - Push to the branch (
git push origin feature/amazing-feature
) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE file for details.