Hybrid (ViewComponent + Lit) Approach
The component is invoked in Rails via Neem::ButtonComponent, which now acts as a server-side wrapper that outputs the <neem-button> custom element. This provides the best of both worlds: Ruby-native syntax and standards-based client-side encapsulated UI.
Primary — Default
Maps to Kotlin PrimaryButton. Full-width, 64px height via token.
Secondary — Default
Maps to Kotlin SecondaryButton. 2px outlined border from token.
Primary — Loading
Loading state shows DottedLoadingIndicator. Content hidden, dots visible.
Secondary — Loading
Secondary loading state. Same dot animation, outlined style preserved.
Primary — Disabled
Kotlin: enabled = false → surface.disabledContainer.
Secondary — Disabled
Kotlin: enabled = false → border.disabled + text.disabled.
Primary — With Leading Icon
Leading icon via leadingIcon property.
Secondary — With Trailing Icon
Trailing icon via trailingIcon property.
Server Output — What Rails Renders
The ViewComponent emits a single custom element tag. The Lit runtime on the client hydrates it. All styling lives inside the Shadow DOM.
<!-- Neem::ButtonComponent renders this one line --> <neem-button label="Confirm Payment" variant="primary"></neem-button> <!-- With optional props --> <neem-button label="Secure Transaction" variant="primary" loading="true" leadingIcon="<svg viewBox='0 0 24 24'>...</svg>" ></neem-button> <!-- Disabled --> <neem-button label="Not Applicable" variant="secondary" disabled></neem-button>
Source — button_component.rb (Ruby / ViewComponent)
The Ruby class emits the custom element. The Rails developer never writes HTML or touches tokens directly.
# app/components/neem/button_component.rb
module Neem
class ButtonComponent < ViewComponent::Base
def initialize(label:, variant: :primary, theme: :blue,
disabled: false, loading: false,
leading_icon: nil, trailing_icon: nil)
@label = label
@variant = variant.to_sym
@theme = theme.to_sym
@disabled = disabled
@loading = loading
@leading_icon = leading_icon
@trailing_icon = trailing_icon
end
# Renders a <neem-button> custom element rather than a plain <button>.
# The Lit Web Component on the client picks up styles from the token cascade.
def call
content_tag("neem-button", nil,
label: @label,
variant: @variant,
disabled: @disabled || nil,
loading: @loading || nil,
leadingIcon: @leading_icon,
trailingIcon: @trailing_icon,
"data-theme": @theme
)
end
end
end
# In any Rails .html.erb template:
<%= render Neem::ButtonComponent.new(
label: "Confirm Payment",
variant: :primary,
leading_icon: icon_svg.html_safe
) %>
Token System — Why Theming is Free
The component HTML carries no style. The Lit element reads only Layer 3 alias tokens. Swap data-neem-theme and every button updates instantly.
/* palette.css — Layer 1: raw values, defined once */
:root { --neem-palette-cyan20: #6CD2FF; }
/* themes.css — Layer 2: brand mapping, one block per theme */
[data-neem-theme="blue"] { --neem-primary: var(--neem-palette-cyan20); }
[data-neem-theme="green"] { --neem-primary: var(--neem-palette-green20); }
/* aliases.css — Layer 3: semantic tokens. This is all a component reads. */
:root {
--neem-surface-primary: var(--neem-primary);
--neem-text-on-primary: var(--neem-on-primary);
}
/* neem-button.ts styles (inside Shadow DOM) */
button.primary {
background: var(--neem-surface-primary); /* ← resolves through all 3 layers */
color: var(--neem-text-on-primary);
}