Component Crafting Guide
This guide demonstrates how to create components using the Pom component framework, from basic single-element components to complex multi-part compositions.
Table of Contents
- Basic Components
- Complex Components with Multiple Elements
- Composite Components with Slots
- Best Practices
Basic Components
Basic components render a single HTML element with configurable options and styles.
Component Class
# app/components/my/button_component.rb
class My::ButtonComponent < Pom::Component
# Define component options with enums and defaults
option :color, enums: [:red, :green, :blue], default: :red
option :variant, enums: [:solid, :outline, :ghost], default: :solid
option :size, enums: [:sm, :md, :lg], default: :md
option :disabled, default: false
option :submit, default: false
# Define styles using Tailwind CSS classes
# Supports conditional styling based on option values
define_styles(
base: "inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
variant: {
solid: "border-transparent shadow-sm",
outline: "border-2 bg-transparent",
ghost: "border-transparent bg-transparent hover:bg-opacity-10"
},
color: {
red: "text-white bg-red-600 hover:bg-red-700 focus:ring-red-500",
blue: "text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500",
green: "text-white bg-green-600 hover:bg-green-700 focus:ring-green-500"
},
size: {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-base",
lg: "px-6 py-3 text-lg"
},
disabled: {
true: "opacity-50 cursor-not-allowed pointer-events-none",
false: "cursor-pointer"
}
)
# Define default HTML attributes and merge computed styles
def default_options
{
class: styles_for(variant: variant, color: color, size: size, disabled: disabled),
type: submit ? "submit" : "button",
disabled: disabled,
aria: { label: "Button" }
}
end
# Option 1: Inline rendering (preferred for simple components)
def call
tag.button(content, **merge_options(default_options, extra_options))
end
end
Alternative: Template File
For components where you prefer separating markup:
<!-- app/components/my/button_component.html.erb -->
<%= tag.button(**merge_options(default_options, extra_options)) do %>
<%= content %>
<% end %>
Note: When using a template file, remove the call method from the component class.
Usage in Views
<%= my_button(variant: :outline, color: :blue, size: :lg) do %>
Click Me
<% end %>
<!-- With custom HTML attributes -->
<%= my_button(variant: :solid, color: :red, submit: true, class: "custom-class", data: { action: "click->controller#action" }) do %>
Submit Form
<% end %>
<!-- Disabled state -->
<%= my_button(disabled: true) do %>
Disabled Button
<% end %>
Complex Components with Multiple Elements
Components with multiple nested elements should define separate style sets for each element and use template files for clarity.
Component Class
# app/components/my/card_component.rb
class My::CardComponent < Pom::Component
option :variant, enums: [:default, :bordered, :elevated], default: :default
option :padding, enums: [:sm, :md, :lg], default: :md
option :rounded, default: true
# Extra options for the root wrapper element
option :root_options, default: {}
# Define styles for the root element
define_styles(
:root,
base: "w-full bg-white overflow-hidden",
variant: {
default: "border border-gray-200",
bordered: "border-2 border-gray-300",
elevated: "shadow-lg"
},
rounded: {
true: "rounded-lg",
false: ""
}
)
# Define styles for the primary content element
define_styles(
base: "w-full",
padding: {
sm: "p-3",
md: "p-4",
lg: "p-6"
}
)
def default_root_options
{
class: styles_for(:root, variant: variant, rounded: rounded)
}
end
def default_options
{
class: styles_for(padding: padding)
}
end
end
Template File
<!-- app/components/my/card_component.html.erb -->
<%= tag.div(**merge_options(default_root_options, root_options)) do %>
<%= tag.div(**merge_options(default_options, extra_options)) do %>
<%= content %>
<% end %>
<% end %>
Usage in Views
<%= my_card(variant: :elevated, padding: :lg) do %>
<h2>Card Title</h2>
<p>Card content goes here</p>
<% end %>
<!-- Customize root and content separately -->
<%= my_card(
variant: :bordered,
root_options: { class: "max-w-md mx-auto", data: { controller: "card" } },
class: "text-center"
) do %>
Centered card content
<% end %>
Composite Components with Slots
For components with distinct, reusable sections, use ViewComponent’s slot system to create clean, flexible compositions.
Parent Component
# app/components/my/dialog_component.rb
class My::DialogComponent < Pom::Component
option :open, default: false
option :size, enums: [:sm, :md, :lg], default: :md
# Define slots for sub-components
renders_one :header, My::Dialog::HeaderComponent
renders_one :body, My::Dialog::BodyComponent
renders_one :footer, My::Dialog::FooterComponent
define_styles(
base: "fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50",
open: {
true: "block",
false: "hidden"
}
)
define_styles(
:dialog,
base: "bg-white rounded-lg shadow-xl",
size: {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg"
}
)
def default_options
{
class: styles_for(open: open)
}
end
def default_dialog_options
{
class: styles_for(:dialog, size: size),
role: "dialog",
aria: { modal: true }
}
end
end
Parent Template
<!-- app/components/my/dialog_component.html.erb -->
<%= tag.div(**merge_options(default_options, extra_options)) do %>
<%= tag.div(**default_dialog_options) do %>
<%= header %>
<%= body %>
<%= footer %>
<% end %>
<% end %>
Sub-Components
# app/components/my/dialog/header_component.rb
class My::Dialog::HeaderComponent < Pom::Component
define_styles(
base: "px-6 py-4 border-b border-gray-200"
)
def default_options
{
class: styles_for
}
end
end
# app/components/my/dialog/body_component.rb
class My::Dialog::BodyComponent < Pom::Component
define_styles(
base: "px-6 py-4"
)
def default_options
{
class: styles_for
}
end
end
# app/components/my/dialog/footer_component.rb
class My::Dialog::FooterComponent < Pom::Component
define_styles(
base: "px-6 py-4 border-t border-gray-200 flex justify-end gap-2"
)
def default_options
{
class: styles_for
}
end
end
Sub-Component Templates
<!-- app/components/my/dialog/header_component.html.erb -->
<%= tag.div(**merge_options(default_options, extra_options)) do %>
<%= content %>
<% end %>
<!-- app/components/my/dialog/body_component.html.erb -->
<%= tag.div(**merge_options(default_options, extra_options)) do %>
<%= content %>
<% end %>
<!-- app/components/my/dialog/footer_component.html.erb -->
<%= tag.div(**merge_options(default_options, extra_options)) do %>
<%= content %>
<% end %>
Usage in Views
<%= my_dialog(open: true, size: :lg) do |c| %>
<% c.with_header do %>
<h3 class="text-lg font-semibold">Confirm Action</h3>
<% end %>
<% c.with_body do %>
<p>Are you sure you want to proceed with this action?</p>
<% end %>
<% c.with_footer do %>
<%= my_button(variant: :ghost) { "Cancel" } %>
<%= my_button(variant: :solid, color: :red) { "Confirm" } %>
<% end %>
<% end %>
Best Practices
Component Structure
- Keep nesting shallow: Limit component nesting to 2-3 levels maximum
- Use slots for sub-components: Instead of passing options for each sub-element, use
renders_oneorrenders_manyslots - Separate concerns: Use template files for complex markup; keep component classes focused on logic and configuration
Options Pattern
# DON'T - Too many granular options for sub-elements
option :header_class, default: ""
option :header_padding, default: :md
option :body_class, default: ""
option :body_padding, default: :md
option :footer_class, default: ""
option :footer_align, default: :left
# DO - Use slots and let sub-components handle their own options
renders_one :header, My::Dialog::HeaderComponent
renders_one :body, My::Dialog::BodyComponent
renders_one :footer, My::Dialog::FooterComponent
Style Organization
- Always define a base class: Common styles shared across all variants
- Use consistent naming: Match style keys to option names for clarity
- Leverage Tailwind: Use utility classes for rapid development
- Keep specificity low: Avoid overly specific selectors; make components easily customizable
Naming Conventions
- Component files:
app/components/namespace/component_name_component.rb - Template files:
app/components/namespace/component_name_component.html.erb - Sub-components:
app/components/namespace/parent_name/sub_name_component.rb - Helper methods:
namespace_component_name(auto-generated), see configuration
Accessibility
Always include appropriate ARIA attributes and semantic HTML:
def default_options
{
class: styles_for(...),
role: "button",
aria: { label: "Descriptive label" },
tabindex: disabled ? -1 : 0
}
end
Customization Strategy
Allow users to override styles and attributes at multiple levels:
<!-- Override component-level defaults -->
<%= my_card(variant: :elevated, class: "custom-class") do %>
Content
<% end %>
<!-- Override root wrapper -->
<%= my_card(root_options: { class: "container mx-auto", data: { controller: "modal" } }) do %>
Content
<% end %>
<!-- Override slot content -->
<%= my_dialog do |c| %>
<% c.with_header(class: "bg-gray-100") do %>
Custom Header
<% end %>
<% end %>
Testing Recommendations
Test component rendering, option handling, and style application:
# test/components/my/button_component_test.rb
class My::ButtonComponentTest < ViewComponent::TestCase
def test_renders_with_defaults
render_inline(My::ButtonComponent.new) { "Click me" }
assert_selector "button[type='button']", text: "Click me"
end
def test_applies_variant_styles
render_inline(My::ButtonComponent.new(variant: :outline))
assert_selector "button.border-2"
end
def test_submit_type
render_inline(My::ButtonComponent.new(submit: true))
assert_selector "button[type='submit']"
end
end