netoum

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:

https://github.com/corex-ui/corex

Most Liked

netoum

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.

https://corex.gigalixirapp.com/en/live/combobox-form

netoum

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

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

Where Next?

Popular in Announcing Top

tfwright
After working on it for a couple of months and using it in production for most of that time, today I’ve released LiveAdmin, a LiveView ba...
New
tmbb
I’ve published the first version of my Makeup library. It’s a syntax highlighter for Elixir in the spirit of Pygments, Currently it highl...
New
mathieuprog
Hello :waving_hand: Allow me to introduce you to Tz, an alternative time zone database support to Tzdata. Why another library? First a...
New
martinthenth
Hello everybody :wave: Recently, some of my colleagues talked about database ids and uuids and their problems, and I remembered the pain...
New
bryanjos
Hi, I just published version 0.23.0 of Elixirscript. https://github.com/bryanjos/elixirscript/blob/master/CHANGELOG.md Most of the chan...
New
maltoe
Hello! Came here to announce ChromicPDF, a pet project PDF generator I’ve been working on for the past few months. Why another PDF gener...
New
oltarasenko
Dear Elixir community, After a year of development, bug fixes, and improvements, we are proudly ready to share the release of Crawly 0.1...
New
Jskalc
Hi! Today, after a couple weeks of development I’ve released v0.1 of LiveVue. It’s a seamless integration of Vue and Phoenix LiveView, i...
New
archan937
It is a well-know topic within the Elixir community: “To mock or not to mock? :)” Every alchemist probably has his / her own opinion con...
New
woylie
I released Doggo, a collection of unstyled Phoenix components. https://github.com/woylie/doggo Features Unstyled Phoenix components....
New

Other popular topics Top

senggen
Erlang/OTP 25 [erts-13.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] 15:22:35.803 [error] gen_event {lager_file_backend...
New
mcarvalho
What is the difference between System.get_env and Application.get_env? For example, what are best practices to use one versus another.
New
lessless
I believe there are people here who are dealing with CSV files import on the daily basis, and since Excel is a really popular tool there ...
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New
svb
Hi! Currently I want to submit a form by pressing the Enter key. However, since my input field is of type “textarea” this is just adds a...
New

Latest on Elixir Forum

Elixir Forum

We're in Beta

About us Mission Statement