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 = falsesurface.disabledContainer.

Secondary — Disabled

Kotlin: enabled = falseborder.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);
}