Skip to content

Vident Build type-safe Rails components with first-class Stimulus

One declarative DSL for Phlex or ViewComponent — no more hand-crafted data attributes, no more refactor anxiety.

Vident logo

Two engines, one API

Drop into a Phlex or ViewComponent codebase without changing how you build views. Pick the engine that fits your app or preferred framework.

Stimulus without the boilerplate

Declare actions, targets, values, and classes in Ruby. The data attributes are generated for you, and renames stay safe.

Typed props

Components use the Literal gem for typed properties, so a wrong-shape arg fails loudly the moment a component is built.

Tailwind class merging

Override base classes from the call site. The built-in merger resolves conflicts the way Tailwind users expect.

Component caching

A cache_component helper scopes Rails fragment caching to the component, so expensive renders only happen once.

First-class generators

bin/rails g vident:install sets up your app and base components. bin/rails g vident:component scaffolds a component, its Stimulus controller, and a unit test in one go.

See it in action

Three task cards from the gem’s own dummy app. Each carries typed props (list is _Union(:today, :this_week, :backlog), status is _Union(:todo, :done, :wont_do)), a stimulus do block that maps the status straight to a Stimulus value, and three named class lists that the controller swaps when the card transitions between states.

Click Mark done or Won’t do — the buttons disable, the border colour swaps, the status text updates, and the title strikes through on dismiss. Same Ruby class that ships in test/dummy/app/components/tasks/, same Stimulus controller, just pre-rendered for the static site.

Write the launch announcement

today

Due: Today

todo

Migrate the legacy importer

this week

Due: Wed

done

Add Stripe webhooks

backlog

Due: —

wont do

# frozen_string_literal: true

module Tasks
  class TaskCardComponent < ApplicationComponent
    prop :task_id, Integer
    prop :title, String
    prop :due, String, default: ""
    prop :list, _Union(:today, :this_week, :backlog), default: :today
    prop :status, _Union(:todo, :done, :wont_do), default: :todo

    stimulus do
      values_from_props :task_id, :title, :status

      # Three named class lists — Stimulus exposes each as `this.<name>Classes`
      # in the JS controller, so the controller can swap borders + background
      # when status changes without hard-coding Tailwind utilities in JS.
      classes todo: "border-yellow-400 bg-yellow-50",
        done: "border-green-500 bg-green-50",
        wont_do: "border-gray-400 bg-gray-50"

      action(:select).on(:click)
    end

    def view_template
      root_element(
        class: "block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md #{class_list_for_stimulus_classes(@status)}",
        role: "button",
        tabindex: 0
      ) do |card|
        div(class: "flex items-start justify-between gap-2") do
          card.child_element(
            :h3,
            stimulus_target: :title_text,
            class: "font-semibold text-gray-900 #{"line-through text-gray-500" if @status == :wont_do}"
          ) { @title }
          span(class: "rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700") { @list.to_s.tr("_", " ") }
        end

        if @due.present?
          p(class: "mt-1 text-xs text-gray-500") { "Due: #{@due}" }
        end

        card.child_element(
          :p,
          stimulus_target: :status_text,
          class: "mt-3 text-xs uppercase tracking-wide text-gray-500"
        ) { @status.to_s.tr("_", " ") }

        div(class: "mt-3 flex gap-2") do
          # Both buttons share an `apply` handler; the controller reads
          # `event.params.kind` to tell them apart, matching each button's
          # `stimulus_params:` declaration.
          done_disabled = (@status == :done)
          dismiss_disabled = (@status == :wont_do)
          done_attrs = {
            stimulus_action: [:click, :apply],
            stimulus_target: :done_button,
            stimulus_params: {kind: "done"},
            type: "button",
            class: "flex-1 rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50"
          }
          done_attrs[:disabled] = true if done_disabled
          card.child_element(:button, **done_attrs) { "Mark done" }

          dismiss_attrs = {
            stimulus_action: [:click, :apply],
            stimulus_target: :dismiss_button,
            stimulus_params: {kind: "dismissed"},
            type: "button",
            class: "flex-1 rounded border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
          }
          dismiss_attrs[:disabled] = true if dismiss_disabled
          card.child_element(:button, **dismiss_attrs) { "Won't do" }
        end
      end
    end
  end
end
<div role="button" tabindex="0" class="tasks--task-card-component block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md border-yellow-400 bg-yellow-50" data-controller="tasks--task-card-component" data-action="click->tasks--task-card-component#select" data-tasks--task-card-component-task-id-value="1" data-tasks--task-card-component-title-value="Write the launch announcement" data-tasks--task-card-component-status-value="todo" data-tasks--task-card-component-todo-class="border-yellow-400 bg-yellow-50" data-tasks--task-card-component-done-class="border-green-500 bg-green-50" data-tasks--task-card-component-wont-do-class="border-gray-400 bg-gray-50" id="tasks--task-card-component-f9a57e17004a06d57674549a8d6df075-0">
  <div class="flex items-start justify-between gap-2">
    <h3 class="font-semibold text-gray-900 " data-tasks--task-card-component-target="titleText">Write the launch announcement</h3>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">today</span>
  </div>
  <p class="mt-1 text-xs text-gray-500">Due: Today</p>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500" data-tasks--task-card-component-target="statusText">todo</p>
  <div class="mt-3 flex gap-2">
    <button type="button" class="flex-1 rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50" data-action="click->tasks--task-card-component#apply" data-tasks--task-card-component-target="doneButton" data-tasks--task-card-component-kind-param="done">Mark done</button>
    <button type="button" class="flex-1 rounded border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50" data-action="click->tasks--task-card-component#apply" data-tasks--task-card-component-target="dismissButton" data-tasks--task-card-component-kind-param="dismissed">Won't do</button>
  </div>
</div><div role="button" tabindex="0" class="tasks--task-card-component block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md border-green-500 bg-green-50" data-controller="tasks--task-card-component" data-action="click->tasks--task-card-component#select" data-tasks--task-card-component-task-id-value="2" data-tasks--task-card-component-title-value="Migrate the legacy importer" data-tasks--task-card-component-status-value="done" data-tasks--task-card-component-todo-class="border-yellow-400 bg-yellow-50" data-tasks--task-card-component-done-class="border-green-500 bg-green-50" data-tasks--task-card-component-wont-do-class="border-gray-400 bg-gray-50" id="tasks--task-card-component-d67d1793b021e0e5fe68553b3db26d82-1">
  <div class="flex items-start justify-between gap-2">
    <h3 class="font-semibold text-gray-900 " data-tasks--task-card-component-target="titleText">Migrate the legacy importer</h3>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">this week</span>
  </div>
  <p class="mt-1 text-xs text-gray-500">Due: Wed</p>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500" data-tasks--task-card-component-target="statusText">done</p>
  <div class="mt-3 flex gap-2">
    <button type="button" class="flex-1 rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50" disabled="disabled" data-action="click->tasks--task-card-component#apply" data-tasks--task-card-component-target="doneButton" data-tasks--task-card-component-kind-param="done">Mark done</button>
    <button type="button" class="flex-1 rounded border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50" data-action="click->tasks--task-card-component#apply" data-tasks--task-card-component-target="dismissButton" data-tasks--task-card-component-kind-param="dismissed">Won't do</button>
  </div>
</div><div role="button" tabindex="0" class="tasks--task-card-component block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md border-gray-400 bg-gray-50" data-controller="tasks--task-card-component" data-action="click->tasks--task-card-component#select" data-tasks--task-card-component-task-id-value="3" data-tasks--task-card-component-title-value="Add Stripe webhooks" data-tasks--task-card-component-status-value="wont_do" data-tasks--task-card-component-todo-class="border-yellow-400 bg-yellow-50" data-tasks--task-card-component-done-class="border-green-500 bg-green-50" data-tasks--task-card-component-wont-do-class="border-gray-400 bg-gray-50" id="tasks--task-card-component-50b2f0b724f36c010009f2a3bc03146a-2">
  <div class="flex items-start justify-between gap-2">
    <h3 class="font-semibold text-gray-900 line-through text-gray-500" data-tasks--task-card-component-target="titleText">Add Stripe webhooks</h3>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">backlog</span>
  </div>
  <p class="mt-1 text-xs text-gray-500">Due: —</p>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500" data-tasks--task-card-component-target="statusText">wont do</p>
  <div class="mt-3 flex gap-2">
    <button type="button" class="flex-1 rounded bg-green-600 px-2 py-1 text-xs font-medium text-white hover:bg-green-700 disabled:opacity-50" data-action="click->tasks--task-card-component#apply" data-tasks--task-card-component-target="doneButton" data-tasks--task-card-component-kind-param="done">Mark done</button>
    <button type="button" class="flex-1 rounded border border-gray-400 px-2 py-1 text-xs font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50" disabled="disabled" data-action="click->tasks--task-card-component#apply" data-tasks--task-card-component-target="dismissButton" data-tasks--task-card-component-kind-param="dismissed">Won't do</button>
  </div>
</div>

The Ruby file is the only source of truth for the controller identifier (tasks--task-card-component). Rename the class, and every data-action, data-target, and data-value attribute moves with it — no string-chasing across .erb/.js/.rb files. Prefer ViewComponent? The same component lives in either engine on top of one DSL.

Installation

# Gemfile
gem "vident"

# Pick at least one rendering engine
gem "vident-phlex"           # Phlex
gem "vident-view_component"  # ViewComponent
bundle install
bin/rails g vident:install

Where to next