Nearly every project I’ve worked on has, at some point, had to answer the question of inlining SVGs into HTML. I’ve been refining my approach to this from project to project and now have something stable and reusable I just drop into anything new.

Why inline SVGs at all?

Embedding SVG markup directly in your HTML gives CSS full access to the SVG’s internals — fill: currentColor, stroke control, hover states, all of it. It also means your icons come along for free with the HTML you’re already serving, rather than as a separate request per image. If you need a deeper primer on the approach, CSS Tricks has a good one.

Phase 1: Reaching for a gem

The first time this came up, I immediately reached for a gem. Everyone does, especially early in your career. The excellent inline_svg was the obvious choice. It worked out of the box, slotted cleanly into ERB templates via a view helper, and had first-class support for a range of asset pipeline configurations — including Webpacker (Webpacker, not Shakapacker — yes, that long ago), which was always awkward to work with.

<%= inline_svg_tag "icons/arrow-right.svg", class: "size-6 text-gray-500" %>

External dependencies come at a cost though. A general-purpose tool is, by definition, more complicated than you need it to be. inline_svg handled multiple asset pipeline strategies, SVG transformation pipelines, and a configuration surface to match. That flexibility meant it could be applied to pretty much any project. But icons, in practice, are done exactly one way per project — you pick a folder, you pick a naming convention, and that’s it for the lifetime of the app. The complexity it brought for cases I’d never hit started to feel like dead weight: transitive dependencies and a configuration file the next person had to understand before changing anything.

Phase 2: Vendoring into /app/lib

A few years later I found myself needing to add inline SVGs again. This time around I vendored a small module into app/lib that read SVG files from a known path, did the minimal manipulation needed, and returned markup:

# app/lib/icon_renderer.rb
class IconRenderer
  class IconNotFoundError < StandardError; end

  def self.render(icon, size: "size-6", **options)
    new(icon, size: size, **options).render
  end

  def initialize(icon, size:, **options)
    @icon = icon
    @size = size
    @options = options
  end

  def render
    # parse SVG, inject attributes, return html_safe string
  end
end

A view helper wired it into templates:

# app/helpers/icon_helper.rb
module IconHelper
  def icon(name, **options)
    IconRenderer.render(name, **options).html_safe
  end
end

No external dependencies — nokogiri is bundled as part of Rails — a surface area I controlled completely, and logic I could read in full.

But this still wasn’t perfect. The logic was split across two files that had to be understood and maintained together, even though they lived apart. I was also using a view helper to keep the ERB calls sane, and they had their own problems. They were globally included — icon :arrow_right could be defined anywhere, with nothing at the call site to tell you where to look. They were difficult to test in isolation. They accumulate quietly until your ApplicationHelper is a junk drawer.

These days, I reach for ViewComponent as my default for any view logic — reused partials, anything with non-trivial rendering behaviour, anything I want to test without standing up a full controller context. The icon renderer works fine and doesn’t need much maintenance (it’s still in use in that project), but it was exactly the kind of thing ViewComponent was made for.

Phase 3: A ViewComponent

On the next project, I dragged over my little module but pushed it into a ViewComponent. This is the version I carry around with me now. It encapsulates everything — parsing, attribute manipulation, error handling — in a single class.

I also took the opportunity to swap Nokogiri for Oga, a pure-Ruby XML parser with no native extensions — an idea I picked up from Kill Your Dependencies.

# app/components/icon_component.rb
# frozen_string_literal: true

class IconComponent < ViewComponent::Base
  class IconNotFoundError < StandardError; end

  attr_reader :icon, :size, :options

  def initialize(icon, size: "size-6", **options)
    @icon = icon
    @size = size
    @options = options
  end

  def call
    return render_icon_not_found if icon_not_found?

    svg = parsed_svg

    set_classes!(svg)
    set_aria_attributes!(svg, options[:aria])
    set_data_attributes!(svg, options[:data])
    set_base_attributes!(svg, base_attributes)
    set_role!(svg)

    svg.to_xml.strip.html_safe
  end

  private

  def icon_name
    icon.to_s
  end

  def class_list
    [size, options[:class]].compact_blank.join(" ")
  end

  def base_attributes
    options.except(:class, :aria, :data)
  end

  def set_classes!(svg)
    svg.set("class", (class_list.split + ["icon-#{icon_name.dasherize}"]).join(" "))
  end

  def set_aria_attributes!(svg, aria_attributes)
    aria_attributes&.each do |attribute, value|
      svg.set("aria-#{attribute.to_s.parameterize(preserve_case: true).dasherize}", value)
    end
  end

  def set_data_attributes!(svg, data_attributes)
    data_attributes&.each do |attribute, value|
      svg.set("data-#{attribute.to_s.parameterize(preserve_case: true).dasherize}", value)
    end
  end

  def set_base_attributes!(svg, attributes)
    attributes&.each do |attribute, value|
      svg.set(attribute.to_s.parameterize(preserve_case: true).dasherize, value)
    end
  end

  def set_role!(svg)
    svg.set("role", svg.get("role").presence || (svg.get("aria-label").blank? ? "presentation" : "img"))
  end

  def icon_not_found?
    !File.exist?(icon_asset_path)
  end

  def render_icon_not_found
    if Rails.env.local?
      raise IconNotFoundError, "Could not find icon: #{icon_name}"
    else
      "(Icon Not Found)"
    end
  end

  def parsed_svg
    Oga.parse_xml(File.read(icon_asset_path)).at_css("svg")
  end

  def icon_asset_path
    @_icon_asset_path ||= Rails.root.join("app/assets/images/icons/#{icon_name}.svg").to_s
  end
end

Everything in there is something I’ve actually needed across the years, and nothing I haven’t. Accessibility roles are set automatically (role="presentation" by default, role="img" when an aria-label is present). Size is a separate argument from other classes so you can vary icon sizes without rewriting class lists. Each icon gets an icon-{name} class — icon-arrow-right for :arrow_right — which makes targeting in tests and CSS straightforward without coupling to size or colour classes.

In use:

<%= render IconComponent.new(:arrow_right) %>
<%= render IconComponent.new(:arrow_right, class: "text-gray-500", aria: { label: "Next" }) %>
<%= render IconComponent.new(:arrow_right, size: "size-12") %>

Where I landed

Took a few years and a few attempts to get there, but I now have a single file to drop into any new project. The specific choices are mine: Tailwind for CSS, ViewComponent for encapsulation, Oga for parsing. But the shape of it works regardless of your stack, and the gist is there if you want a starting point.