RemixIcons - 2,800+ Icons for Phoenix LiveView

Hey folks! :waving_hand:

I put together a tiny library called RemixIcons because, honestly, I just wanted an easy way to drop clean, reliable icons into my LiveView apps without digging around for SVGs every time.

It gives you 2,800+ icons, caches them with Cachex so everything stays fast, and comes with a simple Phoenix component that “just works.” Add it once and you’re done.

Links:

If you try it out, let me know what you think. Always happy to improve it. :raising_hands:

16 Likes

Thanks for sharing!

BTW, as someone who has also built an icon library in the past, my recommendation for you would be to pre-compile the SVGs instead of storing them in memory. Even if using Cachex, if a page has a couple of hundred icons, you’ll still have the penalty of IO’ing on the first load/hit.

2 Likes

Thanks for the feedback!

I actually started with precompiled SVGs, but the compilation time became really heavy because the set has 2,800+ icons. I couldn’t find a clean way to precompile only the icons actually used without adding a lot of complexity to the library, so I ended up moving to a cache-based approach.

Regarding the initial IO hit: from what I’ve seen so far, Cachex handles that pretty well. It will “hold” concurrent requests while populating the entry and only performs the IO once per icon. So even in scenarios with high traffic and an empty cache, each icon should only incur a single read.

1 Like

IIRC, material icons has a little over 10k icons if you count all styles, and it compiles under 5 seconds (~7 on a cold compile), which is just fine for something that is done just once (once the module is compiled, you only need to do it again if you want to include more icons)…

time mix icons.build
* creating lib/material_icons.ex

________________________________________________________
Executed in    2.18 secs    fish           external
   usr time    4.73 secs  421.00 micros    4.73 secs
   sys time    0.82 secs  342.00 micros    0.82 secs

The reads for the task above are not parallelized, so it would be possible to speed it up if that’s a problem. FWIW, this is essentially doing what Phoenix does with views: it embeds the content into functions, allowing you to avoid IO altogether (even a single read might be problematic, depending on the application).

3 Likes

Here’s how I’ve been thinking about:

1. Compile time really hurts DX

I actually started with the “precompile everything into a module” idea. But even on my MacBook M3 Pro, I couldn’t get the compile step under ~10 seconds. On a low-cost VPS, it’s even worse. And when you imagine every library adding 10–15 seconds on top of that… man, DX just melts. It adds up fast.

2. I’m still not convinced the first read is a real problem

I keep trying to picture a situation where the first disk read of an SVG actually shows up as a bottleneck. We’re talking… what? Maybe 5–10ms? And in a Phoenix app you already have things that dwarf that all day long like API calls, DB queries, template rendering, LiveView diffs.

Unless there’s a real case where someone saw P95/P99 spikes because of that first read, it feels more like optimizing the wrong thing.

3. Precompiled icons don’t magically escape the “first load” cost

Even if everything is precompiled, those icons still end up inside a huge .beam file sitting on disk. And the BEAM loads modules lazily only when they’re first used. Take a look on BEAM Code Loading Strategy.

So we still get a first-hit cost. The difference is just the shape of it:

  • with caching: you read a tiny SVG once and Cachex handles the rest
  • with precompiled modules: you load a giant blob of thousands of icons into memory at once. Same class of problem, just wearing a different outfit.

4. And warm-up applies to both approaches

If the goal is “avoid first hit entirely,” we can force it on either path:

  • Precompiled? Code.ensure_loaded/1 on boot.
  • Runtime + Cachex? Warm the cache on startup with the icons you care about.

So the “we can avoid first hit” argument doesn’t really tilt the scale one way or the other. Both models offer that escape hatch.

1 Like

It certainly feels like an odd way to do it to me. I used to have a list of the Remix Icons I used in a text file and compiled functions for only those, but I migrated to the Tailwind plugin method when I saw how they did it with Hero Icons. It stores the SVG in a CSS variable, which, if I understand correctly, is going to store the SVG once for all instances.

1 Like

Obviously this is a topic that lends itself to bikeshedding, but…

This is very obviously the ideal approach, but it is indeed super annoying and messy to rig up a custom compiler. However, there has been a lot of recent work in Phoenix to support colocated JS, and the underlying abstraction, from what I understand, is shaped similarly.

@steffend do you see a path for using macro components (I’m not sure the current status of that feature) to rig up something like this? The idea would be to extract all of the static icon invocations at compile time and generate a module containing only those invocations, or something like that.

1 Like

To bikeshed a bit more, if an icon is used in a typical app it is almost certainly going to be used again, so you don’t really need any sort of cache eviction policy here. Nor do you need any of Cachex’s other features, right?

So if you’re sticking with the caching strategy it should be trivial to use an :ets table directly and drop the Cachex dependency.

That’s hard drive latency; for an SSD that’s at least a couple of orders of magnitude too high.

The first load is nothing, the real tragedy of this approach is that you’re taking locks and copying data from ets tables for a workload that has zero actual contention and can be entirely pre-computed. But on the flip side, the icons are probably refc binaries and there are a number of things that will perform ets reads during a request anyway, like Config and that weird HTTP clock thing in Bandit/Cowboy, so it honestly is not that big of a deal.

1 Like

Honestly, the whole thing could run on plain ETS just fine. I only went with Cachex because it’s super convenient, especially the part where it holds the first incoming requests while the cache warms up. That alone made my life easier.

At some point, when things are less hectic, I might switch everything to ETS and drop the Cachex dependency altogether. Or who knows, maybe the ideal solution is to write a generator that builds a Tailwind plugin’s configuration automatically. That might end up being the best of both worlds.

2 Likes
    plugin(function ({ matchComponents, theme }) {
      let iconsDir = path.join(__dirname, "./vendor/remixicon")
      let values = {}
      fs.readdirSync(iconsDir).map(dir => {
        fs.readdirSync(path.join(iconsDir, dir)).map(file => {
          let name = path.basename(file, ".svg")
          values[name] = { name, fullPath: path.join(iconsDir, dir, file), }
        })
      })
      matchComponents({
        "ri": ({ name, fullPath }) => {
          let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
          return {
            [`--ri-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
            "-webkit-mask": `var(--ri-${name})`,
            "mask": `var(--ri-${name})`,
            "mask-repeat": "no-repeat",
            "background-color": "currentColor",
            "vertical-align": "middle",
            "display": "inline-block",
            "width": theme("spacing.5"),
            "height": theme("spacing.5")
          }
        }
      }, { values })
    })
2 Likes

In iconify_ex we compile only used icons thanks to a macro component when using Surface but I don’t if the same possible in LiveView in which case we simply do it the first time an icon is loaded during dev (committing the results so precompiled ones are used in prod).

2 Likes

This looks AMAZING!

I’ll try them next week. Thanks for sharing.

1 Like

Sure, although the syntax for those would maybe be a bit inconvenient, as you’d need to write <i :type={MyMacroComponent} ...> everywhere you place an icon. And since the API isn’t public for now, replicating all the logic is quite a bit complicated, as was mentioned. You can definitely do it with regular macros (the syntax would then be more like {remixicon("the-icon", size: 12)}), but probably not worth the effort?

If you already use Tailwind, the plugin approach is much easier to do and is actually what we use in Tidewave:

const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")

module.exports = plugin(function({matchComponents, theme}) {
  let baseDir = path.join(__dirname, "../../deps/remixicons/icons");
  let values = {};
  let icons = fs
    .readdirSync(baseDir, { withFileTypes: true })
    .filter((dirent) => dirent.isDirectory())
    .map((dirent) => dirent.name);

  icons.forEach((dir) => {
    fs.readdirSync(path.join(baseDir, dir)).map((file) => {
      let name = path.basename(file, ".svg");
      values[name] = { name, fullPath: path.join(baseDir, dir, file) };
    });
  });

  matchComponents(
    {
      ri: ({ name, fullPath }) => {
        let content = fs
          .readFileSync(fullPath)
          .toString()
          .replace(/\r?\n|\r/g, "");

        return {
          [`--ri-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
          "-webkit-mask": `var(--ri-${name})`,
          mask: `var(--ri-${name})`,
          "background-color": "currentColor",
          "vertical-align": "middle",
          display: "inline-block",
          width: theme("spacing.10"),
          height: theme("spacing.10"),
        };
      },
    },
    { values },
  );
})
3 Likes

This is awesome! Thank you, thank you!

1 Like