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. Each carries typed props (priority is _Union(:low, :medium, :high), status is _Union(:todo, :done, :wont_do), tags is _Array(String)), a stimulus do block that maps those props straight to Stimulus values, and dynamic classes that pick the border colour from @status at render time.

Click a card or its Mark done / Won’t do buttons to see the same controller code that runs in the dummy Rails app fire here too. The Source tab shows the whole component — toggle between Phlex and ViewComponent to see the same UI built with either engine. The Rendered HTML tab shows what the browser actually receives, with every attribute the DSL generated.

Write the launch announcement

high
docsmarketing

todo

Migrate the legacy importer

medium
backend

done

Add Stripe webhooks

low
paymentsdeferred

wont do

# frozen_string_literal: true

module Phlex
  class TaskCardComponent < ApplicationComponent
    prop :task_id, Integer
    prop :title, String
    prop :priority, _Union(:low, :medium, :high), default: :medium
    prop :status, _Union(:todo, :done, :wont_do), default: :todo
    prop :tags, _Array(String), default: -> { [] }

    stimulus do
      values_from_props :task_id, :title, :status

      classes status: -> {
        case @status
        when :done then "border-green-500 bg-green-50"
        when :wont_do then "border-gray-400 bg-gray-50"
        else "border-yellow-400 bg-yellow-50"
        end
      }

      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-center justify-between") do
          h3(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") { @priority.to_s }
        end

        if @tags.any?
          div(class: "mt-2 flex flex-wrap gap-1") do
            @tags.each do |tag|
              span(class: "rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200") { tag }
            end
          end
        end

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

        div(class: "mt-3 flex gap-2") do
          card.child_element(
            :button,
            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"
          ) { "Mark done" }

          card.child_element(
            :button,
            stimulus_action: [:click, :apply],
            stimulus_target: :wont_do_button,
            stimulus_params: {kind: "wont_do"},
            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"
          ) { "Won't do" }
        end
      end
    end
  end
end
# task_card_component.rb
# frozen_string_literal: true

module ViewComponent
  class TaskCardComponent < ::Vident::ViewComponent::Base
    prop :task_id, Integer
    prop :title, String, reader: :public
    prop :priority, _Union(:low, :medium, :high), default: :medium, reader: :public
    prop :status, _Union(:todo, :done, :wont_do), default: :todo, reader: :public
    prop :tags, _Array(String), default: -> { [] }, reader: :public

    stimulus do
      values_from_props :task_id, :title, :status

      classes status: -> {
        case @status
        when :done then "border-green-500 bg-green-50"
        when :wont_do then "border-gray-400 bg-gray-50"
        else "border-yellow-400 bg-yellow-50"
        end
      }

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

    def title_class
      base = "font-semibold text-gray-900"
      (status == :wont_do) ? "#{base} line-through text-gray-500" : base
    end

    def status_label
      status.to_s.tr("_", " ")
    end

    private

    def root_element_attributes
      {
        html_options: {role: "button", tabindex: 0}
      }
    end

    def root_element_classes
      "block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md #{class_list_for_stimulus_classes(:status)}"
    end
  end
end

# task_card_component.html.erb
<%= root_element do |card| %>
  <div class="flex items-center justify-between">
    <h3 class="<%= title_class %>"><%= title %></h3>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700"><%= priority %></span>
  </div>

  <% if tags.any? %>
    <div class="mt-2 flex flex-wrap gap-1">
      <% tags.each do |tag| %>
        <span class="rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200"><%= tag %></span>
      <% end %>
    </div>
  <% end %>

  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500"><%= status_label %></p>

  <div class="mt-3 flex gap-2">
    <%= card.child_element(
      :button,
      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"
    ) { "Mark done" } %>

    <%= card.child_element(
      :button,
      stimulus_action: [:click, :apply],
      stimulus_target: :wont_do_button,
      stimulus_params: {kind: "wont_do"},
      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"
    ) { "Won't do" } %>
  </div>
<% end %>
<div role="button" tabindex="0" class="phlex--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="phlex--task-card-component" data-action="click->phlex--task-card-component#select" data-phlex--task-card-component-task-id-value="1" data-phlex--task-card-component-title-value="Write the launch announcement" data-phlex--task-card-component-status-value="todo" data-phlex--task-card-component-status-class="border-yellow-400 bg-yellow-50" id="phlex--task-card-component-f9a57e17004a06d57674549a8d6df075-0">
  <div class="flex items-center justify-between">
    <h3 class="font-semibold text-gray-900 ">Write the launch announcement</h3>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">high</span>
  </div>
  <div class="mt-2 flex flex-wrap gap-1">
    <span class="rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200">docs</span>
    <span class="rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200">marketing</span>
  </div>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500">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->phlex--task-card-component#apply" data-phlex--task-card-component-target="doneButton" data-phlex--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->phlex--task-card-component#apply" data-phlex--task-card-component-target="wontDoButton" data-phlex--task-card-component-kind-param="wont_do">Won't do</button>
  </div>
</div><div role="button" tabindex="0" class="phlex--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="phlex--task-card-component" data-action="click->phlex--task-card-component#select" data-phlex--task-card-component-task-id-value="2" data-phlex--task-card-component-title-value="Migrate the legacy importer" data-phlex--task-card-component-status-value="done" data-phlex--task-card-component-status-class="border-green-500 bg-green-50" id="phlex--task-card-component-d67d1793b021e0e5fe68553b3db26d82-1">
  <div class="flex items-center justify-between">
    <h3 class="font-semibold text-gray-900 ">Migrate the legacy importer</h3>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">medium</span>
  </div>
  <div class="mt-2 flex flex-wrap gap-1">
    <span class="rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200">backend</span>
  </div>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500">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" data-action="click->phlex--task-card-component#apply" data-phlex--task-card-component-target="doneButton" data-phlex--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->phlex--task-card-component#apply" data-phlex--task-card-component-target="wontDoButton" data-phlex--task-card-component-kind-param="wont_do">Won't do</button>
  </div>
</div><div role="button" tabindex="0" class="phlex--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="phlex--task-card-component" data-action="click->phlex--task-card-component#select" data-phlex--task-card-component-task-id-value="3" data-phlex--task-card-component-title-value="Add Stripe webhooks" data-phlex--task-card-component-status-value="wont_do" data-phlex--task-card-component-status-class="border-gray-400 bg-gray-50" id="phlex--task-card-component-50b2f0b724f36c010009f2a3bc03146a-2">
  <div class="flex items-center justify-between">
    <h3 class="font-semibold text-gray-900 line-through text-gray-500">Add Stripe webhooks</h3>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">low</span>
  </div>
  <div class="mt-2 flex flex-wrap gap-1">
    <span class="rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200">payments</span>
    <span class="rounded bg-white px-2 py-0.5 text-xs text-gray-600 ring-1 ring-gray-200">deferred</span>
  </div>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500">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->phlex--task-card-component#apply" data-phlex--task-card-component-target="doneButton" data-phlex--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->phlex--task-card-component#apply" data-phlex--task-card-component-target="wontDoButton" data-phlex--task-card-component-kind-param="wont_do">Won't do</button>
  </div>
</div>

The Ruby file is the only source of truth for the controller identifier (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.

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