diff --git a/config/test.exs b/config/test.exs
index 48793de45e..ad76167336 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -4,3 +4,8 @@ config :logger, :level, :debug
config :logger, :default_handler, false
config :phoenix_live_view, enable_expensive_runtime_checks: true
+
+# Disable applying the data-phx-css attribute so that tests that check
+# against rendered output that are completely irrelevant to the data-phx-css
+# attribute are not polluted by it.
+config :phoenix_live_view, apply_css_scope_attribute: false
diff --git a/lib/mix/tasks/compile/phoenix_live_view.ex b/lib/mix/tasks/compile/phoenix_live_view.ex
index 957698d013..77de5b8137 100644
--- a/lib/mix/tasks/compile/phoenix_live_view.ex
+++ b/lib/mix/tasks/compile/phoenix_live_view.ex
@@ -2,8 +2,7 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do
@moduledoc """
A LiveView compiler for HEEx macro components.
- Right now, only `Phoenix.LiveView.ColocatedHook` and `Phoenix.LiveView.ColocatedJS`
- are handled.
+ Right now, only `Phoenix.LiveView.ColocatedHook`, `Phoenix.LiveView.ColocatedJS`, and `Phoenix.LiveView.ColocatedCSS` are handled.
You must add it to your `mix.exs` as:
@@ -30,5 +29,6 @@ defmodule Mix.Tasks.Compile.PhoenixLiveView do
defp compile do
Phoenix.LiveView.ColocatedJS.compile()
+ Phoenix.LiveView.ColocatedCSS.compile()
end
end
diff --git a/lib/phoenix_component/macro_component.ex b/lib/phoenix_component/macro_component.ex
index 5c93cb00c8..9cad4ceda7 100644
--- a/lib/phoenix_component/macro_component.ex
+++ b/lib/phoenix_component/macro_component.ex
@@ -127,7 +127,7 @@ defmodule Phoenix.Component.MacroComponent do
@type children :: [heex_ast()]
@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()}
@callback transform(heex_ast :: heex_ast(), meta :: transform_meta()) ::
{:ok, heex_ast()} | {:ok, heex_ast(), data :: term()}
diff --git a/lib/phoenix_live_view/colocated_css.ex b/lib/phoenix_live_view/colocated_css.ex
new file mode 100644
index 0000000000..99948d87c3
--- /dev/null
+++ b/lib/phoenix_live_view/colocated_css.ex
@@ -0,0 +1,309 @@
+defmodule Phoenix.LiveView.ColocatedCSS do
+ @moduledoc ~S'''
+ A special HEEx `:type` that extracts any CSS styles from a colocated
+ `
+ ```
+
+ > #### A note on dependencies and umbrella projects {: .info}
+ >
+ > For each application that uses colocated CSS, a separate directory is created
+ > inside the `phoenix-colocated-css` folder. This allows to have clear separation between
+ > styles of dependencies, but also applications inside umbrella projects.
+
+ ## Scoped CSS
+
+ By default, Colocated CSS styles are scoped at compile time to the template in which they are defined.
+ This provides style encapsulation preventing CSS rules within a component from unintentionally applying
+ to elements in other nested components. Scoping is performed via the use of the `@scope` CSS at-rule.
+ For more information, see [the docs on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope).
+
+ To prevent Colocated CSS styles from being scoped to the current template you can provide the `global`
+ attribute, for example:
+
+ ```heex
+
+ ```
+
+ **Note:** When using Scoped Colocated CSS with implicit `inner_block` slots or named slots, the content
+ provided will be scoped to the parent template which is providing the content, not the component which
+ defines the slot. For example, in the following snippet the elements within [`intersperse/1`](`Phoenix.Component.intersperse/1`)'s
+ `inner_block` and `separator` slots will both be styled by the `.sample-class` rule, not any rules defined within the
+ [`intersperse/1`](`Phoenix.Component.intersperse/1`) component itself:
+
+ ```heex
+
+
+ ```
+
+ > #### Warning! {: .warning}
+ >
+ > The `@scope` CSS at-rule is Baseline available as of the end of 2025. To ensure that Scoped CSS will
+ > work on the browsers you need, be sure to check [Can I Use?](https://caniuse.com/css-cascade-scope) for
+ > browser compatibility.
+
+ > #### Tip {: .info}
+ >
+ > When Colocated CSS is scoped via the `@scope` rule, the scoping root is set to the outermost elements
+ > of the given template. For selectors in your Colocated CSS to target the scoping root, you will need to
+ > specify the scoping root in the selector via the use of the `:scope` pseudo-selector. For more details,
+ > see [the docs on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@scope#scope_pseudo-class_within_scope_blocks).
+
+ ## Internals
+
+ While compiling the template, colocated CSS is extracted into a special folder inside the
+ `Mix.Project.build_path()`, called `phoenix-colocated-css`. This is customizable, as we'll see below,
+ but it is important that it is a directory that is not tracked by version control, because the
+ components are the source of truth for the code. Also, the directory is shared between applications
+ (this also applies to applications in umbrella projects), so it should typically also be a shared
+ directory not specific to a single application.
+
+ The colocated CSS directory follows this structure:
+
+ ```text
+ _build/$MIX_ENV/phoenix-colocated-css/
+ _build/$MIX_ENV/phoenix-colocated-css/my_app/
+ _build/$MIX_ENV/phoenix-colocated-css/my_app/colocated.css
+ _build/$MIX_ENV/phoenix-colocated-css/my_app/MyAppWeb.DemoLive/line_HASH.css
+ _build/$MIX_ENV/phoenix-colocated-css/my_dependency/MyDependency.Module/line_HASH.css
+ ...
+ ```
+
+ Each application has its own folder. Inside, each module also gets its own folder, which allows
+ us to track and clean up outdated code.
+
+ To use colocated CSS, your bundler needs to be configured to resolve the
+ `phoenix-colocated-css` folder. For new Phoenix applications, this configuration is already included
+ in the esbuild configuration inside `config.exs`:
+
+ config :esbuild,
+ ...
+ my_app: [
+ args:
+ ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
+ cd: Path.expand("../assets", __DIR__),
+ env: %{
+ "NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]
+ }
+ ]
+
+ The important part here is the `NODE_PATH` environment variable, which tells esbuild to also look
+ for packages inside the `deps` folder, as well as the `Mix.Project.build_path()`, which resolves to
+ `_build/$MIX_ENV`. If you use a different bundler, you'll need to configure it accordingly. If it is not
+ possible to configure the `NODE_PATH`, you can also change the folder to which LiveView writes colocated
+ CSS by setting the `:target_directory` option in your `config.exs`:
+
+ ```elixir
+ config :phoenix_live_view, :colocated_css,
+ target_directory: Path.expand("../assets/css/phoenix-colocated-css", __DIR__)
+ ```
+
+ To bundle and use colocated CSS with esbuild, you can import it like this in your `app.js` file:
+
+ ```javascript
+ import "phoenix-colocated-css/my_app/colocated.css"
+ ```
+
+ Importing CSS in your `app.js` file will cause esbuild to generate a separate `app.css` file.
+ To load it, simply add a second `` to your `root.html.heex` file, like so:
+
+ ```html
+
+ ```
+
+ > #### Tip {: .info}
+ >
+ > If you remove or modify the contents of the `:target_directory` folder, you can use
+ > `mix clean --all` and `mix compile` to regenerate all colocated CSS.
+
+ > #### Warning! {: .warning}
+ >
+ > LiveView assumes full ownership over the configured `:target_directory`. When
+ > compiling, it will **delete** any files and folders inside the `:target_directory`,
+ > that it does not associate with a colocated CSS file.
+
+ ## Options
+
+ Colocated CSS can be configured through the attributes of the `
+
+ <.scoped_css_component />
+
+
+
defmodule SyntaxHighlight do
@behaviour Phoenix.Component.MacroComponent
@@ -159,6 +171,17 @@ defmodule Phoenix.LiveViewTest.E2E.ColocatedLive do
push_event(socket, "js:exec", %{cmd: Phoenix.json_library().encode!(ops)})
end
+ defp scoped_css_component(assigns) do
+ ~H"""
+
+
Scoped CSS Div
+ """
+ end
+
defp lv_code_sample(assigns) do
~H'''
diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs
index a282529bda..d52e102846 100644
--- a/test/e2e/test_helper.exs
+++ b/test/e2e/test_helper.exs
@@ -275,6 +275,10 @@ defmodule Phoenix.LiveViewTest.E2E.Endpoint do
from: Path.join(Mix.Project.build_path(), "phoenix-colocated/phoenix_live_view"),
at: "/assets/colocated"
+ plug Plug.Static,
+ from: Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view"),
+ at: "/assets/colocated_css"
+
plug Plug.Static, from: System.tmp_dir!(), at: "/tmp"
plug :health_check
@@ -314,8 +318,9 @@ end
IO.puts("Starting e2e server on port #{Phoenix.LiveViewTest.E2E.Endpoint.config(:http)[:port]}")
-# we need to manually compile the colocated hooks / js
+# we need to manually compile the colocated hooks, js, and css
Phoenix.LiveView.ColocatedJS.compile()
+Phoenix.LiveView.ColocatedCSS.compile()
if not IEx.started?() do
# when running the test server manually, we halt after
diff --git a/test/e2e/tests/colocated.spec.js b/test/e2e/tests/colocated.spec.js
index b0d6e1c672..5e82c27ba6 100644
--- a/test/e2e/tests/colocated.spec.js
+++ b/test/e2e/tests/colocated.spec.js
@@ -30,6 +30,40 @@ test("colocated JS works", async ({ page }) => {
await expect(page.locator("#hello")).toBeVisible();
});
+test("global colocated CSS works", async ({ page }) => {
+ await page.goto("/colocated");
+ await syncLV(page);
+
+ // the colocated CSS should apply to both elements regardless of the fact
+ // that they are not in the sample template
+ await expect(page.locator(".test-in-page.test-colocated-css")).toHaveCSS(
+ "background-color",
+ "rgb(102, 51, 153)",
+ );
+
+ await expect(page.locator(".test-in-component.test-colocated-css")).toHaveCSS(
+ "background-color",
+ "rgb(102, 51, 153)",
+ );
+});
+
+test("scoped colocated CSS works", async ({ page }) => {
+ await page.goto("/colocated");
+ await syncLV(page);
+
+ // the colocated CSS should only to the element in the component it is
+ // scoped to
+ await expect(page.locator(".test-in-page.test-colocated-css")).not.toHaveCSS(
+ "width",
+ "175px",
+ );
+
+ await expect(page.locator(".test-in-component.test-colocated-css")).toHaveCSS(
+ "width",
+ "175px",
+ );
+});
+
test("custom macro component works (syntax highlighting)", async ({ page }) => {
await page.goto("/colocated");
await syncLV(page);
diff --git a/test/phoenix_live_view/colocated_css_test.exs b/test/phoenix_live_view/colocated_css_test.exs
new file mode 100644
index 0000000000..d3a88e6ced
--- /dev/null
+++ b/test/phoenix_live_view/colocated_css_test.exs
@@ -0,0 +1,142 @@
+defmodule Phoenix.LiveView.ColocatedCSSTest do
+ # we set async: false because we call the colocated CSS compiler
+ # and it reads / writes to a shared folder
+ use ExUnit.Case, async: false
+
+ test "simple global style is extracted and available under manifest import" do
+ defmodule TestGlobalComponent do
+ use Phoenix.Component
+ alias Phoenix.LiveView.ColocatedCSS, as: Colo
+
+ def fun(assigns) do
+ ~H"""
+
+ """
+ end
+ end
+
+ assert module_folders =
+ File.ls!(
+ Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view")
+ )
+
+ assert folder =
+ Enum.find(module_folders, fn folder ->
+ folder =~ ~r/#{inspect(__MODULE__)}\.TestGlobalComponent$/
+ end)
+
+ assert [style] =
+ Path.wildcard(
+ Path.join(
+ Mix.Project.build_path(),
+ "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css"
+ )
+ )
+
+ assert File.read!(style) == "\n .sample-class {\n background-color: #FFFFFF;\n }\n"
+
+ # now write the manifest manually as we are in a test
+ Phoenix.LiveView.ColocatedCSS.compile()
+
+ assert manifest =
+ File.read!(
+ Path.join(
+ Mix.Project.build_path(),
+ "phoenix-colocated-css/phoenix_live_view/colocated.css"
+ )
+ )
+
+ path =
+ Path.relative_to(
+ style,
+ Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/")
+ )
+
+ # style is in manifest
+ assert manifest =~ ~s[@import "./#{path}";\n]
+ after
+ :code.delete(__MODULE__.TestGlobalComponent)
+ :code.purge(__MODULE__.TestGlobalComponent)
+ end
+
+ test "simple scoped style is extracted and available under manifest import" do
+ defmodule TestScopedComponent do
+ use Phoenix.Component
+ alias Phoenix.LiveView.ColocatedCSS, as: Colo
+
+ def fun(assigns) do
+ ~H"""
+
+ """
+ end
+ end
+
+ assert module_folders =
+ File.ls!(
+ Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view")
+ )
+
+ assert folder =
+ Enum.find(module_folders, fn folder ->
+ folder =~ ~r/#{inspect(__MODULE__)}\.TestScopedComponent$/
+ end)
+
+ assert [style] =
+ Path.wildcard(
+ Path.join(
+ Mix.Project.build_path(),
+ "phoenix-colocated-css/phoenix_live_view/#{folder}/*.css"
+ )
+ )
+
+ file_contents = File.read!(style)
+
+ file_contents =
+ Regex.replace(~r/data-phx-css=".+"/, file_contents, "data-phx-css=\"SCOPE_HERE\"")
+
+ # The scope is a generated value, so for testing reliability we just replace it with a known
+ # value to assert against.
+ assert file_contents ==
+ "@scope ([data-phx-css=\"SCOPE_HERE\"]) to ([data-phx-css]) { \n .sample-class {\n background-color: #FFFFFF;\n }\n }"
+
+ # now write the manifest manually as we are in a test
+ Phoenix.LiveView.ColocatedCSS.compile()
+
+ assert manifest =
+ File.read!(
+ Path.join(
+ Mix.Project.build_path(),
+ "phoenix-colocated-css/phoenix_live_view/colocated.css"
+ )
+ )
+
+ path =
+ Path.relative_to(
+ style,
+ Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/")
+ )
+
+ # style is in manifest
+ assert manifest =~ ~s[@import "./#{path}";\n]
+ after
+ :code.delete(__MODULE__.TestScopedComponent)
+ :code.purge(__MODULE__.TestScopedComponent)
+ end
+
+ test "writes empty colocated.css when no colocated styles exist" do
+ manifest =
+ Path.join(Mix.Project.build_path(), "phoenix-colocated-css/phoenix_live_view/colocated.css")
+
+ Phoenix.LiveView.ColocatedCSS.compile()
+ assert File.exists?(manifest)
+ assert File.read!(manifest) == ""
+ end
+end
diff --git a/test/phoenix_live_view/html_engine_test.exs b/test/phoenix_live_view/html_engine_test.exs
index ce9bbbf50a..81d730090b 100644
--- a/test/phoenix_live_view/html_engine_test.exs
+++ b/test/phoenix_live_view/html_engine_test.exs
@@ -1,4 +1,6 @@
defmodule Phoenix.LiveView.HTMLEngineTest do
+ # async: false due to manipulation of the Application
+ # env for :apply_css_scope_attribute
use ExUnit.Case, async: true
import ExUnit.CaptureIO
@@ -2290,4 +2292,431 @@ defmodule Phoenix.LiveView.HTMLEngineTest do
assert meta[:column] == 10
end
end
+
+ describe "data-phx-css attribute" do
+ test "is correctly applied to a single self-closing tag" do
+ enable_apply_css_scope_attribute()
+
+ source = ""
+ scope = Phoenix.LiveView.TagEngine.generate_scope(source)
+
+ rendered =
+ source
+ |> render()
+ |> normalize_whitespace()
+
+ expected = ~s()
+
+ assert rendered == normalize_whitespace(expected)
+ after
+ disable_apply_css_scope_attribute()
+ end
+
+ test "is correctly applied to a single tag with body" do
+ enable_apply_css_scope_attribute()
+
+ source = "
+ """
+
+ assert rendered == normalize_whitespace(expected)
+ after
+ disable_apply_css_scope_attribute()
+ end
+
+ test "is correctly applied to all outermost tags, but not nested tags" do
+ enable_apply_css_scope_attribute()
+
+ source = """
+
+ """
+ |> normalize_whitespace()
+ |> Regex.compile!()
+
+ rendered =
+ source
+ |> render()
+ |> normalize_whitespace()
+
+ [
+ inner_block_and_slot_scope,
+ inner_block_and_slot_scope,
+ simple_scope,
+ simple_scope,
+ inner_block_and_slot_scope,
+ simple_scope,
+ simple_scope
+ ] = Regex.run(pattern, rendered, capture: :all_but_first)
+
+ refute scope == inner_block_and_slot_scope
+ refute scope == simple_scope
+ refute inner_block_and_slot_scope == simple_scope
+ after
+ disable_apply_css_scope_attribute()
+ end
+ end
+
+ defp normalize_whitespace(string) do
+ # Eliminate all newlines and space between tags to make
+ # assertions more resilient against irrelevant whitespace differences
+ string
+ |> String.replace("\n", "")
+ |> String.replace(~r/> +, "><")
+ end
+
+ defp enable_apply_css_scope_attribute() do
+ Application.put_env(:phoenix_live_view, :apply_css_scope_attribute, true)
+
+ # It is mportant that this module is defined and compiled
+ # after the Application env is updated so that the contents
+ # of this module are also scoped
+ defmodule CSSScope do
+ use Phoenix.Component
+
+ slot :inner_block, required: true
+ slot :test
+
+ def inner_block_and_slot(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+
+ """
+ end
+
+ def simple(assigns) do
+ ~H"""
+
Simple
+ """
+ end
+ end
+ end
+
+ defp disable_apply_css_scope_attribute() do
+ Application.put_env(:phoenix_live_view, :apply_css_scope_attribute, false)
+
+ :code.delete(__MODULE__.CSSScope)
+ :code.purge(__MODULE__.CSSScope)
+ end
end