Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

Three icon conventions in one app, and the copy-pasted one is on every sign-in screen

I went looking for how many icons our quiz app ships and found three different answers, because the app has three ways of producing an icon and nobody had ever counted them together. Convention one: 62 glyphs f

I went looking for how many icons our quiz app ships and found three different answers, because the app has three ways of producing an icon and nobody had ever counted them together.

Convention one: 62 glyphs from a component library

lucide-react is imported in 50 files by 51 import statements, and those statements name 62 distinct glyphs. The vocabulary reads like a tour of the product: QrCode, Trophy, Timer, Users, Wifi, WifiOff, Power, PowerOff, Crown, ReceiptText.

It renders as a stroke-based 24 unit grid, and it labels itself on the way out:

<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
     stroke="currentColor" stroke-width="2"
     class="lucide lucide-chevron-down h-3.5 w-3.5 opacity-60"
     aria-hidden="true">

Two things in that tag are doing unpaid work. The lucide-chevron-down class means you can read a page's icon inventory out of the HTML with grep, no source access needed. The aria-hidden="true" means a decorative glyph does not get read out by a screen reader unless you deliberately turn that off.

Convention two: one glyph from a 38-set icon pack

import { FaGoogle } from "react-icons/fa"

That is one import, in one file, for the Google mark on the sign-in button. It is the entire reason react-icons is in package.json.

The reason is legitimate. Lucide is a line-icon set and deliberately does not ship brand logos, so the moment you need the actual Google G you need a pack that carries brand marks.

What it costs on disk:

83M  node_modules/react-icons
39M  node_modules/lucide-react

The one directory we import from, react-icons/fa, is 1,365,071 bytes in its ESM build, as a single file of exported components. Its sibling fa6 is another 1,723,849 bytes. There are 38 icon-set directories in the package and we reach into one of them, for one glyph.

It also renders to a different shape entirely: fill-based, Font Awesome's original viewBox, sized in em, and not aria-hidden:

<svg stroke="currentColor" fill="currentColor" stroke-width="0"
     viewBox="0 0 488 512" class="w-5 h-5" height="1em" width="1em">

The height="1em" versus width="24" difference is the part that bites. One icon follows the font size it inherits and the other does not, so a row with both needs its sizes pinned by hand, which is exactly what the w-5 h-5 on that tag is doing. The missing aria-hidden is why the button around it carries an explicit aria-label="Sign in with Google".

Convention three: path data, pasted

Then there is this, which I did not expect to find:

<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"
     viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
     strokeLinecap="round" strokeLinejoin="round">
  <path d="m15 18-6-6 6-6"/>
</svg>

That is a chevron pointing left, and m15 18-6-6 6-6 is byte for byte lucide's own chevron-left path. It appears in six files: the sign-in page, the sign-up page, the forgot-password page, its success page, the reset page and the reset success page. Two more hand-written SVGs live in the maintenance notice and the player lobby.

So the app imports ChevronDown, ChevronRight and ChevronUp from lucide as components, and the only chevron that points left exists as six copies of raw path data, without the lucide- class and without the aria-hidden the library would have added for free. Those six pages are the ones a stranger is most likely to meet.

Nothing is broken. It is just that an icon import is a local decision in a single file, so three reasonable local decisions add up to an app with no icon policy.

What none of this costs in the browser

Both libraries are named in optimizePackageImports in next.config.mjs, which turns a barrel import into a direct one so the build pulls the glyph rather than the index. More to the point, every icon on our public pages is rendered on the server or at build time, so what the browser receives first is inline SVG markup, not the module that produced it.

You can verify that without a profiler:

curl -s https://pub-trivia.app/login | grep -o '<svg[^>]*>'

Two SVGs come back. One is the hand-pasted chevron, one is the Font Awesome Google mark, and the stroke-versus-fill difference tells you which is which at a glance. No .js has to run for either of them to be on screen.

Do the same on a content page and you get the other convention:

curl -s https://pub-trivia.app/features | grep -o 'class="lucide[^"]*"'

Five hits on that page, all lucide, all aria-hidden: a chevron-down in the header menu, a chevron-right in the breadcrumb trail, and three arrow-rights on the related-links cards.

The duplicates that fall out of counting

Fifty files each importing their own icons produces exactly the drift you would expect. Our 62 glyphs include BarChart2 and BarChart3, CheckCircle and CheckCircle2, and both Edit and Pencil. Two of those pairs are the same idea at different library versions; the third is two names for one concept. Nobody chose to have both of any of them.

The boring fix is one module that re-exports the app's icon vocabulary, so there is a single place where Pencil beats Edit, a single place where ChevronLeft becomes a component instead of a paste, and one diff when either changes.

Check it on the live pages

  • pub-trivia.app/login has conventions two and three in the same card. Compare the two <svg> tags in the source and you have the whole post in four lines of HTML.
  • pub-trivia.app/features is convention one only: every glyph self-labels with a lucide-* class, so the page tells you its own icon inventory.
  • pub-trivia.app/tools/team-name-generator carries lucide-refresh-cw next to the draw button. The generated names are in the HTML before any JavaScript runs, which is the same server-first rendering that keeps the icons out of the critical path.

If you want to run this audit on your own app, the two numbers worth having are the count of import statements and the count of distinct named imports. The gap between them is your icon policy, written down by accident.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.