Skip to content

Add ColocatedCSS with component-scoped CSS support (poc)#4114

Closed
green-david wants to merge 7 commits intophoenixframework:mainfrom
green-david:dg-colocated-css
Closed

Add ColocatedCSS with component-scoped CSS support (poc)#4114
green-david wants to merge 7 commits intophoenixframework:mainfrom
green-david:dg-colocated-css

Conversation

@green-david
Copy link
Copy Markdown
Contributor

ColocatedCSS

This PR adds ColocatedCSS to LiveView in a similar fashion to the existing ColocatedJS/ColocatedHook functionality added in #3810.

Scoped CSS is a concept present in many other frameworks such as Svelte, Vue, and others, and provides the ability to write component-level CSS without the need to worry about interfering with CSS rules defined in other components/templates.

Rather than use an approach like some of the aforementioned frameworks which parses the given CSS and manipulates the selectors at compile-time, this PR takes the approach of utilizing the new CSS-native @scope functionality which is Baseline Available as of the end of 2025 in all major browsers.

Although the implementation of ColocatedCSS provided is scoped-by-default, it also supports a global attribute to enable globally scoped ColocatedCSS as well if needed.

Prior Art

This PR is heavily inspired by prior art such as garrison's post on ElixirForum and Steffen's prior work in #3725.

Notes

I don't expect this PR to be merged in it's current state (or potentially at all), but figured i'd throw it up as a proof-of-concept implementation to spark discussion and see if there is any interest in going down this route for ColocatedCSS. I believe ColocatedCSS could be a valuable addition to LiveView and be especially attractive to developers familiar with other frameworks with similar functionality.

One of the new JS tests for ColocatedCSS is failing in Firefox in particular as @scope did not become available in Firefox until v146, but the latest version of playwright (1.57.0) only supports Firefox v144.

@type tag_meta :: %{closing: :self | :void}
@type heex_ast :: {tag(), attributes(), children(), tag_meta()} | binary()
@type transform_meta :: %{env: Macro.Env.t()}
@type transform_meta :: %{scope: String.t(), env: Macro.Env.t()}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super convinced by passing this in the transform_meta as it is so specific to ColocatedCSS specifically but this was the most immediately obvious way to propagate this information into the MacroComponent for use to actually generate the scoped CSS content

end

defp target_dir do
default = Path.join(Mix.Project.build_path(), "phoenix-colocated-css")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR uses an entirely new subdirectory, phoenix-colocated-css, rather than going in phoenix-colocated, as ColocatedJS seems to excercise total control over the contents of phoenix-colocated, so this prevents collisions. However, it would likely make sense to put everything under a common subdirectory and simply namespace them (I think Steffen was going down a similar path in #3725)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to share the folder, but of course it means extra work. If we do it, we could also share common code from colocated js and css into a shared module that we call from the compiler and which traverses all modules only once. No need to change anything about that at this point though!

Comment on lines +245 to +247
:md5
|> :crypto.hash(source)
|> Base.encode32(case: :lower, padding: false)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generates a decently long identifier to put on the tags for scoping, which I can see being a downside of diff sizes over the wire - open to suggestions as to alternate methodologies which generate shorter scope identifiers

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine, since scopes are not dynamic. Short scopes would be nice, but I don't see a good way to do that, since compilation can be incremental in development. I guess when building a release for production, you could create short scopes with a counter, but most likely not worth the extra effort.

end

defp maybe_add_scope_text(text, scope, previous_tag) do
scope_root? = not match?({:tag, _, _, _}, previous_tag)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very likely not the most optimal method to determine whether or not to apply the scope attribute to this element, but it seems to work. The tag_engine code is quite a beast and im sure im missing a much better way to do so!

The goal here is to apply data-phx-css="SCOPE_HERE" to all elements at the outermost nesting level inside of each template / component - that way the @scope rule we apply can use said outermost elements as the scope root, and descendant elements with the data-phx-css attribute as scope limits, as presumably they are their own template / component!

Copy link
Copy Markdown
Collaborator

@SteffenDE SteffenDE left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great!

One difficulty with scoped CSS is that we want it to be able to modify how other elements are rendered in the template. You did this by changing the tag engine specifically for colocated CSS. One reason why we didn't ship colocated CSS yet is because we did not want to do that and instead find a solution that was more generic.

So one idea was to require something like this for scoped CSS:

~H"""
<wrapper :type={Phoenix.LiveView.ColocatedCSS}>
  <style>
    ...
  </style>

  <!-- scoped CSS applies to all elements here -->
</wrapper>
<!-- scoped CSS does not apply here -->
"""

So all the logic could be handled by the macro component. This obviously is not really nice, so we didn't do it. I also wanted to solve components and other HEEx constructs in macro components, so I briefly worked on this PR, but we decided to not go that route either for now.

Another idea we had was to be able to do something like MacroComponent.put_attr("data-phx-css", "value"), which would tell the engine to apply that attribute to all tags in this component from this point on. I think that may be a viable option here. It would need to be a MacroComponent.put_root_attr(key, value) instead, but the idea is the same. Something that is not CSS specific, but possibly useful to other macro components as well. If we want to be more explicit (this would probably require using the process dictionary), it could also be something that could be returned from the transform function:

def transform(ast, meta) do
  {:ok, new_ast, data, directives}
end

where directives (or however we'd call that) could be instructions used for further compilation, like [put_root_attrs: [{"data-phx-css", "foo"]}].

cc @josevalim

@green-david
Copy link
Copy Markdown
Contributor Author

@SteffenDE First and foremost - thank you for the quick and kind review. It is very much appreciated.

I also went down the wrapper element rabbit hole but realized it was essentially a non-starter without #3846 as the entire component contents, slots and dynamic content included, would have to be wrapped, just as you have mentioned.

I agree that having to alter the TagEngine specifically to accomplish a particular MacroComponent is fairly anti-thesis to the intent for MacroComponents to be extensions of functionality without editing LiveView itself.

The MacroComponent.put_attr/2 or [put_attrs: attrs] approaches are quite interesting. If I am understanding you correctly, the position of the MacroComponent call in the template would affect which elements are targeted by said operations (I think it essentially has to, as by the time we have traversed the template to get to the MacroComponent, we have already processed tags in outer nesting levels?), which would require ColocatedCSS blocks to be the first element at the root level in order to affect the entire template/component. Does that sound correct?

I also had the thought that maybe passing the scope in the transform_meta isn't as bad as it initially seems, if you think of it less as a "css scope identifier" and more as a "unique template identifier" that LiveView generates that your MacroComponent can utilize in a more generic fashion. ColocatedCSS could just happen to use the template identifier to make data-phx-css attributes for css scoping...

I will explore a few of these ideas and see if I can make any headway.

Again, thank you for the review!

@SteffenDE
Copy link
Copy Markdown
Collaborator

the position of the MacroComponent call in the template would affect which elements are targeted

Correct! I wanted to mention that in my response, but I forgot. I think that's most likely fine though and can be well-documented.

I also had the thought that maybe passing the scope in the transform_meta isn't as bad as it initially seems, if you think of it less as a "css scope identifier" and more as a "unique template identifier"

Right, but I don't think that the identifier there is the important part. The question is: when and how do we decide to annotate an HTML tag. I believe we should decide that in the colocated CSS code. But that means - as mentioned above - that we cannot change tags that have already been handled before. And if we do that, we also don't need the tag engine to generate a unique identifier for us, as we can already get one by using the existing meta.env.

@SteffenDE
Copy link
Copy Markdown
Collaborator

SteffenDE commented Jan 14, 2026

Hey @green-david,

I've been thinking a bit more about this and talked to @josevalim and I believe the best way forward here would be to do the following, which is similar, but also a bit different compared to what you did with the scope in the tag engine:

We add a new root_tag_annotation to the tag_engine, which applies to all "root tags" in a template:

~H"""
<div ROOT_TAG>
  <something-else NOT_A_ROOT_TAG />
  <.some_component>
    <p ROOT_TAG />

    <:other_slot>
      <p ROOT_TAG />
      <div ROOT_TAG>
        <span NOT_A_ROOT_TAG />
      </div>
    </:other_slot>
  </.some_component>
</div>

<p ROOT_TAG />
"""

I annotated all the "root tags". By default, the root_tag_annotation would be something like phx-r. We can use this as for the closing scope selector. If no scoped CSS is used, root tags just get a phx-r attribute, which is very short.

Then, if scoped CSS is used, the macro component can return a different root_tag_annotation, for example phx-r="SHA_OF_SCOPED_CSS". Importantly, that annotation gets set for all of the root tags owned by that template, so root tags in slots also get the same annotation.

def transform(ast, meta) do
  ...
  {:ok, new_ast, data, [root_tag_annotation: hash]}
end

For simplicity, we can say that if a macro component returns a directive, which is how I called that fourth return element, we raise if the macro component is not at the very top of the template. This ensures that there can be no confusion about the position of the macro component affecting how things work. Maybe that could change in the future, but I think it's fine to start with that restriction.

We make this annotation opt-in with a compile time configuration:

config :phoenix_live_view, root_tag_annotation: "phx-r" # or similar

This means that existing apps are not affected, but we can enable it by default for new apps in phx_new. If someone tries to use colocated CSS without enable_root_tag_annotation, we can raise an error at compile time.


José also mentioned that people might want to opt in to using parent styles for specific components. I think this should work if we change the closing scope selector to something like [phx-r:not([phx-inherit-styles])].


Now, I looked at Vue.js's documentation and they decided to explicitly opt-in the root tag of components to be affected by parent styles:

With scoped, the parent component's styles will not leak into child components. However, a child component's root node will be affected by both the parent's scoped CSS and the child's scoped CSS. This is by design so that the parent can style the child root element for layout purposes.

I'm not sure if we need to do that, but - please correct me if I'm wrong - I believe we could also do that by changing the closing selector to [phx-r:not([phx-inherit-styles])] > *, as per https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope#description:

The scope's upper bound is inclusive and its lower bound is exclusive. To change this behavior, you can combine either selector with a universal child selector. For example, @scope (scope root) to (scope limit > *) would make both bounds inclusive, @scope (scope root > *) to (scope limit) would make both bounds exclusive, while @scope (scope root > *) to (scope limit > *) would give an exclusive upper bound and an inclusive lower bound.

And that could be controlled based on an attribute like <style :type={ColocatedCSS} lower-bound="inclusive">. We could then decide if inclusive or exclusive should be the default.


I think that's all for now. Let me know what you think! It's very much possible that I'm missing something somewhere and there's a big flaw in that plan :D

@green-david
Copy link
Copy Markdown
Contributor Author

I've been thinking a bit more about this and talked to @josevalim and I believe the best way forward here would be to do the following, which is similar, but also a bit different compared to what you did with the scope in the tag engine:

We add a new root_tag_annotation to the tag_engine, which applies to all "root tags" in a template:

Love this!

For simplicity, we can say that if a macro component returns a directive, which is how I called that fourth return element, we raise if the macro component is not at the very top of the template. This ensures that there can be no confusion about the position of the macro component affecting how things work. Maybe that could change in the future, but I think it's fine to start with that restriction.

Agreed, and I think this restriction actually drives you you to use ColocatedCSS in the most "intuitive" manner and is similar to other frameworks.

José also mentioned that people might want to opt in to using parent styles for specific components. I think this should work if we change the closing scope selector to something like [phx-r:not([phx-inherit-styles])].

I could see this being particularly desirable if you want to make defp components to use as "helper" components in a file perhaps, but want to define all of your styles at the top of the single def public component which internally uses said helpers perhaps?

The thought I have here is how to determine whether to apply phx-inherit-styles to these templates - would the components which want to opt in to this behaviour need to have their own ColocatedCSS block with some option to indicate they want to opt-in to parent styles, even if they provide no CSS rules in that block? For example:

<style :type={ColocatedCSS} inherit-parent="true"></style>

I don't think that is a crazy restriction to have. I think with this methodology you could also "compose" the parent styles with additional child styles like so, which seems pretty neat:

<style :type={ColocatedCSS} inherit-parent="true">
  /* this CSS rule would only apply to the child component */
  .sample-class { background-color: rebeccapurple; }
</style>

I'm not sure if we need to do that, but - please correct me if I'm wrong - I believe we could also do that by changing the closing selector to [phx-r:not([phx-inherit-styles])] > *, as per https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope#description:

And that could be controlled based on an attribute like <style :type={ColocatedCSS} lower-bound="inclusive">. We could then decide if inclusive or exclusive should be the default.

Yes!! I think this would be a great addition.

I think that's all for now. Let me know what you think! It's very much possible that I'm missing something somewhere and there's a big flaw in that plan :D

I'll let you know if I encounter any issues, but at first glance the approach seems great and I don't see any flaws. I will update this PR based on this discussion over the next couple of days and give you a ping when its ready for another look. If you think of anything else before I get back to you, please ping me and ill be sure to take a look.

You and Jose's feedback is invaluable. Thank you for taking the time and efforts to provide your guidance!

@SteffenDE
Copy link
Copy Markdown
Collaborator

The thought I have here is how to determine whether to apply phx-inherit-styles to these templates - would the components which want to opt in to this behaviour need to have their own ColocatedCSS block with some option to indicate they want to opt-in to parent styles, even if they provide no CSS rules in that block?

I think it would be as easy as

defp my_helper_component(assigns) do
  ~H"""
  <div phx-inherit-styles>
    ...
  </div>
  """
end

So no need to define any colocated styles if the component doesn't need them. It just affects the CSS scope selector. If the component used colocated CSS, it should also work I believe. Looking at an example:

def my_component(assigns) do
  ~H"""
  <style :type={ColocatedCSS}>
    p { color: red; }
  </style>

  <p>Hey</p>
  <.helper_component />
  """
end

defp helper_component(assigns) do
  ~H"""
  <style :type={ColocatedCSS}>
    p { font-weight: 600; }
  </style>

  <p phx-inherit-styles>I am a helper</p>
  """
end

would get rendered as

<p phx-r="HASH_A">Hey</p>
<p phx-r="HASH_B" phx-inherit-styles>I am a helper</p>

And the CSS rules would say

@scope [phx-r="HASH_A"] to [phx-r:not([phx-inherit-styles])] {
  p { color: red; }
}

@scope [phx-r="HASH_B"] to [phx-r:not([phx-inherit-styles])] {
  p { color: font-weight: 600; }
}

so the helper p would get both applied.

I will update this PR based on this discussion over the next couple of days and give you a ping when its ready for another look.

I'd propose to split the work into two parts:

  1. a PR that adds the root annotation feature to the tag engine
  2. a second PR for ColocatedCSS that then uses that annotation

Thank you for taking the time and efforts to provide your guidance!

This is exciting, thank you for working on it! :)

@green-david
Copy link
Copy Markdown
Contributor Author

marked as draft based on above discussions, separate PRs to come that will hopefully make this one obsolete!

SteffenDE added a commit that referenced this pull request Jan 22, 2026
This commit introduces a compiler for the new unified
tree structure introduced in the previous commit. It
changes the TagEngine to use the new compiler and also
moves macro component handling to the parser.

For backwards compatibility, the TagEngine still implements
the EEx.Engine behaviour, but it is basically a no-op
(it will still parse Elixir expressions twice!)
and calls the new compiler at the end.

The benefit of this is that we can now have the full
tokenized template and handle macro components before
compiling inner parts of the template. This allows us
to more easily implement directives as explored by

#4114
#4116
SteffenDE added a commit that referenced this pull request Jan 22, 2026
This commit introduces a compiler for the new unified
tree structure introduced in the previous commit. It
changes the TagEngine to use the new compiler and also
moves macro component handling to the parser.

For backwards compatibility, the TagEngine still implements
the EEx.Engine behaviour, but it is basically a no-op
(it will still parse Elixir expressions twice!)
and calls the new compiler at the end.

The benefit of this is that we can now have the full
tokenized template and handle macro components before
compiling inner parts of the template. This allows us
to more easily implement directives as explored by

#4114
#4116
SteffenDE added a commit that referenced this pull request Jan 22, 2026
This commit introduces a compiler for the new unified
tree structure introduced in the previous commit. It
changes the TagEngine to use the new compiler and also
moves macro component handling to the parser.

For backwards compatibility, the TagEngine still implements
the EEx.Engine behaviour, but it is basically a no-op
(it will still parse Elixir expressions twice!)
and calls the new compiler at the end.

The benefit of this is that we can now have the full
tokenized template and handle macro components before
compiling inner parts of the template. This allows us
to more easily implement directives as explored by

#4114
#4116
SteffenDE added a commit that referenced this pull request Jan 22, 2026
This commit introduces a compiler for the new unified
tree structure introduced in the previous commit. It
changes the TagEngine to use the new compiler and also
moves macro component handling to the parser.

For backwards compatibility, the TagEngine still implements
the EEx.Engine behaviour, but it is basically a no-op
(it will still parse Elixir expressions twice!)
and calls the new compiler at the end.

The benefit of this is that we can now have the full
tokenized template and handle macro components before
compiling inner parts of the template. This allows us
to more easily implement directives as explored by

#4114
#4116
SteffenDE added a commit that referenced this pull request Jan 22, 2026
This commit introduces a compiler for the new unified
tree structure introduced in the previous commit. It
changes the TagEngine to use the new compiler and also
moves macro component handling to the parser.

For backwards compatibility, the TagEngine still implements
the EEx.Engine behaviour, but it is basically a no-op
(it will still parse Elixir expressions twice!)
and calls the new compiler at the end.

The benefit of this is that we can now have the full
tokenized template and handle macro components before
compiling inner parts of the template. This allows us
to more easily implement directives as explored by

#4114
#4116
SteffenDE added a commit that referenced this pull request Jan 22, 2026
This commit introduces a compiler for the new unified
tree structure introduced in the previous commit. It
changes the TagEngine to use the new compiler and also
moves macro component handling to the parser.

For backwards compatibility, the TagEngine still implements
the EEx.Engine behaviour, but it is basically a no-op
(it will still parse Elixir expressions twice!)
and calls the new compiler at the end.

The benefit of this is that we can now have the full
tokenized template and handle macro components before
compiling inner parts of the template. This allows us
to more easily implement directives as explored by

#4114
#4116
SteffenDE added a commit that referenced this pull request Jan 23, 2026
* Add Phoenix.LiveView.TagEngine namespace

This commit moves the Tokenizer into the
Phoenix.LiveView.TagEngine.Tokenizer module and also
adds a Parser that builds a tree similar to what the
HTMLFormatter previously did. We're going to use that
node tree for compiling templates in a future commit.

The HTMLFormatter and HTMLAlgebra were now use this
tree format.

* Add Phoenix.LiveView.TagEngine.Compiler

This commit also introduces a compiler for the new unified
tree structure introduced in the previous commit. It
changes the TagEngine to use the new compiler and also
moves macro component handling to the parser.

For backwards compatibility, the TagEngine still implements
the EEx.Engine behaviour, but it is basically a no-op
(it will still parse Elixir expressions twice!)
and calls the new compiler at the end.

The benefit of this is that we can now have the full
tokenized template and handle macro components before
compiling inner parts of the template. This allows us
to more easily implement directives as explored by

#4114
#4116
@green-david
Copy link
Copy Markdown
Contributor Author

closing in favor of #4131

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants