Skip to content

Bonus Module — Phlex::UI as a Standalone Gem

6 lessons · Open Source · Phlex::UI
We extract the Phlex::UI component library from KanbanFlow into a properly structured, versioned, published Ruby gem — complete with Lookbook previews, a configuration API, and a Rails generator.


Before we start

This module is optional. KanbanFlow is complete. You don’t need to do this to have a working application or a useful component library.

But if you want Phlex::UI to travel with you across projects — if you want to bundle add phlex_ui in a new Rails app and have 20+ components available immediately, with Lookbook previews, typed props, and Tailwind token theming — this module shows you how.

The process of extraction is also instructive. Moving components from an app into a gem surfaces assumptions you didn’t know you’d made. By the end you will understand what makes a component library genuinely reusable, as opposed to merely portable.


Lesson 1 — Gem structure

Creating the gem skeleton

1
bundle gem phlex_ui --no-ext --no-test

The --no-ext flag skips native extensions (we don’t need them). --no-test skips the test framework setup — we’ll add Minitest manually.

The generator creates:

phlex_ui/
  lib/
    phlex_ui/
      version.rb
    phlex_ui.rb
  spec/ (or test/)
  phlex_ui.gemspec
  Gemfile
  Rakefile
  README.md
  CHANGELOG.md
  LICENSE.txt
  .gitignore

The gemspec

 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
# phlex_ui.gemspec
require_relative "lib/phlex_ui/version"

Gem::Specification.new do |spec|
  spec.name        = "phlex_ui"
  spec.version     = PhlexUI::VERSION
  spec.authors     = ["Your Name"]
  spec.email       = ["you@example.com"]
  spec.summary     = "A Phlex component library for Rails"
  spec.description = "Reusable, typed, Tailwind-styled Phlex components " \
                     "with Lookbook previews and dark mode support."
  spec.homepage    = "https://github.com/you/phlex_ui"
  spec.license     = "MIT"

  spec.required_ruby_version = ">= 3.2"

  spec.files = Dir[
    "lib/**/*",
    "app/**/*",
    "LICENSE.txt",
    "README.md",
    "CHANGELOG.md"
  ]

  spec.require_paths = ["lib"]

  spec.add_dependency "phlex-rails", ">= 2.0"
  spec.add_dependency "literal",     ">= 1.9"

  spec.add_development_dependency "rails",    ">= 8.0"
  spec.add_development_dependency "lookbook", ">= 2.3"
  spec.add_development_dependency "minitest"
  spec.add_development_dependency "nokogiri"
end

Version numbering with SemVer

1
2
3
4
# lib/phlex_ui/version.rb
module PhlexUI
  VERSION = "0.1.0"
end

For a component library, SemVer means:

Patch (0.1.00.1.1) — bug fixes, typo corrections in class strings, accessibility improvements that don’t change the API.

Minor (0.1.00.2.0) — new components, new props with defaults, new variants. Anything additive that doesn’t break existing usage.

Major (0.1.01.0.0) — removed components, renamed props, changed slot method names, breaking Tailwind class changes that require host app changes. Anything that breaks existing phlex_ui usage.

Start at 0.1.0 — the 0.x range signals that the API is not yet stable and minor versions may contain breaking changes.

Directory structure

The gem will follow Rails Engine conventions for its app directory:

phlex_ui/
  app/
    components/
      phlex_ui/
        base.rb
        button.rb
        badge.rb
        avatar.rb
        alert.rb
        card.rb
        table.rb
        panel.rb
        link.rb
        heading.rb
        empty_state.rb
        modal.rb
        dropdown.rb
        toast.rb
        toast_container.rb
        accordion.rb
        tabs.rb
        breadcrumb.rb
        search_bar.rb
        form_group.rb
        label.rb
        text_input.rb
        textarea.rb
        select.rb
        checkbox.rb
        radio.rb
        stimulus_component.rb
  test/
    components/
      button_test.rb
      # ... etc
  lib/
    phlex_ui/
      version.rb
      railtie.rb
      configuration.rb
    phlex_ui.rb

Note the phlex_ui/ subdirectory under app/components/. This namespacing prevents conflicts with the host app’s own Components:: namespace. The gem’s components live under PhlexUI::Components:: rather than Components:: — this is an important design decision covered in Lesson 2.


Lesson 2 — Moving components into the gem

The namespace question

In KanbanFlow, components live under Components::Button, Components::Card etc. When extracted to a gem, we have two options:

Option A — Keep Components:: namespace Host apps use Components::Button — but the gem and the host app share the same namespace. Conflicts are possible if the host app defines its own Components::Button.

Option B — Use PhlexUI::Components:: namespace The gem owns PhlexUI::Components::. The host app’s Components:: is completely separate. Kits are set up independently.

We use Option B. It’s more explicit and avoids conflicts:

1
2
3
# In the host app, after bundle add phlex_ui:
Button(...)    # host app's own button (if it has one)
PhlexUI::Button(...)  # or via include PhlexUI::Components

The host app can include PhlexUI::Components in its own Components::Base to get short-form Kit syntax if desired.

PhlexUI::Components::Base

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# app/components/phlex_ui/base.rb
module PhlexUI
  module Components
    class Base < Phlex::HTML
      extend Literal::Properties

      private

      def class_names(*classes)
        classes.flatten.compact.reject(&:empty?).join(" ")
      end
    end
  end
end

Notice what’s missing: no include Phlex::Rails::Helpers::Routes. The gem’s base class has no Rails dependencies beyond Phlex itself. Routes and other Rails helpers are opt-in — the host app includes them as needed:

1
2
3
4
5
6
# Host app's own base, after including the gem:
class Components::Base < Phlex::HTML
  include PhlexUI::Components  # get all PhlexUI Kit methods
  include Phlex::Rails::Helpers::Routes
  extend Literal::Properties
end

This separation means PhlexUI::Components::Base can be used in a non-Rails Ruby app — a Sinatra app, a Roda app, even a plain Ruby script.

Moving the components

Copy each component from KanbanFlow’s app/components/ into the gem’s app/components/phlex_ui/, updating:

  1. The class name: Components::ButtonPhlexUI::Components::Button
  2. The parent class: Components::BasePhlexUI::Components::Base
  3. Remove include Phlex::Rails::Helpers::Routes from any component that has it — this moves to opt-in
  4. Update any internal Kit calls to use the PhlexUI::Components:: prefix
 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
# app/components/phlex_ui/button.rb
module PhlexUI
  module Components
    class Button < Base
      BASE_CLASSES = "inline-flex items-center justify-center rounded-md " \
                     "font-medium transition-colors focus:outline-none " \
                     "focus:ring-2 focus:ring-offset-2 disabled:opacity-50 " \
                     "disabled:pointer-events-none"

      VARIANTS = {
        primary:   "bg-primary text-white hover:bg-primary-hover",
        secondary: "bg-secondary text-secondary-text hover:bg-secondary-hover",
        danger:    "bg-danger text-white hover:bg-danger-hover",
        ghost:     "bg-transparent text-text hover:bg-secondary",
      }.freeze

      SIZES = {
        sm: "px-3 py-1.5 text-sm",
        md: "px-4 py-2 text-base",
        lg: "px-6 py-3 text-lg",
      }.freeze

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

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

Repeat for every component. This is mechanical but important — take care to remove any KanbanFlow-specific assumptions (route helpers, model references, app-specific constants).

The entry point

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# lib/phlex_ui.rb
require "phlex"
require "literal"
require "phlex_ui/version"
require "phlex_ui/configuration"
require "phlex_ui/railtie" if defined?(Rails)

# Auto-require all components
Dir[File.join(__dir__, "../app/components/phlex_ui/**/*.rb")].each do |file|
  require file
end

module PhlexUI
  module Components
    extend Phlex::Kit
  end
end

Lesson 3 — Railtie for Rails integration

What a Railtie does

A Railtie is the mechanism Rails gems use to hook into the Rails initialisation process. It lets the gem add autoload paths, run initializers, add routes, and configure Rails — all automatically when the gem is loaded.

For phlex_ui, the Railtie needs to:

  1. Tell Zeitwerk where the gem’s components live
  2. Add Lookbook’s preview path (in development)
  3. Optionally run a generator to set up the host app

The Railtie

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# lib/phlex_ui/railtie.rb
module PhlexUI
  class Railtie < Rails::Railtie
    initializer "phlex_ui.autoload_components" do |app|
      # Tell Zeitwerk to autoload gem components under PhlexUI::Components
      app.config.autoload_paths << File.expand_path(
        "../../app/components", __dir__
      )
    end

    initializer "phlex_ui.lookbook_previews" do |app|
      if defined?(Lookbook) && Rails.env.development?
        previews_path = File.expand_path(
          "../../test/components/previews", __dir__
        )
        app.config.lookbook.preview_paths ||= []
        app.config.lookbook.preview_paths << previews_path
      end
    end
  end
end

Testing the Railtie

With the Railtie in place, add the gem to a fresh Rails app and confirm:

1
2
3
# In a fresh Rails app:
bundle add phlex_ui
bin/rails server

Visit any page — if the Railtie is working, PhlexUI::Components::Button will be autoloaded. Visit /lookbook — if Lookbook is installed, PhlexUI components will appear in the sidebar.

The generator

A generator that sets up the host app with the necessary configuration:

 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
# lib/generators/phlex_ui/install_generator.rb
module PhlexUI
  module Generators
    class InstallGenerator < Rails::Generators::Base
      desc "Sets up PhlexUI in your Rails app"

      def add_initializer
        create_file "config/initializers/phlex_ui.rb", <<~RUBY
          PhlexUI.configure do |config|
            # config.default_button_variant = :primary
          end
        RUBY
      end

      def include_in_components_base
        say "Add the following to your Components::Base:", :green
        say <<~RUBY
          include PhlexUI::Components
        RUBY
      end

      def add_tailwind_tokens
        say "Add the following to app/assets/tailwind/application.css:", :green
        say <<~CSS
          @import "phlex_ui/tokens";
        CSS
      end
    end
  end
end

Run with:

1
bin/rails generate phlex_ui:install

Bundling Tailwind tokens

The gem’s Tailwind tokens need to be importable in the host app’s CSS. Add the token file to the gem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* app/assets/stylesheets/phlex_ui/tokens.css */
@theme {
  --color-primary:       #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-danger:        #dc2626;
  --color-surface:       #ffffff;
  --color-surface-alt:   #f9fafb;
  --color-border:        #e5e7eb;
  --color-text:          #111827;
  --color-text-muted:    #6b7280;
  /* ... all tokens from Module 7 ... */
}

Host apps can override any token after the import:

1
2
3
4
5
6
7
8
9
/* app/assets/tailwind/application.css */
@import "tailwindcss";
@import "phlex_ui/tokens";

/* Override primary for your brand: */
@theme {
  --color-primary:       #7c3aed;
  --color-primary-hover: #6d28d9;
}

Lesson 4 — Lookbook previews in the gem

Bundling previews

Lookbook previews packaged inside the gem give host apps a working component browser immediately — no preview files to write, no setup beyond installing the gem.

The gem’s previews live in test/components/previews/phlex_ui/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# test/components/previews/phlex_ui/button_preview.rb
module PhlexUI
  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 PhlexUI::Components::Button.new(
        label:    label,
        variant:  variant.to_sym,
        disabled: disabled
      )
    end

    def primary   = render PhlexUI::Components::Button.new(label: "Primary",   variant: :primary)
    def secondary = render PhlexUI::Components::Button.new(label: "Secondary", variant: :secondary)
    def danger    = render PhlexUI::Components::Button.new(label: "Danger",    variant: :danger)
    def ghost     = render PhlexUI::Components::Button.new(label: "Ghost",     variant: :ghost)
    def disabled  = render PhlexUI::Components::Button.new(label: "Disabled",  disabled: true)
  end
end

The PhlexUI:: namespace on preview classes prevents conflicts with the host app’s own previews.

Documentation pages

Lookbook supports Markdown documentation pages alongside previews. Bundle them in test/components/docs/phlex_ui/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!-- test/components/docs/phlex_ui/button.md.erb -->
---
title: Button
label: Button
---

# Button

The primary interactive element in PhlexUI. Use `Button` for actions
that trigger server-side behaviour. Use `Link` for navigation.

## Usage

```ruby
Button(label: "Save changes")
Button(label: "Delete", variant: :danger)
Button(label: "Skip", variant: :ghost)

Variants

<%= render_preview :primary %> <%= render_preview :secondary %> <%= render_preview :danger %> <%= render_preview :ghost %>

Props

Prop Type Default Description
label String required Button text
variant Symbol :primary Visual style
size Symbol :md Size — :sm, :md, :lg
disabled Boolean false Disables the button
type String "button" HTML button type

Accessibility

aria-disabled is always set — even when disabled: false — ensuring screen readers correctly announce the button’s state.


### The Lookbook preview layout

The gem needs its own preview layout so components render with the
correct Tailwind stylesheet — even if the host app has different assets:

```ruby
# app/views/layouts/phlex_ui/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>

Set as the default layout for all PhlexUI previews in the Railtie:

1
2
3
4
5
6
7
initializer "phlex_ui.lookbook_config" do
  if defined?(Lookbook) && Rails.env.development?
    Lookbook.configure do |config|
      config.preview_layout = "phlex_ui/preview"
    end
  end
end

Lesson 5 — Configuration

ActiveSupport::Configurable

Rails provides ActiveSupport::Configurable for implementing a configuration API. It’s what Rails itself uses for config.x options:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# lib/phlex_ui/configuration.rb
module PhlexUI
  include ActiveSupport::Configurable

  config_accessor :default_button_variant,  default: :primary
  config_accessor :default_button_size,     default: :md
  config_accessor :default_badge_variant,   default: :default
  config_accessor :default_avatar_size,     default: :md
  config_accessor :icon_set,                default: :unicode
  config_accessor :toast_duration,          default: 4000
end

Host apps configure the library in an initializer:

1
2
3
4
5
6
# config/initializers/phlex_ui.rb
PhlexUI.configure do |config|
  config.default_button_variant = :primary
  config.toast_duration         = 3000
  config.icon_set               = :heroicons  # if heroicons integration added
end

Using configuration in components

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# app/components/phlex_ui/button.rb
module PhlexUI
  module Components
    class Button < Base
      prop :variant, Symbol,
           default: -> { PhlexUI.config.default_button_variant }
      prop :size,    Symbol,
           default: -> { PhlexUI.config.default_button_size }
      # ...
    end
  end
end

The -> { PhlexUI.config.default_button_variant } Proc form ensures the config is read at instantiation time, not at class load time — so changing the config after load still takes effect.

Allowing component subclassing

Host apps can extend any PhlexUI component:

1
2
3
4
5
6
7
# app/components/button.rb
class Components::Button < PhlexUI::Components::Button
  # Override the primary variant with the brand colour
  VARIANTS = PhlexUI::Components::Button::VARIANTS.merge(
    primary: "bg-violet-600 text-white hover:bg-violet-700"
  ).freeze
end

This is the extension pattern preferred over monkey-patching — the host app owns its subclass, the gem owns the base class. Both can evolve independently.

Theme customisation

For Tailwind token overrides, the gem provides a documented list of every token it uses — so host app designers know exactly which CSS variables to override:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* All tokens used by PhlexUI — override any or all in your application.css */

/* Required — components won't render correctly without these */
--color-primary
--color-primary-hover
--color-danger
--color-surface
--color-surface-alt
--color-border
--color-text
--color-text-muted

/* Optional — have sensible defaults if not set */
--color-success
--color-warning
--color-info
--radius-md
--font-sans

Lesson 6 — Publishing and maintenance

Pre-publish checklist

Before publishing to RubyGems for the first time:

□ All components have Lookbook previews
□ All components have documentation pages
□ README explains installation and basic usage
□ CHANGELOG.md has a 0.1.0 entry
□ gemspec metadata is complete (homepage, source_code_uri)
□ No KanbanFlow-specific code remains in components
□ Tests pass: bundle exec rake test
□ The gem installs cleanly in a fresh Rails app
□ /lookbook shows all components correctly
□ Dark mode works in the preview layout

Cutting a release

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Bump the version
vim lib/phlex_ui/version.rb  # 0.1.0 → 0.1.1

# Update CHANGELOG.md
# Add entry for 0.1.1 with date and changes

# Commit
git add .
git commit -m "Release v0.1.1"
git tag v0.1.1

# Build and push
bundle exec rake build
bundle exec rake release

rake release builds the gem, pushes the tag to GitHub, and publishes to RubyGems in one step.

Writing a good CHANGELOG.md

Follow the Keep a Changelog format:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Changelog

## [Unreleased]

## [0.1.1] - 2025-05-15
### Fixed
- Button disabled state now correctly sets aria-disabled="false"
  when enabled (not just "true" when disabled)

### Added
- SearchBar component with debounced Stimulus controller

## [0.1.0] - 2025-04-01
### Added
- Initial release
- Button, Badge, Avatar, Alert, Card, Table, Panel, Link, Heading
- EmptyState, Modal, Dropdown, Toast, Accordion, Tabs, Breadcrumb
- FormGroup, Label, TextInput, Textarea, Select, Checkbox, Radio
- Lookbook previews for all components
- Tailwind v4 token system with dark mode support

CONTRIBUTING.md

 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
# Contributing to PhlexUI

## Development workflow

PhlexUI uses a preview-first development workflow:

1. Write the Lookbook preview first — define all the scenarios your
   component needs to handle before writing any component code
2. Write the component until all preview scenarios render correctly
3. Write tests covering the component's logic and prop validation
4. Update documentation in the component's `.md.erb` file

This workflow ensures every component is previewable and documented
before it ships.

## Adding a new component

1. Create `app/components/phlex_ui/your_component.rb`
2. Create `test/components/previews/phlex_ui/your_component_preview.rb`
3. Create `test/components/docs/phlex_ui/your_component.md.erb`
4. Add the component to `lib/phlex_ui.rb`
5. Add tests to `test/components/your_component_test.rb`
6. Update `CHANGELOG.md`

## Breaking changes

Please discuss breaking changes in an issue before implementing them.
Breaking changes require a major version bump and a migration guide
in the changelog.

Consuming the gem in a fresh Rails app

The final test — install the published gem in a brand new Rails app and confirm everything works:

1
2
3
4
5
rails new test_app --css tailwind
cd test_app
bundle add phlex_ui
bundle add lookbook --group development
bundle exec rails generate phlex_ui:install

In config/routes.rb:

1
mount Lookbook::Engine, at: "/lookbook" if Rails.env.development?

In app/assets/tailwind/application.css:

1
2
@import "tailwindcss";
@import "phlex_ui/tokens";

Start the server and visit /lookbook. The full PhlexUI component library should appear — all previews, all documentation, all variants.

In a view:

1
2
3
4
5
6
7
8
class Views::Home::Index < Views::Base
  include PhlexUI::Components

  def view_template
    Button(label: "Hello from PhlexUI")
    Badge(label: "It works", variant: :success)
  end
end

If that renders correctly, the gem is working end to end.


Bonus Module summary

  • bundle gem phlex_ui creates the skeleton — gemspec, version file, Rakefile, README, CHANGELOG
  • Components move to app/components/phlex_ui/ under the PhlexUI::Components:: namespace — this prevents conflicts with host app components
  • PhlexUI::Components::Base has no Rails dependencies — routes and other Rails helpers are opt-in at the host app level
  • The Railtie hooks into Rails initialisation to add autoload paths and Lookbook preview paths automatically — no manual setup in the host app
  • Lookbook previews and documentation pages bundled in the gem give every host app a working component browser out of the box
  • ActiveSupport::Configurable provides the PhlexUI.configure block — host apps set defaults for variants, sizes, and durations
  • Component subclassing is the preferred extension pattern — host apps inherit from PhlexUI components and override what they need
  • Tailwind tokens are published as an importable CSS file — @import "phlex_ui/tokens" then override individual tokens
  • rake release builds, tags, and publishes to RubyGems in one step
  • The preview-first development workflow — write the Lookbook preview before writing the component — is the recommended contribution pattern

What you’ve built

phlex_ui — a published, versioned Ruby gem containing:

  • 20+ Phlex components with typed props and Tailwind token theming
  • Lookbook previews and Markdown documentation for every component
  • Dark mode and multi-theme support via CSS custom properties
  • A Rails generator for installation
  • A configuration API for host-app customisation
  • A CHANGELOG.md and CONTRIBUTING.md

Any Rails app can now use bundle add phlex_ui and get a complete, documented, themeable component library. That’s the promise from Module 1, fully delivered.