Two engines, one API
Drop into a Phlex or ViewComponent codebase without changing how you build views. Pick the engine that fits the file.
One declarative DSL for Phlex or ViewComponent — no more hand-crafted data attributes, no more refactor anxiety.
Drop into a Phlex or ViewComponent codebase without changing how you build views. Pick the engine that fits the file.
Declare actions, targets, values, and classes in Ruby. The data attributes are generated for you, and renames stay safe.
Components use the Literal gem for typed properties, so a wrong-shape arg fails loudly the moment a component is built.
Override base classes from the call site. The built-in merger resolves conflicts the way Tailwind users expect.
A cache_component helper scopes Rails fragment caching to the component, so expensive renders only happen once.
bin/rails g vident:install wires the per-request ID seeding and (optionally) drops a Claude Code skill in your repo.
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.
v2.4.1
deployed
v1.9.0
pending
v3.0.0-rc1
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.
# 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