Skip to content

Add ColocatedCSS with Scoped CSS Support#4131

Closed
green-david wants to merge 1 commit intophoenixframework:sd-macro-component-directivesfrom
green-david:dg-colocated-css-new
Closed

Add ColocatedCSS with Scoped CSS Support#4131
green-david wants to merge 1 commit intophoenixframework:sd-macro-component-directivesfrom
green-david:dg-colocated-css-new

Conversation

@green-david
Copy link
Copy Markdown
Contributor

Supersedes #4114

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.

The provided implementation of ColocatedCSS is scoped-by-default, with the ability to opt-in to 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.

);
});

test("scoped colocated css works", async ({ page }) => {
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 test is failing in Firefox currently - even though the new Playwright version 1.58.0 packages Firefox 146 which Can I Use? claims supports @scope.

From what I can tell, this seems to likely be a bug with @scope style invalidation as elements are added/removed/modified in the DOM, most likely this bug or this bug. I tested on a Chromium based browser and it seems to work as expected. Even testing on Firefox Nightly for v149, it still is a bit wonky, with elements being styled even though they aren't in scope until something happens to trigger an invalidation (for example, opening devtools and inspecting the element can cause Firefox to miraculously figure out that he needs to not style the element because it isn't in scope!). Hopefully this is fixed soon :(


## Options

Colocated CSS can be configured through the attributes of the `<style>` 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.

I haven't implemented the phx-inherit-styles sort of option to inherit parent styles in the child as it appears to be a bit trickier than anticipated...

take for example:

def render(assigns) do
    ~H"""
    <style :type={Phoenix.LiveView.ColocatedCSS}>
      .test-class { background-color: red; }
    </style>
    <div>
      <span class="test-class">Should Be Red 1</span>
      <.child>
        <span class="test-class">Should Be Red 2</span>
      </.child>
    </div>
    """
  end

  defp child(assigns) do
    ~H"""
    <style :type={Phoenix.LiveView.ColocatedCSS} inherit-parent-styles>
    </style>
    <span class="test-class">Child Should Be Red 1</span>
    <div>
      {render_slot(@inner_block)}
      <.has_inner_block>
        <span class="test-class">Child Should Be Red 2</span>
      </.has_inner_block>
    </div>
    """
  end

  defp has_inner_block(assigns) do
    ~H"""
    <div>
      {render_slot(@inner_block)}
    </div>
    """
  end

This gives an html structure like the following:

<div phx-r="" phx-css="may7f6dqp2z7omlewmjaltnjnq">
      <span class="test-class">Should Be Red 1</span>
      <span
        phx-r=""
        phx-inherit-css=""
        phx-css="7feutggpxnk3cytc4afgssfu2y"
        class="test-class"
      >
        Child Should Be Red 1
      </span>
      <div phx-r="" phx-inherit-css="" phx-css="7feutggpxnk3cytc4afgssfu2y">
        <span phx-r="" phx-css="may7f6dqp2z7omlewmjaltnjnq" class="test-class">Should Be Red 2</span>
        <div phx-r="">
          <span
            phx-r=""
            phx-inherit-css=""
            phx-css="7feutggpxnk3cytc4afgssfu2y"
            class="test-class"
          >
            Child Should Be Red 2
          </span>
        </div>
      </div>
    </div>

The ends up applying the style to Should be Red 1 and Child Should Be Red 1 but misses styling Should be Red 2 and Child Should be Red 2 because we end up "hitting" this html tag passed in from the parent as slot content which has phx-r on it and not phx-inherit-css:

<span phx-r="" phx-css="may7f6dqp2z7omlewmjaltnjnq" class="test-class">Should Be Red 2</span>

We could implement it from the other direction, and allow for configuring a parent's styles to be "deep", similar to Vue, by simply not providing a lower scope bound, but this is a bit more of a "shoot-yourself-in-the-foot" kindof option because it would apply the parent's styles to all descendants - not only those that opt in.

It is also worth noting that even if it were to work as expected, it would kindof break the contract of having to use the :scope pseudo selector for local root elements - as local roots in the child components would not appear as local roots to the parent element, and therefore not be scope roots - which would be kindof odd / inconsistent.

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 was thinking that phx-inherit-styles should not be an option of the macro component, but rather something you explicitly set, which would also mean that you need to remember to set it on slot roots as well:

  defp child(assigns) do
    ~H"""
    <span class="test-class" phx-inherit-styles>Child Should Be Red 1</span>
    <div>
      {render_slot(@inner_block)}
      <.has_inner_block>
        <span class="test-class" phx-inherit-styles>Child Should Be Red 2</span>
      </.has_inner_block>
    </div>
    """
  end

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.

Yeah, I just cheated a bit locally to have ColocatedCSS set another root_tag_attribute for me locally to test with so to make sure it tagged them all 😆

I think you encounter the same problem, whether we apply it automatically via you using another ColocatedCSS block or applying the annotation manually, because the parent's scope will "stop" early by encountering tag(s) with phx-r on them unexpectedly in descendant content (in this case, its own tag it is providing as inner_block content (<span phx-r="" phx-css="may7f6dqp2z7omlewmjaltnjnq" class="test-class">Should Be Red 2</span>)), or, even if it weren't to provide inner block content, it would accidentally hit the wrapper div tag rendered by the call to has_inner_block (<div phx-r="">), and then not know to "resume" the scope when rendering the rest of the content in child. Normally we know how to handle slot content because it is slot content in our template so it gets tagged with our phx-css attribute and creates a new scope to "resume" scoping :(

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.

because the parent's scope will "stop" early by encountering tag(s) with phx-r on them unexpectedly in descendant content

Ah, I see. That may be fine if we explain that inheriting works on the rendered DOM structure, so all "in-between" components need to opt-in, but yeah the inconsistencies aren't great. So I'm inclined to say that a deep option that skips the lower bound is probably better, but we can postpone that for now.

@green-david
Copy link
Copy Markdown
Contributor Author

@SteffenDE here is a draft PR for adding ColocatedCSS on top of #4127 , as well as some additional thoughts/notes encountered during implementation. Would love your thoughts when you get a chance!

@SteffenDE SteffenDE deleted the branch phoenixframework:sd-macro-component-directives January 26, 2026 11:36
@SteffenDE SteffenDE closed this Jan 26, 2026
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.

I'm wondering if - with all the gotchas this currently still has, especially problems with Firefox - this should be something we ship outside of LiveView, for example as {:phoenix_scoped_css, "~> 0.1"}. Basically saying that it's still kind of experimental. If we do that as part of the phoenixframework organization, we can rely on private API and if it turns out to be an insufficient solution, we could abandon it without having it part of the stable LiveView 1.X series, which would require us to maintain it until an eventual 2.0. Not sure though!

Comment on lines +196 to +197
# TODO: bump message to 1.8 once released to avoid confusion
raise ArgumentError, ~s|ColocatedCSS requires at least {:phoenix, "~> 1.8.0-rc.4"}|
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.

We can do that now. Feel free to also change it in for colocated JS.

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.

defp validate_opt!(_opt, _other_opts), do: :ok

@doc false
def extract(opts, text_content, meta) do
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'm thinking it may be the time to build a better abstraction for this, such that we don't need to duplicate a lot of the code. It would also be nice to not need to traverse the modules multiple times, so that would be an added benefit. I can invest some time to come up with an abstraction :)


## Options

Colocated CSS can be configured through the attributes of the `<style>` tag.
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 was thinking that phx-inherit-styles should not be an option of the macro component, but rather something you explicitly set, which would also mean that you need to remember to set it on slot roots as well:

  defp child(assigns) do
    ~H"""
    <span class="test-class" phx-inherit-styles>Child Should Be Red 1</span>
    <div>
      {render_slot(@inner_block)}
      <.has_inner_block>
        <span class="test-class" phx-inherit-styles>Child Should Be Red 2</span>
      </.has_inner_block>
    </div>
    """
  end

@SteffenDE
Copy link
Copy Markdown
Collaborator

Oh, I just noted that GitHub automatically closed this PR when I merged the parent one.

@green-david
Copy link
Copy Markdown
Contributor Author

I'm wondering if - with all the gotchas this currently still has, especially problems with Firefox - this should be something we ship outside of LiveView, for example as {:phoenix_scoped_css, "~> 0.1"}. Basically saying that it's still kind of experimental. If we do that as part of the phoenixframework organization, we can rely on private API and if it turns out to be an insufficient solution, we could abandon it without having it part of the stable LiveView 1.X series, which would require us to maintain it until an eventual 2.0. Not sure though!

I am good with this approach if we think that is best, I agree that shipping this as-is with the Firefox weirdness is not ideal! We could post it on the forum and let some people try it out and poke holes in it and offer feedback in the meantime, and potentially upstream it if feedback is positive and Firefox works out the kinks.

Thanks for the review, ill rebase and push changes addressing some of the feedback this evening 👍

@green-david
Copy link
Copy Markdown
Contributor Author

@SteffenDE I rebased my fork on main and pushed changes addressing your feedback on this PR in 7660ebc

p.s. I don't see a way for me to re-open this PR or change the target branch, let me know if you'd like me to open a new draft PR, or if you want to re-open it as draft or just leave it closed, your choice!

@SteffenDE SteffenDE mentioned this pull request Jan 27, 2026
@SteffenDE
Copy link
Copy Markdown
Collaborator

I can't reopen or change it either, so #4133 it is!

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