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:

<.accordion class="accordion">
  <:item :let={item}>
    <.accordion_trigger item={item}>
      Lorem ipsum dolor sit amet
    </.accordion_trigger>
    <.accordion_content item={item}>
      Consectetur adipiscing elit...
    </.accordion_content>
  </:item>
</.accordion>

API Control

Control components from client or server:

<button phx-click={Corex.Accordion.set_value("my-accordion", ["item-1"])}>
  Open Item 1
</button>
def handle_event("open_item", _, socket) do
  {:noreply, Corex.Accordion.set_value(socket, "my-accordion", ["item-1"])}
end

Customization Over Configuration

Corex avoids structural configuration. All structure is expressed through slots and nested components, never inferred from attributes. The accordion trigger uses a nested component rather than a text attribute:

<.accordion_trigger item={item}>
   Lorem ipsum dolor sit amet
    <:trigger>
    <.icon name="hero-chevron-down" />
    </:trigger>
</.accordion_trigger>

This requires more code than title="..." but ensures components remain unstyled, composable, and adaptable without fighting constraints.

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}
})

The TypeScript integration is pre-compiled and shipped with the Hex package. You work entirely within Elixir’s toolchain: mix deps.get, configure hooks, start building.

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.

<.accordion class="accordion">
  <:item :let={item}>
    <.accordion_trigger item={item}>Click me</.accordion_trigger>
    <.accordion_content item={item}>Content here</.accordion_content>
  </:item>
</.accordion>

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.

Roadmap (Priority Order)

  1. Complete all components (Phoenix attributes, initial rendering, props)
  2. Complete API (programmatic control methods for each component)
  3. Complete documentation (usage examples, styling guides, accessibility notes)
  4. Mix tests (including Connect API and state synchronization)
  5. E2E tests (including accessibility testing with axe-core)
  6. Mix generator for phx.new (Phoenix installer fork with Corex)
  7. Mix generator template for phx.gen (generate LiveView pages using Corex)
  8. Playground/Storybook (interactive documentation)
  9. Low-level API (expose Connect module for custom components)

Feedback, and suggestions welcome as I continue developing this library.

Documentation

Corex Demo
Corex Hex Doc
Corex Hex PM
Github:

20 Likes

Looking great, congrats on the launch!

1 Like

Thanks for doing this!

I have been hoping to see something based on ZagJS for a while and I’m very thankful you’ve taken this on. I’m excited to see this grow :blush:

1 Like

Do you have plans for 3-state switch? That’s a feature which is often missed, but very helpful.

Do you support / or plan to support “native” HTML5 + CSS3 solutions (that does not require JavaScript at all). See below link for an accordion example:

A huge :+1: for making components accessible by default - it’s a huge help for developers!

There is currently no plan to have other interactive component than Zagjs
Mainly because they come packed with accessibility features that would be hard to replicate and test
Let’s take an example:
The accordion has Direction (LTR/RTL) and an orientation (Vertical/Horizontal).
Some items may be disabled
The user can navigate from one Item to the previous/next with keyboard arrows.
The logic will change depending on the combinaison of all props and will skip the disabled items

You can see an example in the e2e app on the Accordion Playground /playground/accordion

Thank you for the support

1 Like

Phoenix Form Support

Corex now supports Phoenix forms for Checkbox, Select and Switch components.

You can simply pass a Phoenix form field to the components

  • In controller views, the client is the source of truth.
  • In LiveView, the server remains the source of truth and components sync accordingly
  • Validation errors stay fully synchronized and are fully customizable.
  • Error rendering follows Phoenix’s native input interaction model, preventing error messages from appearing on fields the user has not interacted with.
  • Get error translations with custom Gettext Backend

All components pass Phoenix.LiveViewTest, making them safe drop-in replacements for components generated by Phoenix generators.

The E2E app includes both controller and LiveView form examples generated with:

  • mix phx.gen.html
  • mix phx.gen.live

Read Documentation


Phoenix Flash Support

The Toast component now supports the native Phoenix Flash system.

  • Pass flash={@flash} to toast_group and use flash messages as usual.
  • New components are introduced for the following lifecycle events:
    • phx-connected
    • phx-disconnected
    • client-error
    • server-error
  • You can define custom toasts for these four lifecycle events anywhere in your application.

Toast creation is now more flexible, with a loading option available for all toast types.

More details:


Accordion List Support

Thanks to the advise from @achempion, Accordion now supports loading items from a list via Accordion.List.Item.

This feature uses the unified Corex.Collection.Item structure, which includes an additional meta field for attaching custom, dynamic data to each item.

<.accordion
  class="accordion"
  items={[
    %Corex.Accordion.Item{
      value: "lorem",
      trigger: "Lorem ipsum dolor sit amet",
      content: "Consectetur adipiscing elit. Sed sodales ullamcorper tristique.",
      meta: %{
        indicator: "hero-chevron-right",
      }
    },
    %Corex.Accordion.Item{
      trigger: "Duis dictum gravida odio ac pharetra?",
      content: "Nullam eget vestibulum ligula, at interdum tellus.",
      meta: %{
        indicator: "hero-chevron-right",
      }
    },
    %Corex.Accordion.Item{
      value: "donec",
      trigger: "Donec condimentum ex mi",
      content: "Congue molestie ipsum gravida a. Sed ac eros luctus.",
      disabled: true,
      meta: %{
        indicator: "hero-chevron-right",
      }
    }
  ]}
>
  <:item :let={item}>
    <.accordion_trigger item={item}>
      {item.meta.trigger}
      <:indicator>
        <.icon name={item.meta.indicator} />
      </:indicator>
    </.accordion_trigger>

    <.accordion_content item={item}>
      {item.meta.content}
    </.accordion_content>
  </:item>
</.accordion>

See more Accordion examples

Select and Combobox Enhancements

Select and Combobox components now support:

  • Grouping
  • Disabling items
  • Per-item customization using dynamic data

These components rely on the unified Corex.Collection.Item structure with a meta field for extensibility.

Corex.Select — Corex v0.1.0-alpha.11
Corex.Combobox — Corex v0.1.0-alpha.11

Design Tokens

Integrate design tokens into Tailwind CSS v4 using Style Dictionary and Tokens Studio, entirely from Elixir, with no Node.js dependency.

Use the mix corex.design mix task to generate and manage design tokens. Full instructions are available in the documentation

3 Likes

New components

Five new components are available:

  • Clipboard Copy text to the clipboard with configurable trigger and copied states

  • Collapsible Show/hide content with expand/collapse

  • Date Picker Date selection with calendar view and keyboard navigation

  • Dialog Modal dialogs with focus management and accessibility support

  • Tabs Tabbed interfaces for organizing content

Unified list item

Accordion and Tabs now share a common %Corex.List.Item{} struct. Use it for programmatic item lists:

%Corex.List.Item{

  value: "item-1",

  trigger: "Section title",

  content: "Section content",

  meta: %{indicator: "hero-chevron-right"}

}

Form integration

Phoenix Form docs have been expanded for Checkbox, Date Picker, Select, and Switch, including:

  • Controller usage

  • LiveView usage with controlled mode

  • Ecto changeset examples

See each component’s documentation for examples.

Feedback is always welcome. Happy coding

2 Likes

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

3 Likes

Corex Demo is live

Thanks to Gigalixir to offer a great hosting experience for elixir and Phoenix
Try Corex in production with some example for each components, a Phoenix form in a controller and in a Live View, language switch, dark mode and site navigation

Language Switch and RTL

Using the select component, Plugs and cookies, Corex guide you to implement your own local system using Gettext. RTL support is also available to synchronize all components

Tree View

New component added to display a tree of data with the ability to use it as a navigation. Works in controller and Live View

Feedback is always welcome. Happy coding

1 Like

Tree view live produces an internal server error on the demo site.

Thanks for trying out. TreeView Live View page is fixed now.

On the same update I also improved the code splitting and lazy loading of the components.
Only loads the components in use on the current page.
Check out the new “Performance” section on the Installation guide
The chunks we see are the shared parts between the components, so no duplication.

1 Like

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

3 Likes

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.

4 Likes

Corex Generators

Based of Phoenix Installer (phx_new 1.8.4) and Phoenix mix tasks, Corex generators adds new features to get you started in minutes with Phoenix and Corex.

The original Phoenix installer is great as it includes tests but also a separated Integration test suite.
This makes sure that the generated app passes compilation, formatting and tests in a real usage.

mix corex.new

Accepts the same flag than mix.phx.new plus:

  • --mode enable light/dark mode switching. Adds a Mode Switcher using Corex.ToggleGroup

  • --theme enable theme switching. Adds a Theme Switcher using Corex.Select. (e.g. --theme neo:uno:duo:leo)

  • --lang - enable language switching. Adds a Language Switcher using Corex.Select. (e.g. --lang en:ar:fr)

  • --rtl - enable RTL support for slect languages (e.g. --lang en:ar:fr --rtl ar)

  • --design - Include Corex design (tokens and component CSS). When no-design, it uses a prebuilt default.css instead (same as the original Phoenix installer with --no-tailwind).
    You can safely delete it to have a fully unstyled setup.

  • --designex - use Designex to build your Corex tokens from Design Tokens using Style Dictionnary and Tokens Studio

  • --a11y - Include accessibility testing (Wallaby, a11y_audit) for all generated pages

  • --tidewave - Include Tidewave dev dependency and the Tidewave Plug in the endpoint

Each generated app also include a custom Controller and a Live View page example to showcase the use of Client and Server events and API

mix corex.gen.html
mix corex.gen.live

Based of Phoenix mix phx.gen.*, it accept the same flags and adds support for the use of Corex components.
Using project generator config ( similar to Phoenix Scope) it respects your Mode, Theme and Languages settings.
The generated form inputs have been also updated to use Corex Component: Select, Checkbox, PasswordInput, DatePicker and Native Input for the other types

New Components

New components have been:

  • Data Table( adds supports for sorting and row selection with Corex.Checkbox)
  • Data List
  • Native Input (10+ types)
  • Layout Heading (used in the Corex generators)
  • Action
  • Navigate
  • Marquee
  • Code

Unified Translations

Some components require accessible text such are next or prev trigger on the data picker or toggle visiblity on the password input.
Corex now uses a single attribute for all components called translations that use a default value in English.
All the translation are also translation ready with Gettext
This remove the burden to remember or to add the text manually while still offering full customization.

Phoenix Stream support

We have seen above the classic use of the Phoenix stream with a Data Table.
Phoenix Stream can also be implemented on any of the Corex components accepting a list of Items.
The components accept a Phoenix Stream as a list of items and will update the listbox accordingly, while keeping all accessibility features and states.
This means that if the user is focusing an item (with keyboard for example), the focus is preserved even after the list is updated

This has been made possible by the ability for the Vanilla JS machine to update its props during runtime.
In combinaison with Phoenix Live VIew, it opens new possibilities for client/server interactions
More Stream examples with other components coming soon.

All form component have been also updated in order to fix an issue in controlled mode related to ZagJS Vanilla machine bindings. It has now being resolved with the help from the Zag team.
Testing has also been expended to cover the use of Ecto Changeset on all form components.

Corex Design

The default themes have been updated to show the ability to change any design tokens per theme, not only colors. Meaning you can specify different spacing, radius border and more per theme, it is even possible per mode also.

Did you know that Corex Design is totally optional?
Every components is unstyled, meaning there is not a single attribute or styling in any of the Corex components.
You can even use Corex Mode and Themes Plugs without Corex design. Yes they are also fully unstyled and not linked to Corex in any ways.
This is an important approach that allows integrating the components into your existing design system

Upgrade

While still in Alpha version, upgrading to the latest version comes with (undocumented) breaking changes such as renaming component attributes for consistency (for example: collection to items).
If you encounter any issues while upgrading, drop me a message, I can help you out adapting the components to the latest version.

Happy coding

3 Likes