Scoped, colocated CSS for ViewComponent.
Avoids collisions by rewriting class selectors to stable, content-derived names.
E.g. .button becomes .button_a1b2c3d4
Table of Contents
- ViewComponent::ScopedStyles
- Table of Contents
- Installation
- Usage
- 1. Using a sidecar stylesheet
- 2. Using a styles block in the component
- Referencing classes
- Global classes
- Using the scoped CSS
- Multiple stylesheets
- Configuration
- Related projects
- Development
- Contributing
- License
- Code of Conduct
Installation
Install the gem and add to the application's Gemfile by executing:
bundle add view_component-scoped_stylesOr add it to the Gemfile manually:
gem "view_component-scoped_styles"If bundler is not being used to manage dependencies, install the gem by executing:
gem install view_component-scoped_stylesUsage
Include the module in any component class you would like to use with scoped CSS.
class ExampleComponent < ViewComponent::Base
include ViewComponent::ScopedStyles
endCSS can be written in two ways:
1. Using a sidecar stylesheet
Learn more about sidecar here.
bin/rails generate view_component:component Example title --sidecarWith automatic stylesheet generation enabled in config/application.rb, config/ENVIRONMENT.rb, or wherever you configure ViewComponent:
# Ensure sidebar is enabled
config.view_component.generate.sidecar = true
# Then generate stylesheets via
config.view_component.generate.stylesheet = trueview_component:component also creates the sidecar .css file, adds include ViewComponent::ScopedStyles to the component class, and sets class="<%= component_class %>" on the generated ERB template. You can pass --no-stylesheet to skip CSS generation for a single component.
bin/rails generate view_component:component Example title --sidecar
create app/components/example_component.rb
invoke test_unit
create test/components/example_component_test.rb
invoke erb
create app/components/example_component/example_component.html.erb
invoke stylesheet
create app/components/example_component/example_component.css
Or add a matching stylesheet manually in the sidecar directory:
/* app/components/example_component/example_component.css */
.component {
position: relative;
}2. Using a styles block in the component
# app/components/example_component.rb
class ExampleComponent < ViewComponent::Base
include ViewComponent::ScopedStyles
styles do
<<~CSS
.component {
position: relative;
}
CSS
end
endNB: Using a styles block will take precedence over a sidecar stylesheet.
Referencing classes
Use the component_class helper inside component templates to refer to the scoped CSS classes:
<div class="<%= component_class %>">
My component content
</div>The default selector is .component but you can change this by defining component_css_class in your component:
class ExampleComponent < ViewComponent::Base
include ViewComponent::ScopedStyles
component_css_class "example"
styles do
<<~CSS
.example {
position: relative;
}
CSS
end
endcomponent_class takes an optional string argument to reference other classes in the CSS:
class ExampleComponent < ViewComponent::Base
include ViewComponent::ScopedStyles
component_css_class "example"
styles do
<<~CSS
.example {
position: relative;
}
.inner {
position: absolute;
}
CSS
end
end<div class="<%= component_class %>">
My component content
<div class="<%= component_class("inner") %>">
Inner content
</div>
</div>You can also reference scoped classes from another component by passing the component class with from::
<div class="<%= component_class(from: ButtonComponent) %>">
Button root class
</div>
<div class="<%= component_class("icon", from: ButtonComponent) %>">
Button icon class
</div>Inside sidecar stylesheets or styles blocks, use :component(...) as a compile-time reference to another component's scoped classes:
.component:has(:component(ButtonComponent, icon)) {
padding-inline-end: 2rem;
}
:where(:component(CardComponent), :component(PanelComponent)) .title {
margin: 0;
}The first argument is the component constant, and the optional second argument is the class selector without a leading dot. When the class name is omitted, the referenced component's root class is used. :component(...) is replaced while scoped CSS is generated, so it can be used inside more complex selectors like :where(), :is(), :not(), and :has().
Scoped class names are prefixed by default using the class name (e.g. .component → component_a1b2c3d4). Set a global prefix in configuration, or override per component with css_class_prefix.
The prefix is a template string. Use {class_name} for the CSS class being scoped and {component_name} for the component name (namespaces joined by /, with a trailing Component suffix removed):
ViewComponent::ScopedStyles.configure do |config|
config.css_class_prefix = "{class_name}_" # default
endclass ExampleComponent < ViewComponent::Base
include ViewComponent::ScopedStyles
css_class_prefix "vc-{class_name}_"
styles do
<<~CSS
.component { ... } # becomes .vc-component_a1b2c3d4 in components.scoped.css
CSS
end
endNamespaced components can include the component name in the prefix:
class Admin::UserCardComponent < ViewComponent::Base
include ViewComponent::ScopedStyles
css_class_prefix "{component_name}_{class_name}_"
styles do
<<~CSS
.component { ... }
# Admin/UserCardComponent_component_a1b2c3d4 in templates;
# Admin\/UserCardComponent_component_a1b2c3d4 in components.scoped.css
CSS
end
endUpgrading from 0.4.x: set config.css_class_prefix = "c-" (and per-component overrides) to keep the previous c-a1b2c3d4 class names without updating templates.
Global classes
Use CSS Modules-style :global(...) to leave class selectors unchanged in a sidecar stylesheet or styles block:
.component:global(.is-open) {
display: block;
}
:global(.active) .label {
font-weight: bold;
}The compiler removes :global(...) and leaves its contents unscoped. In these examples, .component and .label get scoped names, while .is-open and .active stay unchanged. Nested selector functions work too:
.component:has(:global(.external:not(.disabled))) {
border-color: green;
}Bare :global makes the rest of the current selector global. Use :local(...) for a local selector within it, or bare :local to switch back. Scope resets at each comma, at the end of a selector function, and for each new rule.
.component :global .external .icon :local(.label) {
color: red;
}For selector lists, write :global(.first), :global(.second). Lists inside a single :global(...) are not supported.
Global scope applies to each occurrence. A class can be global in one selector and local in another. component_class("name") returns the scoped name when there is a local occurrence, or the original name when every occurrence is global. The root helper uses the configured root class when present, otherwise the first local class, or the first global class if there are no local classes.
In your view, reference a global class directly:
<div class="<%= component_class %> is-open">Or use the helper for a class that appears only in global scope:
<div class="<%= component_class %> <%= component_class("is-open") %>">Migrating from ignored_css_classes
ignored_css_classes is deprecated and emits an ActiveSupport deprecation warning. It continues to leave every occurrence of the listed classes unchanged, including their helper results.
Remove the Ruby declaration and wrap each occurrence of those classes in :global(...):
# Remove this from your component:
ignored_css_classes "is-open", "active"/* Before */
.component.is-open .active { color: red; }
/* After */
.component:global(.is-open) :global(.active) { color: red; }Regenerate the bundled stylesheet after migrating. Scoped names are content-derived, so changing the CSS changes their hashes.
Using the scoped CSS
Scoped CSS is compiled into app/assets/stylesheets/components.scoped.css by default. Components can select a different output bundle with stylesheet. The default stylesheet is app/assets/stylesheets/components.scoped.css; both the directory and filename are configurable (see Configuration).
You should import this stylesheet within your app:
/* app/assets/stylesheets/application.css */
@import url("./components.scoped.css");Multiple stylesheets
Set stylesheet before declaring a styles block to choose the component's output bundle. The gem appends .scoped.css to the name. This also works with sidecar CSS:
class Admin::ButtonComponent < ViewComponent::Base
include ViewComponent::ScopedStyles
stylesheet "admin"
styles do
".component { color: blue; }"
end
end
class Admin::CardComponent < ViewComponent::Base
include ViewComponent::ScopedStyles
stylesheet "admin"
endBoth components compile into app/assets/stylesheets/admin.scoped.css. Any number of components can use the same bundle name. Components without a selection use config.stylesheet_name. Subclasses inherit their parent's selection and can override it.
All output files use config.assets_path and, when configured, config.components_layer. Include each bundle where you need it:
<%= stylesheet_link_tag "admin.scoped", "data-turbo-track": "reload" %>Cross-component class references still work across bundles. Load both bundles when both components' styles are needed. When moving an existing component between bundles, remove its marked CSS block from the old generated file, since registration updates the selected file only.
Configuration
Run the install generator in your Rails app:
bin/rails generate view_component:scoped_styles:installThat creates config/initializers/view_component_scoped_styles.rb with the same defaults as ViewComponent::ScopedStyles::Configuration.
Or create the initializer manually:
ViewComponent::ScopedStyles.configure do |config|
# Where ViewComponent classes live (relative to Rails.root). Default: "app/components"
config.components_path = File.join("app", "components")
# Where the bundled scoped stylesheet is written (relative to Rails.root). Default: "app/assets/stylesheets"
config.assets_path = File.join("app", "assets", "stylesheets")
# Filename of the bundled scoped stylesheet. Default: "components.scoped.css"
config.stylesheet_name = "components.scoped.css"
# Optional @layer name for the bundled scoped stylesheet (e.g. "components"). Default: nil.
config.components_layer = nil
# Prefix for scoped class names. Supports {component_name} and {class_name}. Default: "{class_name}_"
config.css_class_prefix = "{class_name}_"
end| Option | Default | Description |
|---|---|---|
components_path |
"app/components" |
Where ViewComponent classes live, relative to Rails.root. |
assets_path |
"app/assets/stylesheets" |
Directory where the bundled scoped stylesheet is written, relative to Rails.root. |
stylesheet_name |
"components.scoped.css" |
Filename of the bundled scoped stylesheet within assets_path. |
components_layer |
nil |
When set, wraps generated CSS in @layer <name> { ... } for cascade control. |
css_class_prefix |
"{class_name}_" |
Prefix template for scoped class names. Supports {class_name} and {component_name} (namespaces joined by /, Component suffix stripped; e.g. .component → component_a1b2c3d4). Use "c-" to match 0.4.x behavior. |
Related projects
This gem was heavily inspired by Partials Fx, and indeed takes its foundations from it, modified to work with ViewComponent instead.
- https://github.com/Rails-Designer/partials_fx
- https://github.com/aileron-inc/view_component_scoped_css
- https://github.com/amkisko/style_capsule.rb
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/chrise86/view_component-scoped_styles. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the ViewComponent::ScopedStyles project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.