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"
endReplaces 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.
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
def data_attrs
{variant: @variant, controller: "button-component"}
end
endWith 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"
data variant: :variant, controller: "button-component"
def initialize(variant: :primary, disabled: false)
@variant = variant
@disabled = disabled
end
private
attr_reader :variant
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. - Data attributes get the same declarative treatment — see Declaring data, aria, and HTML attributes below for the full pattern.
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.
Not in external stylesheets. Open the component file and you see exactly what it looks like. No grepping for selectors. No cascade surprises.
A component renders one semantic block; that block is where its appearance lives. The DSL's css declarations describe that block.
When a caller passes class: "...", the DSL smart-merges those classes onto the top-level element. Predictable surface, predictable override.
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
endThe 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.
- 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)
bundle add view_component_css_dslInclude 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
endComponent 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
endSplat **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_attrsonto 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.
Always applied. Inherited and smart-merged into child components.
css "rounded border shadow p-4 bg-white"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, :dangerApplied when the method returns truthy on the instance.
css :disabled?, style: "opacity-50 cursor-not-allowed"
css :active?, style: "ring-2 ring-blue-500"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.
The gem provides three sibling declarators that mirror css's shape: data, aria, and attribute. Use them to declare attributes alongside your styles instead of overriding methods.
class ButtonComponent < ApplicationComponent
css "rounded px-4 py-2 bg-blue-500 text-white"
data variant: :variant, size: :size
aria label: "Submit"
attribute target: "_blank"
def initialize(variant: :primary, size: :default)
@variant = variant
@size = size
end
attr_reader :variant, :size
endAll three declarators share the same patterns. The only difference is where the attribute lands in the rendered HTML — data produces data-*, aria produces aria-*, and attribute produces a top-level attribute.
Always emitted. Stringified at render time (booleans, integers, etc. all become strings; nil drops the attribute).
data controller: "modal"
aria label: "Close dialog"
attribute target: "_blank"When the value is a Symbol, the DSL calls that instance method at render time and uses the result. Standard pattern for streaming an ivar or computed value into a data attribute.
data variant: :variant # calls #variant; renders as data-variant="<value>"
attribute tabindex: :tab_index
def tab_index
focusable? ? 0 : -1
endIf the method returns nil, the attribute is dropped.
For one-off computed values that don't deserve a named method:
aria label: -> { "#{@variant} Notification".titleize }
data turbo_permanent: -> { true if turbo_permanent? }Procs are instance_exec'd at render time, so they see instance state. Procs returning nil drop the attribute.
Mirrors the css :method?, style: "..." pattern — a positional Symbol or Proc as the first argument acts as a predicate. When truthy, the declaration applies; when falsy, it's skipped entirely.
data :auto_dismiss?, timeout: "5000", animation: "fade"
aria :loud?, label: "Important"
attribute -> { @disabled }, disabled: trueThe Symbol form calls the named instance method; the Proc form is instance_exec'd.
Each declaration accepts a hash of attributes. All share the same predicate (if any).
data controller: "modal",
modal_dismiss_action: "click->modal#dismiss"For aria and attribute, repeated keys across declarations replace — the last declaration wins.
For data, the keys :controller and :action accumulate (they're space-separated lists in HTML), and everything else replaces. This matches how the gem already merges component defaults with caller-passed values.
data :modal?, controller: "modal"
data :trap_focus?, controller: "trap-focus"
# Both predicates true → data-controller="modal trap-focus"
# Only :modal? true → data-controller="modal"
# Neither true → data-controller attribute is omittedWhatever a caller passes for class:, data:, aria:, or any HTML attribute layers on top of your declarations using the same rules:
class:smart-merged (see Smart merge behavior below)data:controller/action keys concatenate, others replacearia:and other attrs: caller wins
Subclass declarations stack on top of parent declarations using the same rules. data controller: declarations in a child class concatenate with the parent's; data role: in a child class replaces the parent's. aria and attribute keys in a child class replace the parent's.
class CardComponent < ApplicationComponent
data controller: "card"
data role: "region"
end
class HighlightedCardComponent < CardComponent
data controller: "highlighted" # appends → data-controller="card highlighted"
data role: "alert" # replaces → data-role="alert"
endCallers 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.
class ButtonComponent < ApplicationComponent
css "rounded px-4 py-2 bg-blue-500 text-white"
end
render ButtonComponent.newRenders:
<button class="rounded px-4 py-2 bg-blue-500 text-white"></button>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 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.
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"
endAxis, method, and proc rules are appended, not overridden.
bundle install
bundle exec rspec
bundle exec standardrbReleases 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.
MIT. See LICENSE.txt.