view_component_css_dsl

CI

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 :unknown raises an ArgumentError.
  • Declarations are easy to scan, easy to extend.
  • A caller's class: "..." is smart-merged with the component's defaults: bg-black from the caller wins over the component's bg-blue-500, but rounded and px-4 stick.

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:

  1. include ViewComponentCssDsl in your base component class. To opt out for one component, inherit from ViewComponent::Base directly.
  2. 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-500 from the caller replaced bg-blue-500 from the component (same category).
  • mt-4 was added (no margin in the base).
  • rounded, px-4, py-2, text-white retained from the base.
  • data-id and aria-label flow 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

License

MIT. See LICENSE.txt.