Skip to content

Lesson 1 — Lookbook: a component browser for Phlex

What is Lookbook?

Lookbook is a UI development environment for Rails. It gives you a browser-based component preview system where you can view every Phlex::UI component in isolation, with different props and states, in a resizable viewport. Think of it as a living style guide that lives inside your app and updates in real time as you work.

In Module 3 we used demo.rb — a standalone Ruby script that rendered all components to an HTML file. Lookbook replaces that with something far more powerful: a proper component browser integrated into the Rails app, accessible at /lookbook during development.

Installation

Add Lookbook to the development group in your Gemfile:

1
2
3
group :development do
  gem "lookbook", ">= 2.3.14"
end

For live UI updates when you change component or preview files, also add:

1
2
3
4
5
group :development do
  gem "lookbook",     ">= 2.3.14"
  gem "listen"
  gem "actioncable"
end

listen and actioncable are optional — without them Lookbook still works, you just need to manually refresh the browser to see changes. Many Rails apps already include these gems. Run bundle install.

Mount the engine

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# config/routes.rb
Rails.application.routes.draw do
  if Rails.env.development?
    mount Lookbook::Engine, at: "/lookbook"
  end
  
  root "home#index"
  get "about", to: "home#about"

  get "up" => "rails/health#show", as: :rails_health_check
end

Mounting inside Rails.env.development? ensures Lookbook is never accidentally exposed in production.

Tell Lookbook where your components live

By default Lookbook looks for Phlex views in app/views. Our components live in app/components — add that path to the Lookbook config:

1
2
3
4
# config/initializers/lookbook.rb
Lookbook.configure do |config|
  config.component_paths << Rails.root.join("app/components")
end

Preview files

Previews live in test/components/previews/. Each preview class inherits from Lookbook::Preview and defines one method per scenario:

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# test/components/previews/button_preview.rb

class ButtonPreview < Lookbook::Preview
  # Primary navigation link
  def default
    render Components::Button.new(label: "New Board", href: "#")
  end

  def secondary
    render Components::Button.new(label: "Cancel", href: "#", variant: :secondary)
  end

  def ghost
    render Components::Button.new(label: "Cancel", href: "#", variant: :ghost)
  end

  def danger_link
    render Components::Button.new(label: "Delete board", href: "#",
                                  variant: :danger,
                                  data: { turbo_method: :delete,
                                          turbo_confirm: "Are you sure?" })
  end

  def submit
    render Components::Button.new(label: "Create board", type: "submit")
  end

  def stimulus_trigger
    render Components::Button.new(label: "Open modal", type: "button",
                                  data: { action: "click->modal#open" })
  end

  def disabled
    render Components::Button.new(label: "Unavailable", type: "submit",
                                  disabled: true)
  end

  def small
    render Components::Button.new(label: "Small", href: "#", size: :sm)
  end

  def large
    render Components::Button.new(label: "Large", href: "#", size: :lg)
  end

  # @param label text
  # @param variant select { choices: [primary, secondary, danger, ghost] }
  # @param size select { choices: [sm, md, lg] }
  def interactive(label: "Click me", variant: :primary, size: :md)
    render Components::Button.new(
      label:   label.to_s.strip.empty? ? "Click me" : label,
      variant: variant.to_sym,
      size:    size.to_sym,
      href:    "#"
    )
  end
end

Start the server and visit http://localhost:3000/lookbook. You should see Button in the sidebar with all eight scenarios listed. Click each one to preview it in the viewport.

Preview layout

By default Lookbook renders previews without any surrounding HTML — just the component in isolation. This is fine for most components, but for components that need a page context (correct font, background colour etc.) you can specify a layout:

1
2
3
4
5
class ButtonPreview < Lookbook::Preview
  layout "lookbook/preview"

  # ...
end

Create the layout at app/views/layouts/lookbook/preview.html.erb:

<!DOCTYPE html>
<html>
  <head>
    <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
  </head>
  <body class="p-8 bg-white">
    <%= yield %>
  </body>
</html>

This gives every preview the Tailwind stylesheet and comfortable padding. Set it as the default in the Lookbook config so you don’t have to specify it on every preview class:

1
2
3
4
5
# config/initializers/lookbook.rb
Lookbook.configure do |config|
  config.component_paths  << Rails.root.join("app/components")
  config.preview_layout      = "lookbook/preview"
end

Previews for all existing components

Create a preview file for each component in Phlex::UI. These follow the same pattern as ButtonPreview — one method per meaningful scenario:

1
2
3
4
5
6
7
8
# test/components/previews/badge_preview.rb
class BadgePreview < Lookbook::Preview
  def default    = render Components::Badge.new(label: "Default")
  def primary    = render Components::Badge.new(label: "Primary",  variant: :primary)
  def success    = render Components::Badge.new(label: "Success",  variant: :success)
  def warning    = render Components::Badge.new(label: "Warning",  variant: :warning)
  def danger     = render Components::Badge.new(label: "Danger",   variant: :danger)
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# test/components/previews/avatar_preview.rb
class AvatarPreview < Lookbook::Preview
  def initials
    render Components::Avatar.new(name: "Alice Smith")
  end

  def small
    render Components::Avatar.new(name: "Alice Smith", size: :sm)
  end

  def large
    render Components::Avatar.new(name: "Alice Smith", size: :lg)
  end

  def with_image
    render Components::Avatar.new(
      name:      "Alice Smith",
      image_url: "https://i.pravatar.cc/150?u=alice"
    )
  end
end
 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
# test/components/previews/alert_preview.rb
class AlertPreview < Lookbook::Preview
  def info
    render Components::Alert.new(message: "This is an info message.", variant: :info)
  end

  def success
    render Components::Alert.new(message: "Operation successful.", variant: :success)
  end

  def warning
    render Components::Alert.new(message: "Please review your input.", variant: :warning)
  end

  def danger
    render Components::Alert.new(message: "Something went wrong.", variant: :danger)
  end

  def dismissible
    render Components::Alert.new(
      message:     "This alert can be dismissed.",
      variant:     :info,
      dismissible: true
    )
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# test/components/previews/card_preview.rb
class CardPreview < Lookbook::Preview
  def title_only
    render Components::Card.new(title: "Card title") {
      p { "Just a body." }
    }
  end

  def full_slots
    render Components::Card.new { |card|
      card.header { "Custom header" }
      card.body   { "Card body content." }
      card.footer { "Footer content" }
    }
  end
end
 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
# test/components/previews/table_preview.rb
PREVIEW_PEOPLE = [
  { name: "Alice", role: "Admin"  },
  { name: "Bob",   role: "Member" },
  { name: "Carol", role: "Member" },
].freeze

class TablePreview < Lookbook::Preview
  def default
    render Components::Table.new(rows: PREVIEW_PEOPLE).tap { |t|
      t.column("Name") { |row| row[:name] }
      t.column("Role") { |row| row[:role] }
    }
  end

  def with_caption
    render Components::Table.new(
      rows:    PREVIEW_PEOPLE,
      caption: "Team members"
    ).tap { |t|
      t.column("Name") { |row| row[:name] }
      t.column("Role") { |row| row[:role] }
    }
  end
end
1
2
3
4
5
6
7
# test/components/previews/heading_preview.rb
class HeadingPreview < Lookbook::Preview
  def h1 = render Components::Heading.new(text: "Heading level 1", level: 1)
  def h2 = render Components::Heading.new(text: "Heading level 2", level: 2)
  def h3 = render Components::Heading.new(text: "Heading level 3", level: 3)
  def h4 = render Components::Heading.new(text: "Heading level 4", level: 4)
end
1
2
3
4
5
6
# test/components/previews/link_preview.rb
class LinkPreview < Lookbook::Preview
  def default   = render Components::Link.new(label: "Default link",   href: "#")
  def secondary = render Components::Link.new(label: "Secondary link", href: "#", variant: :secondary)
  def button    = render Components::Link.new(label: "Button link",    href: "#", variant: :button)
end
1
2
3
4
5
6
7
8
# test/components/previews/panel_preview.rb
class PanelPreview < Lookbook::Preview
  def default
    render Components::Panel.new(title: "Panel title") {
      p { "Panel body content." }
    }
  end
end

Visit http://localhost:3000/lookbook — you should see all Phlex::UI components in the sidebar, each with multiple preview scenarios.

Exercise

Add a # @param annotation to ButtonPreview so the variant can be changed interactively in the Lookbook UI:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class ButtonPreview < Lookbook::Preview
  # @param variant select { choices: [primary, secondary, danger, ghost] }
  # @param label text
  # @param disabled toggle
  def interactive(variant: :primary, label: "Click me", disabled: false)
    render Components::Button.new(
      label:    label.to_s.strip.empty? ? "Click me" : label,
      variant:  variant.to_sym,
      disabled: disabled
    )
  end
end

This adds an interactive controls panel to the Lookbook UI where you can change the variant, label, and disabled state without editing code. Note that we can’t allow an empty label so we provide a default as a fallback.