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. Under the hood it delegates to the tailwind_merge gem, which mirrors tailwind-merge (JS) semantics. 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.
JS-toggle visibility — use the hidden attribute, not the class
hidden is treated as part of the display group, so "flex hidden" collapses to "hidden" (the same as upstream tailwind-merge). If you need to toggle visibility from JavaScript while preserving a base display class, use the HTML5 hidden attribute via the attribute DSL:
class PaneComponent < ApplicationComponent
css "block"
attribute hidden: -> { collapsed? }
endThen toggle from JS with element.toggleAttribute('hidden') or element.hidden = true/false. The class merger stays out of the way and the element retains its block/flex/etc. layout when shown.
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.