netoum
Corex - Accessible and unstyled UI Phoenix components
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and LiveView hooks.
It works with both Phoenix Controllers and LiveView without requiring a JavaScript framework or Node.js build process.
Currently in early alpha, looking for feedback on the architecture, API design, and overall approach
History
I originally created corex-ui.com, a Vanilla JS integration of Zag.js for static websites. The challenge was adapting this approach to Phoenix’s server-rendered model while feeling natural to Phoenix developers. Corex is the result: interactive, accessible components that work with Phoenix conventions rather than against them.
Why Corex
State Machines for Complex Interactions
Zag.js handles intricate state management and accessibility concerns. An accordion must manage which items are open/closed, keyboard navigation, focus management, ARIA attributes, and animation states. Rather than implementing this yourself, Zag.js provides battle-tested state machines.
Seamless Phoenix Integration
Corex wraps Zag.js with ergonomic Phoenix components:
Manual Slot
<.accordion>
<:trigger value="anatomy">Anatomy</:trigger>
<:trigger value="machine">State machines</:trigger>
<:content value="anatomy">Structure & slots</:content>
<:content value="machine">Zag.js on the client</:content>
</.accordion>
With List
<.accordion
class="accordion"
items={
Corex.Content.new([
%{trigger: "Anatomy", content: "Structure & slots"},
%{trigger: "State machines", content: "Zag.js on the client"}
])
}
/>
API Control and Events
Control components from client or server:
Client
<.action phx-click={Corex.Accordion.set_value("my-accordion", ["item-1"])}>
Open Item 1
</.action>
Server
def handle_event("open_item", _, socket) do
{:noreply, Corex.Accordion.set_value(socket, "my-accordion", ["item-1"])}
end
Unstyled by Default
Components ship with zero styling. They expose semantic data attributes you can target with your own CSS:
[data-scope="accordion"][data-part="item-trigger"] {
/* Your styles */
}
[data-scope="accordion"][data-part="item-trigger"][data-state="open"] {
/* Open state styles */
}
Works with any design system without style overrides or specificity battles.
Simple by Design
Installation is straightforward:
use Corex
import Hooks from "corex"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: {...colocatedHooks, ...Hooks}
})
Progressive Enhancement
Uncontrolled by default: Components manage their own state on the client using Zag.js. User interactions update the UI immediately without server round-trips. Covers most use cases.
Controlled when needed: The server owns the state. State changes emit as events and reflect back through assigns. Useful when component state must be validated, persisted, or coordinated with application logic.
def mount(_params, _session, socket) do
{:ok, assign(socket, :value, ["item-1"])}
end
def handle_event("on_value_change", %{"value" => value}, socket) do
{:noreply, assign(socket, :value, value)}
end
Both modes expose the same interaction API and can be mixed within the same application.
Forms and Validation
Integrates with Phoenix forms without custom abstractions. Components work without server validation (client-managed state) or with changesets (server-side validation). Form fields, labels, and errors are passed explicitly through slots.
Feedback, and suggestions welcome as I continue developing this library.
Documentation
Corex Demo
Corex Hex Doc
Corex Hex PM
Github:
Most Liked
netoum
How to render and search 9000+ items in a Combobox?
The Corex combobox component works great for dozens or even hundreds of items. It receives the full list and filters client-side on every keystroke.
But what happens when your list reaches the thousands?
Client-side filtering breaks down. You can’t ship 10,000 items to the browser and call it a day.
The solution: keep rendering client-side, but let the server own the data.
Disable client-side filtering, listen to the input change event, and update the item list on the fly from the server. The component still renders what it receives, you just control what it receives.
For the curious, this has been made possible with the latest update on ZagJS Vanilla machine allowing runtime updates of the props combined with the updated() hook life cycle of Live View
This gives you the best of both worlds:
- Instant client-side rendering, accessibility attributes and keyboard navigation
- Server-side queries that scale to any dataset size
- Full control over the initial state on mount, you can even display a totally different list of items
- Custom empty state slot when nothing matches
- Compatible with groups or items. Search can also include the group name
- Integrates with Phoenix form
Minimal code
defmodule MyAppWeb.CountryCombobox do
use MyAppWeb, :live_view
@items [
%{id: "fra", label: "France"},
%{id: "bel", label: "Belgium"},
%{id: "deu", label: "Germany"},
%{id: "usa", label: "USA"},
%{id: "jpn", label: "Japan"}
]
def mount(_params, _session, socket) do
{:ok, assign(socket, items: [])}
end
def handle_event("search", %{"value" => value, "reason" => "input-change"}, socket) do
filtered =
if byte_size(value) < 1 do
[]
else
term = String.downcase(value)
Enum.filter(@items, fn item ->
String.contains?(String.downcase(item.label), term)
end)
end
{:noreply, assign(socket, items: filtered)}
end
def render(assigns) do
~H"""
<.combobox
id="country-combobox"
collection={@items}
filter={false}
on_input_value_change="search"
>
<:empty>No results</:empty>
<:trigger><.icon name="hero-chevron-down" /></:trigger>
</.combobox>
"""
end
end
Disable client filtering with disabled={false}
Use on_input_value_change to filter on the server.
This example uses a local list, you can replace it with a database query.
Try it yourself, search over 9000 airports grouped across 250 cities.
netoum
Dark Mode Toggle
Built on Corex.ToggleGroup, it uses a triple-layer approach (cookies + localStorage + immediate script execution) to ensure:
- No FOUC (Flash of Unstyled Content)
- Syncs across browser tabs
- Respects system preferences
- Works perfectly with LiveView and controllers
Signature Pad
- Full Phoenix form integration (controllers & LiveView)
- Works with and without Ecto changesets
- Controlled/uncontrolled modes
- Customizable drawing options (color, size, pressure simulation)
Signature Pad joins the growing collection of form components (checkbox, select, date picker) that work seamlessly in both traditional controllers and LiveView, with or without Ecto Changeset
Happy coding
netoum
Release 0.1.0-alpha.23
11 new components:
- Angle Slider
- Avatar
- Carousel
- Editable
- Floating Panel
- Listbox
- Number Input
- Password Input
- Pin Input
- Radio Group
- Timer.
So far the development has been pleasant and thanks to my previous integration for static websites, I can focus on the component architecture and life cycle instead of the Vanilla JS integration details.
I would say that core integration of ZagJS is easier on Phoenix compared to a static site because we are able to render server side. While on a static website we require the client to handle the whole structure.
On the other hand, on a static website there is no server updates, or in our case Live View life cycle, which adds another level of integration complexity.
Next is to test and document the missing form components integrations for controllers, Liveview and Ecto changesets
The demo site has been updated with the new components.
Happy coding
Popular in Announcing
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #advent-of-code
- #elixirconf-us
- #distillery
- #processes
- #forms
- #api
- #metaprogramming
- #security
- #performance










