Icons
Finly ships 571 icons behind one typed component, with no icon package as a dependency. Every glyph is inline SVG markup in a TypeScript module, resolved at build time — icons inline into the HTML and nothing lands in client JavaScript.
Using one
---
import { Icon } from "@components/svg/icons";
---
<Icon name="activity" />
<Icon name="trash-01" size="lg" class="text-error" />
<Icon name="search-01" title="Search" />
name is a typed IconName. The union is generated alongside the registry, so a wrong name is a compile error that astro check catches — not a blank square you find in review. Your editor autocompletes all 571.
size is sm md lg xl — size-4 through size-8, default md. Or ignore the variant and pass class="size-6"; the base is inline-block shrink-0 and everything else is yours.
Colour is currentColor. The geometry carries no fill or stroke colour of its own, so any text-* token recolours it: text-primary, text-muted-foreground, text-error. Dark mode is free, because the token flips underneath. Brand marks are filled silhouettes — the negative space, such as Facebook’s “f”, is a path knockout — so they flip with the theme too, rendering in one tone rather than in brand colours.
Accessibility is decorative by default. With no title, the <svg> carries aria-hidden="true", which is right for the overwhelming majority of uses: an icon beside a label is decoration, and announcing it twice is noise. Passing title switches it to role="img" with a <title> child, which is what an icon-only control needs.
An icon-only button therefore has two correct spellings. Either give the icon a title, or leave it decorative and put the name on the button:
<Button icon aria-label="Close"><Icon name="x-01" /></Button>
What is in the registry
Two sources, and the split matters when you go looking for a glyph.
The bulk are line icons from the Stratis UI set — General, Arrows, Media & Devices, Alerts, Security, Images, Files, Charts, Development, Communication and Editor. They are 24×24, stroked, and carry their own stroke width.
On top is a brand set — filled marks for facebook, instagram, tiktok, threads, messenger, whatsapp, telegram, behance, github, discord, linkedin, slack, line, apple, google, pinterest, google-play and bluesky.
Names are kebab-case, and families are numbered rather than adjectival — add-square-01 through add-square-04, alert-circle, alert-square, alert-triangle. Browsing the registry in /examples/ui/ is faster than guessing; the icons panel renders all 571 with their names.
iconNames is exported alongside the component for exactly that kind of gallery:
import { iconNames } from "@components/svg/icons";
How it is built
src/components/svg/icons/icons.ts is auto-generated and should never be hand-edited. It maps each name to the inner markup of a 24×24 icon, and exports the IconName union and the iconNames array alongside it. The component is thirty lines:
<svg viewBox="0 0 24 24" fill="none" class={icon({ size, class: className })} data-slot="icon"
role={title ? "img" : undefined} aria-hidden={title ? undefined : "true"} {...rest}>
{title && <title>{title}</title>}
<Fragment set:html={ICONS[name]} />
</svg>
It follows the same primitive contract as everything in ui/: data-slot, an exported tv() config, native svg props plus variant props, merged class, tokens only.
The ceiling it states about itself
The registry is honest about its one trade-off, in a ponytail: comment:
the whole registry is one module (~450 KB at 571 icons). 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.
So the cost is build-time memory and module size, not shipped bytes. The upgrade path is named too: importing the registry inside a client <script> would ship all of it, and at that point the answer is to split to per-file .svg imports (Astro’s native SVG components) or an SVG sprite. Nothing in the theme does that today.
Adding or regenerating icons
The registry was pulled from Figma Community files through a generator that lives in the scratchpad used to build the set. The process, documented in src/components/svg/icons/README.md:
- Call the Figma MCP
get_design_contexton a category frame node — one call per frame. - Collect
{ id, name, url }into a manifest, deduping by node id (sourcedata-names are not unique). - Run the generator. It downloads each SVG, normalizes colour to
currentColor, flattens bare<g>wrappers, strips ids, scale-to-fits off-grid viewBoxes, dedupes by cleaned content, and rewritesicons.ts.
The cleaner asserts on anything unexpected — a <g transform>, a colour it could not normalize, an empty icon — so a bad export fails loudly instead of shipping a broken glyph. Brand marks run with a BRAND=1 flag, because they arrive filled and wrapped in a <defs><clipPath> frame that the cleaner verifies is really just the frame before stripping.
Two failure modes are handled rather than papered over. The source file mislabels some glyphs — duplicate data-names on numbered variants, a checkmark labelled message-square-plus, icons named Component — and the fix is a node-id-keyed name override in the generator, never a hand edit to icons.ts. And a cross-file name clash is reported as a collision and skipped, not overwritten; the brand file’s youtube was dropped because the UI set already owned the name.
Re-running is idempotent: identical glyphs dedupe by content, so re-imports never double-add.
Adding one icon by hand
If you only need one glyph and do not want to run the generator, add it to ICONS in the same shape — the inner markup of a 24×24 icon, colour normalized to currentColor, no ids:
"my-icon": '<path d="…" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
The IconName union derives from the object, so it becomes typed and autocompletable immediately. Just be aware that the next generator run rewrites the file — which is the reason the README says not to hand-edit it, and the reason a name override in the generator is the durable fix.
Vendor marks are not here
The sixty integration logos and the SSO lockups live separately, in src/components/svg/logos/. They are not general-purpose icons — they are content belonging to the integrations directory, drawn at their own sizes with their own crops. See Integrations Directory for how they were extracted.