headless - Headless (unstyled) UI components for Phoenix (with Alpine)

After spending some time recently researching various ways of building modern UIs I came to one sad conclusion: if one wants to build a modern interactive application in Phoenix (+ LiveView) there are not many choices of complex components, like for example a dropdown with search. Incorporating existing solutions requires data manipulation that are not easy to keep track of especially in LiveView context.

That’s how headless was born - a collection of unstyled, UI components for Phoenix and Phoenix LiveView.

Goals & Rules

  • Provide unstyled Phoenix components as building blocks for your own UI components

  • If something can be achieved with HTML and CSS only, it should be done with HTML and CSS only (no-JS)

  • Where JS is required, use Alpine.js

  • Use Alpine.data() instead of inline markup

  • Components must work with standard Phoenix controllers (dead views)

  • Components must work with Phoenix LiveView

  • Components must work with standard Phoenix forms

  • Components must be accessible (aria attributes, keyboard navigation, focus, etc.)

Non-goals

Providing 100+ HTML components - headless should focus only on tricky components that are non-trivial to implement correctly, something like Headless UI for React and Vue.

Future goals

In the perfect world there would be a collection of headless components and then some UI libraries built on top of that providing elegant styling.

Status

Very very early, Proof of Concept stage.

So far I did an example <.toggle> component that uses a checkbox under the hood and my nemesis <.combobox/> that tried to leverage all the things that we get with Phoenix & LiveView and provide a good client-side experience on top of that.

I would love to hear any feedback on the idea itself, the implementation, etc.

13 Likes

It’s good to see more effort in this space! Are you aware of Doggo? It’s already quite far along though still a work in progress. A lot of the more complex JS has not been implemented yet. This is not my project so I can’t speak to plans there but I am using it in a personal project and currently converting a production app over to it.

For myself, the Alpine dependency makes Headless a non-starter. I’m not recommending you don’t use it, but I feel JS commands+hooks are a better route in terms of longevity within the ecosystem. Using Alpine isn’t a bad thing per se as lots of Phoenix-folks still use it, but you’re boxing out the dependency-conscious among us… which is also not necessarily a bad thing per se as it’s providing some variety. Good work, overall!

4 Likes

To add to what @sodapopcan said, great initiative, we need more in the community! But I agree with the Alpine sentiment. I really like Alpine but it has a big drawback for me is the CSP limitation: CSP — Alpine.js

I rather deal hooks that having problems and workaround the CSP. Even though as a library AlpineJS is pretty nice regarding DX. :slight_smile:

4 Likes

Thank you for the feedback.
I’ve seen doggo, but I think the goals are different (see Non-goals above)

I personally find JS commands a bit hard to read and understand, especially with all the interpolations but I do get the point about being dependency-less. I’m coming from the need to replace “full” SPA framework like Vue/React with Phoenix and just a little of JavaScript and Alpine seems to be a sensible solution.

As for the CSP so far I’ve used the mentioned Alpine.data() and x-bind heavily. I haven’t tried it but there’s a chance it even current version of combobox would work with CSP version of Alpine.

2 Likes

After researching more into ways of bulding a headless library I decided to go with “api-only” approach like Sprout UI did.

And there’s a demo website now: https://headless.fly.dev

4 Likes

Update

After trying multiple approaches to provide the headless API-only components I decided to go with standard Phoenix components and an optional optimization compiler.

The are two problems with API-only components when used like this:

  import Headless

  def avatar(assigns) do
    ~H"""
    <.use_avatar :let={a} src={@src}>
      <div {a.root}>
        <img {a.image} alt={@alt} />
        <div {a.fallback}><%= @initials %></div>
      </div>
    </.use_avatar>
    """
  end
  1. There is no compile time validation for a.root etc. calls. Using a wrong {a.wrong} key will result in runtime error
  2. Since LiveView don’t know what a.root and others will contain it can’t properly track changes and has to mark all these places as dynamic rerender and send them on every update.

This could be solved with component macros but we don’t have them (yet :wink: ).

Instead I wrote a proof of concept “preprocessor”. The only change in client code is to replace def with defc, like this:

  import Headless

  defc avatar(assigns) do
    ~H"""
    <.use_avatar :let={a} src={@src}>
      <div {a.root}>
        <img {a.image} alt={@alt} />
        <div {a.fallback}><%= @initials %></div>
      </div>
    </.use_avatar>
    """
  end

Headless compiler will discover all known use_* calls and inline attributes, resulting in a flattened HEEX template like this:

  import Headless

  def avatar(assigns) do
    ~H"""
    <div x-data="hs_avatar">
      <img x-ref="image" data-src={@src} alt={@alt} />
      <div x-ref="fallback"><%= @initials %></div>
    </div>
    """
  end

This gives us the best of both worlds - easy to use function components with a standard syntax and compile-time validation plus performance improvement.

Demo is now a standard phoenix app so you can see how headless could be used as a dependency.

4 Likes