Skip to content

Lesson 5 — Icon

KanbanFlow uses icons throughout — alert variants, toast notifications, dismiss buttons, status badges. Rather than sprinkling emoji or raw SVG inline across components, we build a single Icon component that owns all HeroIcon paths and renders consistent, accessible SVG.

HeroIcons

HeroIcons is an open-source SVG icon set designed by the makers of Tailwind CSS. It comes in two variants — outline (stroke-based, the style we use) and solid (filled). The outline variant pairs naturally with Tailwind’s text colour utilities because the strokes inherit currentColor.

HeroIcons is MIT licensed, meaning you can use it freely in personal and commercial projects without attribution. The full set is browsable at heroicons.com — search by name, click an icon, and copy the SVG path data directly.

We embed the path data directly in the component rather than loading HeroIcons as a package. This keeps the asset pipeline simple (no Node, no npm), gives us complete control over which icons are available, and means the component has no runtime dependencies beyond Phlex itself. The tradeoff is manual updates if HeroIcons changes a path — in practice this rarely matters since icon shapes are stable across versions.

Why a component rather than a helper

The instinct for icons is often a view helper — icon(:x_mark) that returns an HTML string. A Phlex component is better for two reasons.

First, it composes naturally. Icon(name: :x_mark, class_name: "h-4 w-4") inside a component template renders inline without raw or html_safe — Phlex handles the output correctly. A helper returning a string would need raw safe(...) at every call site.

Second, it’s testable. We can assert that Icon renders an svg with aria-hidden="true", that an unknown name raises ArgumentError, and that the correct path data is present — all with the same ComponentTestHelper used for every other Phlex::UI component.

A Phlex gotcha: path inside svg

Before looking at the component, there’s a Phlex naming conflict worth knowing about.

Inside any Phlex component, path is an HTML element method — it renders a <path> element in an HTML context. When you open an svg block and try to call path(...) bare, Phlex sees its own HTML method, not an SVG element call, and the result is wrong or raises an error.

The fix is to use the block argument that Phlex yields:

1
2
3
svg(...) do |s|
  s.path(d: "...")   # correct — calls path on the yielded instance
end

s is the component instance itself (Phlex’s yield(self) behaviour). Calling s.path makes the intent unambiguous. This same issue affects any SVG element whose name collides with an HTML tag — path is the most common case in practice.

This is the same class of problem as the slot naming conflicts from Module 3 (header, footer, body are all Phlex HTML methods). The rule is consistent: if a method name is also an HTML tag, use an explicit receiver to disambiguate.

The component

 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
58
59
60
61
62
# app/components/icon.rb
class Components::Icon < Components::Base
  ICONS = {
    information_circle: {
      paths: [
        "M11.25 11.25l.041-.02a.75.75 0 011.059.852l-.708 2.836a.75.75 0 " \
        "001.059.852l.041-.02M12 8.25h.008v.008H12V8.25zm9 3.75a9 9 0 11-18 0 9 9 0 0118 0z"
      ]
    },
    check_circle: {
      paths: [
        "M9 12.75l2.25 2.25L15 9.75m6 2.25a9 9 0 11-18 0 9 9 0 0118 0z"
      ]
    },
    exclamation_triangle: {
      paths: [
        "M12 9v3.75m0 3.75h.008v.008H12v-.008zm-8.834 1.132l7.5-13a1.5 " \
        "1.5 0 012.598 0l7.5 13A1.5 1.5 0 0119.464 21H4.536a1.5 1.5 0 " \
        "01-1.299-2.25z"
      ]
    },
    x_circle: {
      paths: [
        "m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
      ]
    },
    x_mark: {
      paths: ["M6 18L18 6M6 6l12 12"]
    },
  }.freeze

  prop :name,       Symbol
  prop :class_name, String, default: -> { "h-6 w-6" }

  def view_template
    svg(**svg_attributes) do |s|
      icon.fetch(:paths).each do |d|
        s.path(stroke_linecap: "round", stroke_linejoin: "round", d:)
      end
    end
  end

  private

  def icon
    ICONS.fetch(@name) do
      raise ArgumentError, "Unknown icon: #{@name.inspect}"
    end
  end

  def svg_attributes
    {
      xmlns:        "http://www.w3.org/2000/svg",
      fill:         "none",
      viewBox:      "0 0 24 24",
      stroke_width: "1.5",
      stroke:       "currentColor",
      class:        @class_name,
      aria_hidden:  "true"
    }
  end
end

Design decisions

paths: is an array. Most HeroIcons outline variants use a single <path> element, but some use two. Storing paths as an array handles both cases identically — adding a two-path icon later is just adding a second string to the array, with no structural change to the component.

class_name not class. Ruby reserves class as a keyword, so Phlex components can’t use it as a prop name. class_name is the established convention. The default "h-6 w-6" is the standard HeroIcon display size — override at the call site when you need something smaller or larger.

aria_hidden: "true". Icons in Phlex::UI are decorative by default — they accompany text labels that already convey the meaning. aria-hidden="true" removes them from the accessibility tree so screen readers don’t read out “svg” or “image” for every icon in the UI. If you ever use an icon as the sole content of a button (no label), add a visible or sr-only span with descriptive text alongside it.

stroke: "currentColor". The SVG stroke inherits the CSS color property of its parent element. This means icon colour is controlled by Tailwind text utilities — text-danger, text-success, text-text-muted — without any prop or variant on Icon itself.

Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Default size (h-6 w-6), inherits parent colour
Icon(name: :check_circle)

# Smaller, explicit colour via parent text class
span(class: "text-success") { Icon(name: :check_circle, class_name: "h-4 w-4") }

# Inside a button — icon accompanies a visible label
button(type: "button", class: "flex items-center gap-2") do
  Icon(name: :x_mark, class_name: "h-4 w-4")
  span { "Dismiss" }
end

Adding icons

To add a new HeroIcon, copy the path data from heroicons.com, choose the outline variant (stroke, not filled), and add an entry to ICONS:

1
2
3
4
5
6
ICONS = {
  # ... existing icons ...
  plus_circle: {
    paths: ["M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"]
  },
}.freeze

The outline variant uses fill: "none" and stroke: "currentColor", which matches the svg_attributes already set. The solid variant (filled) would need different SVG attributes — add a variant prop if you need both.

Lookbook preview

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# test/components/previews/icon_preview.rb
class IconPreview < Lookbook::Preview
  def information_circle = render Components::Icon.new(name: :information_circle)
  def check_circle       = render Components::Icon.new(name: :check_circle)
  def exclamation_triangle = render Components::Icon.new(name: :exclamation_triangle)
  def x_circle           = render Components::Icon.new(name: :x_circle)
  def x_mark             = render Components::Icon.new(name: :x_mark)

  def small
    render Components::Icon.new(name: :check_circle, class_name: "h-4 w-4")
  end

  def large
    render Components::Icon.new(name: :check_circle, class_name: "h-8 w-8")
  end

  # @param name select { choices: [information_circle, check_circle, exclamation_triangle, x_circle, x_mark] }
  # @param size select { choices: [sm, md, lg] }
  def interactive(name: :check_circle, size: :md)
    size_class = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" }.fetch(size.to_sym)
    render Components::Icon.new(name: name.to_sym, class_name: size_class)
  end
end

Tests

 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
# test/components/icon_test.rb
require "test_helper"
require "component_test_helper"

class IconTest < ActiveSupport::TestCase
  include ComponentTestHelper

  test "renders an svg element" do
    doc = render_fragment Components::Icon.new(name: :x_mark)
    assert doc.at_css("svg"), "Expected an svg element"
  end

  test "svg is aria-hidden" do
    doc = render_fragment Components::Icon.new(name: :x_mark)
    assert_equal "true", doc.at_css("svg")["aria-hidden"]
  end

  test "applies default size class" do
    doc = render_fragment Components::Icon.new(name: :x_mark)
    assert_includes doc.at_css("svg")["class"], "h-6"
    assert_includes doc.at_css("svg")["class"], "w-6"
  end

  test "applies custom class_name" do
    doc = render_fragment Components::Icon.new(name: :x_mark, class_name: "h-4 w-4")
    assert_includes doc.at_css("svg")["class"], "h-4"
  end

  test "renders a path element" do
    doc = render_fragment Components::Icon.new(name: :x_mark)
    assert doc.at_css("svg path"), "Expected a path element"
  end

  test "unknown name raises ArgumentError" do
    assert_raises(ArgumentError) do
      Components::Icon.new(name: :does_not_exist).call
    end
  end
end

Using Icon in other components

From Module 8 onwards, Alert and Toast replace their emoji icons with Icon. The pattern is the same in both:

1
2
3
4
5
# Before
span(class: "text-lg leading-none shrink-0") { ICONS[@variant] }

# After
Icon(name: ICON_NAMES[@variant], class_name: "h-5 w-5 shrink-0")

Where ICON_NAMES maps variant symbols to icon name symbols:

1
2
3
4
5
6
ICON_NAMES = {
  info:    :information_circle,
  success: :check_circle,
  warning: :exclamation_triangle,
  danger:  :x_circle,
}.freeze

The dismiss button in both components uses :x_mark:

1
2
3
button(type: "button", ...) do
  Icon(name: :x_mark, class_name: "h-4 w-4")
end

Tags: #phlex #rails #components #icons #heroicons #accessibility #svg #tutorial