Colocated, Scoped, Formatted, Ergonomic. Intermix JS, CSS & Phoenix LiveView components without limits.

Why?

Phoenix LiveView's (1.2) implementation of component-colocated CSS and JS is a fantastic addition, allowing us to finally separate fully by concern rather than implementation language as frontend codebases have been doing since components became the UI architecture pattern de jour. However, colocated CSS and JS in LiveView 1.2 has certain limitations, especially when using a template engine other than HEEx (eg. Temple). LiveView also leaves much of the implementation of colocated CSS/JS to consumer side plugin code. Coloco fills in these gaps, providing ergonomic tooling out-of-the-box which integrates well with any template system.

Coloco aims to provide an excellent dev experience in these areas:

  • Ergonomics: A set of macros allow flexible expression of JS and CSS code within Phoenix component files, next to or within the template the code is relevant to. This comes without any runtime cost.
  • CSS Scoping: Coloco provides a "low-fi" or "low-magic" form of CSS scoping using generated @scope rules, with fallback strategy for browsers that don't support this.
    • Note that "de-scoping" does not always happen automatically. Often you'll want a CSS scope to end when a component's slot content starts, and in these cases you'll need to use an element with a descope_css class or attr around the slot.
    • Coloco does generate CSS to automatically "de-scope" wherever a new CSS scope begins. So in cases where a CSS-scoped parent component has child sub-components with their own scope, no manual de-scope is needed; the scopes will not overlap.
  • CSS Post-Processing: In most production settings, it's highly beneficial to do some transformation of CSS between source code and what is shipped to the browser. Autoprefixer is ubiquitous for auto-adding variations of rules for browser compatibility reasons. Another good example is CSS nesting, which is not yet supported in older browsers. PostCSS provides plugin-based handling of these transforms and many others from a rich ecosystem, but it has rough edges when integrated with Phoenix. Coloco makes PostCSS setup easy, and out-of-the-box handles bugbears like PostCSS watcher process shutdown (when installed naively, orphaned processes will outlive the Phoenix server and accumulate; see this issue).
  • Colocated Code Formatting: Setting up automatic formatting of colocated code is possible in LiveView 1.2, which is amazing, but much of the actual implementation is left to user-side plugin code making initial setup cumbersome. Coloco provides pre-built plugins which use Prettier to format colocated JS and CSS, with only a few easy changes to .formatter.exs required.

Installation

Add coloco to your list of dependencies in mix.exs, then run mix deps.get:

def deps do
  [
    {:coloco, "~> 0.1.0"}
  ]
end

Setup

There are two ways Coloco can be used:

  1. Through four small macros: scope_css, descope_css, colocate_js, colocate_hook
    • Setup: Add import Coloco to your module, or add it within the html_helpers section of your Phoenix app config to make these macros available throughout all components (live and otherwise).
  2. Directly, by calling the ScopeCSS module directly.
    • Setup: None; just call Coloco.ScopedCSS.scope and other functions wherever you need them.

These two methods are functionally equivalent; macros operate during compilation so runtime behavior will be identical. Here are kitchen-sink examples of each style for comparison:

# Macro style:

defmodule MyApplication.MyComponent do
  use MyApplicationWeb, :live_view

  def render(assigns) do
    temple do
      div class: css_scope() do
        p "phx-hook": p_hook(), id: "hooks-need-ids" do
          "hello world"
        end

        div class: descope_css(), do: slot @inner_block

        colocate_js(~H"""
          alert("hello world from colocated js");
        """js)
      end
    end
  end

  def css_scope() do
    scope_css(~H"""
      p {
        color: green;
      }
    """css)
  end

  def p_hook() do
    colocate_hook(~H"""
      export default {
        mounted() {
          alert("hello world from colocated hook");
        },
      };
    """js)
  end
end
# Direct-call / Macroless style:

defmodule MyApplication.MyComponent do
  use MyApplicationWeb, :live_view

  def render(assigns) do
    temple do
      div class: css_scope() do
        p "hello world"

        div class: Coloco.ScopedCSS.descope(__ENV__), do: slot @inner_block

        ~H"""
        <script :type={Phoenix.LiveView.ColocatedJS}>
          alert("hello world from colocated js");
        </script>
        """)
      end
    end
  end

  def css_scope() do
    Coloco.ScopedCSS.scope(__ENV__, ~H"""
    <style :type={Coloco.ScopedCSS}>
      p {
        color: green;
      }
    </style>
    """)
  end

  def p_hook() do
    hook_name = ".p_hook"
    module = __MODULE__ |> to_string() |> String.replace_prefix("Elixir.", "")
    hook_name_prefixed_with_module = module <> hook_name
    ~H"""
    <script :type={Phoenix.LiveView.ColocatedHook} name="#{hook_name}">
      export default {
        mounted() {
          alert("hello world from colocated hook");
        },
      };
    </script>
    """)
    hook_name_prefixed_with_module
  end
end

PostCSS (Browser-compatible CSS nesting & auto-prefixing)

Coloco expects the calling application to manage installation of PostCSS and its plugins, which gives much greater flexibility. First, run these commands in the assets directory:

cd assets
! [[ -f package.json ]] && echo "{}" >> package.json
npm install --save-dev postcss postcss-import postcss-nesting autoprefixer prettier tailwindcss @tailwindcss/cli @tailwindcss/postcss daisyui

Now you'll need to add a PostCSS config file in assets. Here's an example using the plugins installed above; copy this config into assets/postcss.config.cjs:

const path = require("path")
module.exports = {
  plugins: [
    require("postcss-import")({ path: process.env.NODE_PATH.split(path.delimiter) }),
    // postcss-import should come first (per plugin docs)
    require("@tailwindcss/postcss"),
    require("postcss-nesting"),
    require("autoprefixer"),
  ]
}

Now, make these changes to Phoenix app config to run PostCSS during build and also watch source files changes to rebuild when the dev server is running:

    # mix.exs

    defp aliases do
      [
        setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
        "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
        "ecto.reset": ["ecto.drop", "ecto.setup"],
        test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
        "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
---     "assets.build": ["compile", "tailwind example_app", "esbuild example_app"],
+++     "assets.build": ["compile", &Coloco.PostCSS.build/1, "esbuild example_app"],
        "assets.deploy": [
---       "tailwind example_app --minify",
          "esbuild example_app --minify",
          "phx.digest"
        ],
    # config/dev.exs

    config :example_application, ExampleApplication.Endpoint
      ...,
      watchers: [
        esbuild: {Esbuild, :install_and_run, [:example_app, ~w(--sourcemap=inline --watch)]},
---     tailwind: {Tailwind, :install_and_run, [:example_app, ~w(--watch)]}
+++     postcss: {Coloco.PostCSS, :watcher, []}
      ]

Note that PostCSS runs all Tailwind-related processing via the @tailwind/postcss plugin, so standalone commands to invoke tailwind are no longer necessary and are removed from the files above.

Colocated Code Formatting

In order to automatically format JS and CSS colocated code whenever mix format is run (either via CLI, or editor integration) make these changes to your .formatter.exs config file at project root:

    [
      import_deps: [:ecto, :ecto_sql, :phoenix, :temple],
      subdirectories: ["priv/*/migrations"],
      plugins: [
+++     Coloco.Format.PreHTMLFormatterPlugin,   # add BEFORE LiveView.HTMLFormatter
        Phoenix.LiveView.HTMLFormatter,
+++     Coloco.Format.PostHTMLFormatterPlugin,  # add AFTER LiveView.HTMLFormatter
      ],
      inputs: [
        "*.{heex,ex,exs}",
        "{config,lib,test}/**/*.{heex,ex,exs}",
        "priv/*/seeds.exs",
      ],
+++   tag_formatters: %{
+++     script: Coloco.Format.PrettierTagFormatter,
+++     style: Coloco.Format.PrettierTagFormatter,
+++   },
      ...

The sole purpose of Coloco's PreHTMLFormatterPlugin and PostHTMLFormatterPlugin is to manage surrounding <script> and <style> tags properly so that Phoenix.LiveView.HTMLFormatter can operate normally even when these tags aren't included in source code. If you prefer, you can avoid using these plugins and use wrapping tags when you define colocated code instead:

    scope_css(~H"""
    <style>
      p {
        color: green;
      }
    </style>
    """css)

instead of

    scope_css(~H"""
      p {
        color: green;
      }
    """css)

With PreHTMLFormatterPlugin and PostHTMLFormatterPlugin included in plugins, both of these will behave identically and be formatted identically (any wrapping tags you add will remain in place). Without these plugins, only the first example will work with the formatter; the second will cause it to error.

License

MIT