Skip to content

Lesson 3 — Simple components: props and variants

These are leaf components — they render entirely from their props with no yielding or slots. They are the primitives everything else is built from.

Components::Button

Pico styles <button> beautifully by default. Variants map to Pico’s built-in classes: secondary, contrast (for danger), and outline (for ghost). The default primary style needs no class at all.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# app/components/button.rb
require_relative "base"

module Components
  class Button < Base
    VARIANTS = {
      primary:   nil,
      secondary: "secondary",
      danger:    "contrast",
      ghost:     "outline",
    }.freeze

    prop :label,    String
    prop :variant,  Symbol,           default: :primary
    prop :disabled, _Boolean, default: -> { false }  
    prop :type,     String,  default: "button".freeze

    def view_template
      button(
        type:     @type,
        class:    VARIANTS[@variant],
        disabled: @disabled,
        aria:     { disabled: @disabled.to_s }
      ) { @label }
    end
  end
end

Notice how clean the component is without Tailwind utility strings. The structure — prop declarations, variant lookup, attribute rendering — is immediately clear.

Note: type: "button" default — an untyped <button> inside a form submits it accidentally. Defaulting to "button" is the safe choice; pass type: "submit" explicitly when needed.

Adding Button to the demo

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 1. Add to demo.rb requires:
require_relative "app/components/button"

# 2. Add the show_ method:
def show_buttons
  section_header("Buttons")
  div(class: "demo-row") do
    Button(label: "Primary",   variant: :primary)
    Button(label: "Secondary", variant: :secondary)
    Button(label: "Danger",    variant: :danger)
    Button(label: "Ghost",     variant: :ghost)
    Button(label: "Disabled",  disabled: true)
  end
end

# 3. Add the call in view_template body:
show_buttons

Run ruby demo.rb. You should see a row of styled buttons.

Components::Badge

Pico has no native badge component, but <mark> is styled as a highlighted inline element. We use it as the base and add colour variant classes defined in demo.css:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# app/components/badge.rb
require_relative "base"

module Components
  class Badge < Base
    prop :label,   String
    prop :variant, Symbol, default: :default

    def view_template
      mark(class: @variant == :default ? nil : @variant.to_s) { @label }
    end
  end
end

Add to the demo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
require_relative "app/components/badge"

def show_badges
  section_header("Badges")
  div(class: "demo-row") do
    [:default, :primary, :success, :warning, :danger].each do |v|
      Badge(label: v.to_s.capitalize, variant: v)
    end
  end
end

Components::Avatar

Pico has no native avatar. We use a <div> with a CSS class and rely on demo.css for the circle, sizing, and background:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# app/components/avatar.rb
require_relative "base"

module Components
  class Avatar < Base
    prop :name,      String
    prop :image_url, _Nilable(String), default: nil
    prop :size,      Symbol,           default: :md

    def view_template
      div(class: avatar_classes) do
        if @image_url
          img(src: @image_url, alt: @name)
        else
          plain { initials }
        end
      end
    end

    private

    def initials
      @name.split.first(2).map { |w| w[0].upcase }.join
    end

    def avatar_classes
      class_names("avatar", @size == :md ? nil : @size.to_s)
    end
  end
end

Add to the demo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
require_relative "app/components/avatar"

def show_avatars
  section_header("Avatars")
  div(class: "demo-row") do
    Avatar(name: "Alice Smith", size: :sm)
    Avatar(name: "Bob Jones")
    Avatar(name: "Carol White", size: :lg)
    Avatar(name: "Alice Smith",
           image_url: "https://i.pravatar.cc/150?u=alice")
  end
end

Exercise — Components::Link

Create app/components/link.rb. Build a Components::Link component that renders an <a> tag accepting label:, href:, and variant: props.

Pico styles <a> tags by default. It also supports a secondary class for a muted style, and role="button" for a button-styled link.

Expected variants:

  • :default — standard Pico link (no class needed)
  • :secondary — muted link (class "secondary")
  • :button — link that looks like a button (role="button")

Then add it to the demo:

1
2
3
4
5
6
7
8
def show_links
  section_header("Links")
  div(class: "demo-row") do
    Link(label: "Default link",   href: "#")
    Link(label: "Secondary link", href: "#", variant: :secondary)
    Link(label: "Button link",    href: "#", variant: :button)
  end
end

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# app/components/link.rb
require_relative "base"

module Components
  class Link < Base
    prop :label,   String
    prop :href,    String
    prop :variant, Symbol, default: :default

    def view_template
      case @variant
      when :button
        a(href: @href, role: "button") { @label }
      when :secondary
        a(href: @href, class: "secondary") { @label }
      else
        a(href: @href) { @label }
      end
    end
  end
end