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 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.
Due: Today
todo
Due: Wed
done
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.
# 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