view_component_css_dsl
A declarative DSL for styling ViewComponent components with Tailwind CSS.
class ButtonComponent < ApplicationComponent
css "inline-flex rounded px-4 py-2"
css variant: :primary, style: "bg-blue-500 text-white"
css variant: :danger, style: "bg-red-500 text-white"
css :disabled?, style: "opacity-50"
end
Replaces hand-rolled styling boilerplate with declarative one-liners for base styles, variants, and conditionals. Callers override per-instance via class: — smart-merge handles the rest.
Why
Without this DSL, a ViewComponent with a few variants and a disabled state usually looks something like:
class ButtonComponent < ViewComponent::Base
VARIANTS = %i[primary danger].freeze
def initialize(variant: :primary, disabled: false, extra_class: nil)
raise ArgumentError, "invalid variant" unless VARIANTS.include?(variant)
@variant = variant
@disabled = disabled
@extra_class = extra_class
end
private
def css_class
[
"inline-flex rounded px-4 py-2",
variant_class,
("opacity-50" if @disabled),
@extra_class
].compact.join(" ")
end
def variant_class
case @variant
when :primary then "bg-blue-500 text-white"
when :danger then "bg-red-500 text-white"
end
end
end
With the DSL:
class ButtonComponent < ApplicationComponent
css "inline-flex rounded px-4 py-2"
css variant: :primary, style: "bg-blue-500 text-white"
css variant: :danger, style: "bg-red-500 text-white"
css :disabled?, style: "opacity-50"
def initialize(variant: :primary, disabled: false)
@variant = variant
@disabled = disabled
end
private
def disabled? = @disabled
end
- Variant validation is automatic; passing
:unknownraises anArgumentError. - Declarations are easy to scan, easy to extend.
- A caller's
class: "..."is smart-merged with the component's defaults:bg-blackfrom the caller wins over the component'sbg-blue-500, butroundedandpx-4stick.
Philosophy
A handful of opinions are baked into this DSL. It still works if you ignore them, but it's a lot nicer if you don't.
Styling lives with the component
Not in external stylesheets. Open the component file and you see exactly what it looks like. No grepping for selectors. No cascade surprises.
Significant styling lives on the top-level element
A component renders one semantic block; that block is where its appearance lives. The DSL's css declarations describe that block.
Caller customization targets the top-level element
When a caller passes class: "...", the DSL smart-merges those classes onto the top-level element. Predictable surface, predictable override.
Sub-element styling = sub-component
When a piece of your component needs its own styling decisions, promote it to its own ViewComponent (typically as a slot). Pass the shared semantic prop down; each component owns its own style table:
class CardComponent < ApplicationComponent
css "rounded border p-4"
css type: :success, style: "border-green-200 bg-green-50 text-green-900"
css type: :danger, style: "border-red-200 bg-red-50 text-red-900"
renders_one :card_header, ->(**html_attrs, &block) {
Card::HeaderComponent.new(type:, **html_attrs, &block)
}
def initialize(type:)
@type = type
end
private
attr_reader :type
end
class Card::HeaderComponent < ApplicationComponent
css "font-medium"
css type: :success, style: "text-sm"
css type: :danger, style: "text-lg font-bold"
def initialize(type:)
@type = type
end
end
The card renders the header as a slot, passing type: through. Without the DSL, this is typically a case statement or class_names block in both components — duplicated logic, more places for the style decision to drift. With it, each component reacts declaratively to the same shared prop.
If you find yourself reaching inside a component to customize a sub-element, especially with dynamic styling, the sub-element wants to be its own component.
Requirements
- Ruby 3.2+ (matches the floor for
view_component >= 4.0) view_component>= 4.0- Tailwind CSS
>= 3.0(the merge logic targets Tailwind's class-name syntax; v4 works — the syntax is compatible)
Installation
bundle add view_component_css_dsl
Setup
Include the concern in your base class, and inherit your components from it.
html_attrs is automatically passed to all components; no declaration needed.
The one piece of boilerplate: you must splat **html_attrs onto the top-level element of each component template.
Main setup:
# app/components/application_component.rb
class ApplicationComponent < ViewComponent::Base
include ViewComponentCssDsl
end
Component inherits from ApplicationComponent, gaining access to CssDsl
# app/components/button_component.rb
class ButtonComponent < ApplicationComponent
css "rounded px-4 py-2 bg-blue-500 text-white"
css variant: :success, style: "text-green-600"
css variant: :danger, style: "text-lg font-bold text-red-600"
def initialize(variant: :primary)
@variant = variant
end
end
Splat **html_attrs onto the top-level element.
<%# app/components/button_component.html.erb %>
<%= tag.button **html_attrs do %>
<%= content %>
<% end %>
Two conventions to follow:
include ViewComponentCssDslin your base component class. To opt out for one component, inherit fromViewComponent::Basedirectly.- Splat `html_attrs
** onto the top-level element. This is what makes caller-passed attributes (class:,data:,id:,aria:`, etc.) reach the DOM. A future version may automate this away.
The four css patterns
Base CSS
Always applied. Inherited and smart-merged into child components.
css "rounded border shadow p-4 bg-white"
Axis-based variants
Applied when the named instance variable matches. The DSL reads @<axis> from the instance.
css variant: :primary, style: "bg-blue-500 text-white"
css variant: :danger, style: "bg-red-500 text-white"
css size: :sm, style: "px-2 py-1 text-sm"
css size: :lg, style: "px-6 py-3 text-lg"
# Multi-axis rule — applied only when ALL axes match
css variant: :primary, size: :lg, style: "font-bold ring-2"
Passing an axis value with no matching rule raises ArgumentError:
MyComponent.new(variant: :unknown).css
# => ArgumentError: Unknown variant :unknown for MyComponent.
# Valid values: :primary, :danger
Method-based conditionals
Applied when the method returns truthy on the instance.
css :disabled?, style: "opacity-50 cursor-not-allowed"
css :active?, style: "ring-2 ring-blue-500"
Proc-based dynamic CSS
Evaluated at render time in the instance's context. Use when the class can't be known statically.
css "base"
css -> { "pl-#{@indent * 4}" }
Procs returning nil are dropped. Procs participate in smart_merge.
Caller customization
Callers can pass class: (smart-merged with the component's defaults), plus any other HTML attribute (data:, id:, aria:, etc.) — they all land on the top-level element without the component having to opt each one in.
Vanilla call
class ButtonComponent < ApplicationComponent
css "rounded px-4 py-2 bg-blue-500 text-white"
end
render ButtonComponent.new
Renders:
<button class="rounded px-4 py-2 bg-blue-500 text-white"></button>
Call with overrides
render ButtonComponent.new(
class: "mt-4 bg-red-500",
data: {id: "submit-btn"},
aria: {label: "Submit form"}
)
Renders:
<button
class="rounded px-4 py-2 mt-4 bg-red-500 text-white"
data-id="submit-btn"
aria-label="Submit form">
</button>
bg-red-500from the caller replacedbg-blue-500from the component (same category).mt-4was added (no margin in the base).rounded,px-4,py-2,text-whiteretained from the base.data-idandaria-labelflow through to the DOM untouched.
Smart merge behavior
Smart-merge handles Tailwind's conventions so caller and component CSS can coexist sensibly. In every row below, the Component column is what the component declared via css, and the Caller column is what was passed in class: at the call site.
| Component | Caller | Final classes | Why |
|---|---|---|---|
bg-white |
bg-blue-500 |
bg-blue-500 |
Same category (background) — caller wins |
p-4 |
p-8 |
p-8 |
All-padding overrides all-padding |
px-4 |
py-2 |
px-4 py-2 |
Different spacing axes — both kept |
p-4 |
pb-6 |
p-4 pb-6 |
Specific side extends the all-side base |
pl-2 |
px-5 |
px-5 |
Broader axis (x) absorbs the narrower (l) |
border-t |
border-t-2 |
border-t-2 |
Same side, more specific width — caller wins |
border-2 |
border-red-600 |
border-2 border-red-600 |
Width and color are independent |
bg-white |
hover:bg-blue-500 |
bg-white hover:bg-blue-500 |
Modifier prefix is its own namespace |
hover:bg-blue-500 |
hover:bg-red-500 |
hover:bg-red-500 |
Caller wins within the modifier namespace |
bg-white |
data-[open]:bg-gray-100 |
bg-white data-[open]:bg-gray-100 |
Arbitrary modifier is its own namespace |
Modifier prefixes (hover:, md:, dark:, group/, peer-checked:, aria-*, arbitrary […] values, etc.) form their own merge namespace, so hover:bg-blue-500 never conflicts with a base bg-white.
Inheritance
A child component's css "..." declaration is smart-merged with its parent's:
class CardComponent < ApplicationComponent
css "rounded shadow p-4 bg-white"
end
class HighlightedCardComponent < CardComponent
css "bg-yellow-50 ring-2 ring-yellow-200"
# Final base CSS:
# "rounded shadow p-4 bg-yellow-50 ring-2 ring-yellow-200"
end
Axis, method, and proc rules are appended, not overridden.
Development
bundle install
bundle exec rspec
bundle exec standardrb
Releases are managed by reissue. When committing, add Keep-a-Changelog trailers (Added:, Changed:, Fixed:, etc.) and reissue will collate them into CHANGELOG.md at release time. To publish a new version, run the "Release gem to RubyGems.org" workflow from GitHub Actions.
License
MIT. See LICENSE.txt.