Skip to content

Vident 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 the file.

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 wires the per-request ID seeding and (optionally) drops a Claude Code skill in your repo.

See it in action

Three release cards from a small deploy dashboard. Each card carries typed props (environment is _Union(:production, :staging, :preview), status is _Union(:pending, :deployed, :failed)), 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 Promote / Cancel buttons to see the same controller code that runs in the dummy Rails app fire here too. The Vident source tab shows the entire component — under 70 lines, no hand-typed data-* attributes. The Rendered HTML tab shows what the browser actually receives, with every attribute the DSL generated.

API Gateway

v2.4.1

production

deployed

Auth Service

v1.9.0

staging

pending

Web Frontend

v3.0.0-rc1

preview

failed

# frozen_string_literal: true

module Dashboard
  class ReleaseCardComponent < ApplicationComponent
    prop :release_id, Integer
    prop :name, String
    prop :version, String
    prop :environment, _Union(:production, :staging, :preview), default: :staging
    prop :status, _Union(:pending, :deployed, :failed), default: :pending

    stimulus do
      values_from_props :release_id, :name, :status

      # Procs run in the component instance at render time, so they see
      # `@status`. `class_list_for_stimulus_classes(:status)` inlines the
      # same value into `class=` for the first paint.
      classes status: -> {
        case @status
        when :deployed then "border-green-500 bg-green-50"
        when :failed then "border-red-500 bg-red-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
          div do
            h3(class: "font-semibold text-gray-900") { @name }
            p(class: "text-sm text-gray-500") { "v#{@version}" }
          end
          span(class: "rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700") { @environment.to_s }
        end

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

        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.
          card.child_element(
            :button,
            stimulus_action: [:click, :apply],
            stimulus_target: :promote_button,
            stimulus_params: {kind: "promote"},
            type: "button",
            class: "flex-1 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
          ) { "Promote" }

          card.child_element(
            :button,
            stimulus_action: [:click, :apply],
            stimulus_target: :cancel_button,
            stimulus_params: {kind: "cancel"},
            type: "button",
            class: "flex-1 rounded border border-red-500 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-50"
          ) { "Cancel" }
        end
      end
    end
  end
end
<div role="button" tabindex="0" class="dashboard--release-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="dashboard--release-card-component" data-action="click->dashboard--release-card-component#select" data-dashboard--release-card-component-release-id-value="1" data-dashboard--release-card-component-name-value="API Gateway" data-dashboard--release-card-component-status-value="deployed" data-dashboard--release-card-component-status-class="border-green-500 bg-green-50" id="dashboard--release-card-component-9391f55ac6849b29e10f279ef2112510-0">
  <div class="flex items-center justify-between">
    <div>
      <h3 class="font-semibold text-gray-900">API Gateway</h3>
      <p class="text-sm text-gray-500">v2.4.1</p>
    </div>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">production</span>
  </div>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500">deployed</p>
  <div class="mt-3 flex gap-2">
    <button type="button" class="flex-1 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50" data-action="click->dashboard--release-card-component#apply" data-dashboard--release-card-component-target="promoteButton" data-dashboard--release-card-component-kind-param="promote">Promote</button>
    <button type="button" class="flex-1 rounded border border-red-500 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-50" data-action="click->dashboard--release-card-component#apply" data-dashboard--release-card-component-target="cancelButton" data-dashboard--release-card-component-kind-param="cancel">Cancel</button>
  </div>
</div><div role="button" tabindex="0" class="dashboard--release-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="dashboard--release-card-component" data-action="click->dashboard--release-card-component#select" data-dashboard--release-card-component-release-id-value="2" data-dashboard--release-card-component-name-value="Auth Service" data-dashboard--release-card-component-status-value="pending" data-dashboard--release-card-component-status-class="border-yellow-400 bg-yellow-50" id="dashboard--release-card-component-0d23ca33766341f78a51cc3672df9671-1">
  <div class="flex items-center justify-between">
    <div>
      <h3 class="font-semibold text-gray-900">Auth Service</h3>
      <p class="text-sm text-gray-500">v1.9.0</p>
    </div>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">staging</span>
  </div>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500">pending</p>
  <div class="mt-3 flex gap-2">
    <button type="button" class="flex-1 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50" data-action="click->dashboard--release-card-component#apply" data-dashboard--release-card-component-target="promoteButton" data-dashboard--release-card-component-kind-param="promote">Promote</button>
    <button type="button" class="flex-1 rounded border border-red-500 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-50" data-action="click->dashboard--release-card-component#apply" data-dashboard--release-card-component-target="cancelButton" data-dashboard--release-card-component-kind-param="cancel">Cancel</button>
  </div>
</div><div role="button" tabindex="0" class="dashboard--release-card-component block cursor-pointer rounded-lg border-2 p-4 shadow-sm transition hover:shadow-md border-red-500 bg-red-50" data-controller="dashboard--release-card-component" data-action="click->dashboard--release-card-component#select" data-dashboard--release-card-component-release-id-value="3" data-dashboard--release-card-component-name-value="Web Frontend" data-dashboard--release-card-component-status-value="failed" data-dashboard--release-card-component-status-class="border-red-500 bg-red-50" id="dashboard--release-card-component-5a76c66a2e58ddc53d9695c5c7d2f94f-2">
  <div class="flex items-center justify-between">
    <div>
      <h3 class="font-semibold text-gray-900">Web Frontend</h3>
      <p class="text-sm text-gray-500">v3.0.0-rc1</p>
    </div>
    <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-gray-700">preview</span>
  </div>
  <p class="mt-3 text-xs uppercase tracking-wide text-gray-500">failed</p>
  <div class="mt-3 flex gap-2">
    <button type="button" class="flex-1 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50" data-action="click->dashboard--release-card-component#apply" data-dashboard--release-card-component-target="promoteButton" data-dashboard--release-card-component-kind-param="promote">Promote</button>
    <button type="button" class="flex-1 rounded border border-red-500 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 disabled:opacity-50" data-action="click->dashboard--release-card-component#apply" data-dashboard--release-card-component-target="cancelButton" data-dashboard--release-card-component-kind-param="cancel">Cancel</button>
  </div>
</div>

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