Skip to content
AstroCraft Docs
On this theme

Icons

TVfolio ships 572 icons at 24×24, inlined into HTML at build time behind a single typed component. There is no icon package, no sprite fetch, and nothing lands in client JS.

---
import { Icon } from "@components/svg/icons";
---

<Icon name="activity" />
<Icon name="trash-01" size="lg" class="text-error" />
<Icon name="search-01" title="Search" />

The component

Icon.astro follows the same primitive contract as everything in ui/ — an exported tv() config, native svg props, merged class, data-slot="icon":

Prop Values
name a typed IconName — an illegal name fails astro check, and autocomplete lists every icon
size sm md lg xlsize-4size-8, default md. Or just pass class="size-6"
title gives the icon an accessible name (role="img" + <title>)

Geometry uses currentColor, so recolour with any text-* token and dark mode is free. Brand marks are filled silhouettes where the negative space is a path knockout — Facebook’s “f”, for instance — so they flip with the theme too, rendering in one tone rather than brand colours.

Accessibility default: decorative. An icon with no title gets aria-hidden="true". Pass title when the icon carries meaning a screen reader needs.

The two-file registry

The set is split, and understanding the split is the difference between adding an icon that survives and one that vanishes on the next regeneration.

icons.ts     571 icons — AUTO-GENERATED, never hand-edit
custom.ts      1 icon  — hand-maintained
registry.ts    the merge, and the only place IconName is exported

icons.ts is regenerated wholesale, so anything written there is lost on the next run. Hand-maintained glyphs go in custom.ts instead.

The collision rule: additions win

A name present in both files resolves to the custom.ts markup. That makes overriding a generated glyph a deliberate act rather than a silent no-op the next regeneration would undo anyway.

The one custom icon today is X. The “Social Media Icons 24×24” Figma set predates the rename and has no X mark, so it was lifted from the TVfolio design file and normalized by hand into the same 24×24 currentColor shape.

One export site

IconName and iconNames come from registry.ts and only there. The generated file carries a warning against re-emitting them, and the reason is worth internalizing: a second, smaller IconName exported from icons.ts would type-check cleanly while rejecting every custom name. That is a bug that looks like a working import.

The check

registry.test.ts asserts the merge, because both halves of it are silent when they break — a lost custom glyph renders as an empty <svg>, a lost override renders the old one, and neither throws.

It does something slightly clever that is worth noting. The two real sets do not currently overlap, which makes the collision rule unobservable through ICONS — a flipped spread order would be caught by nothing. So the merge function is exported separately and exercised with synthetic inputs. Reversing the spread fails there and nowhere else in the project.

Where the icons come from

Two Figma Community files. The bulk are Stratis UI Icons line icons, ported by category frame: General, Arrows (partial), Media & Devices, Alerts, Security, Images, Files, Charts, Development, Communication and Editor. On top is a Social/brand set from Social Media Icons 24×24 — filled marks including facebook, github, linkedin, discord, slack, bluesky and others.

Line icons carry their own stroke width; brand marks are filled.

Regenerating

The pipeline is documented in full in src/components/svg/icons/README.md. In outline: pull a category frame through the Figma MCP’s get_design_context, collect { id, name, url } into a manifest deduped by node id (the data-names are not unique in the source), then run the generator, which downloads each SVG, normalizes colour to currentColor, flattens bare <g> wrappers, strips ids, scale-to-fits off-grid viewBoxes, dedupes by cleaned content, and rewrites icons.ts.

Three things about that process are worth knowing before you attempt it:

The cleaner asserts rather than guesses. A <g transform>, a colour it could not normalize, an empty icon — each fails loudly instead of shipping a broken glyph.

The source file mislabels some glyphs. Duplicate data-names on -01/-02 variants, a checkmark labelled message-square-plus, icons named Component or -. Fix those with a node-id-keyed name override in the generator, never by hand-editing icons.ts — that edit is lost on the next run.

A cross-file name clash is reported and skipped, not overwritten. The brand file’s youtube collides with the UI set’s, so it is dropped rather than silently replacing one.

The generator merges idempotently — identical glyphs dedupe by content — so re-importing a frame never double-adds. Brand marks need BRAND=1, which strips their <defs>/clip-path wrapper after asserting the clip really is just the 24×24 frame.

The size ceiling

The whole registry is one module, roughly 450 KB at 571 icons, and the source marks this as a known ceiling:

It stays build-time — icons inline into HTML and nothing lands in client JS — but every icon markup loads even if a page uses one.

The upgrade path is named: importing the registry in a client <script> would ship all of it, and at that point you split to per-file .svg imports (Astro’s native SVG components) or an SVG sprite.

In practice this costs a static site nothing at runtime — the module is evaluated during the build and only the icons you actually render reach the HTML. It costs build memory and a slower cold start on the dev server, which is the trade that was accepted.

If you want to trim it, the honest move is to delete the categories you do not use from icons.ts and re-run pnpm check — the typed IconName union will name every usage you broke.

NEXT STEPMotion & Animation