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.
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 your app or preferred framework.
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 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.
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.
todo
done
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.
# 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