Icons in Astro
Astro renders components to HTML on the server or at build time, which suits SVG icons: call the renderer in the frontmatter and the page receives plain inline SVG with no client JavaScript. Islands and the custom element cover the icons that need to react.
Install
Install the package. Static icons need no Astro integration, because glyphfox is plain ES modules that run in the frontmatter. Islands built with glyphfox/react, glyphfox/vue or glyphfox/svelte need the matching Astro integration.
npm install glyphfox # only for islands npx astro add react
Icons in the frontmatter
Code in the frontmatter runs on the server, or at build time for static pages. toSvg returns the SVG string for any icon name, and set:html writes it into the page without escaping it:
--- import { toSvg } from 'glyphfox'; const search = toSvg('search', { size: 20 }); --- <label class="search"> <Fragment set:html={search} /> <input type="search" name="q" aria-label="Search the docs" /> </label>
toSvg takes the full name syntax, so badges, fusions and styles fit in one string: toSvg('file+check@tr'), toSvg('cloud&lock:overlap'), toSvg('heart/solid').
Importing glyphfox loads every icon, but only on the server or in the build. None of it reaches the browser, because the frontmatter never runs there.
An Icon.astro component
Wrap the call in a component so pages pass props instead of options. The SVG goes straight into the parent, without a wrapper element:
--- import { toSvg, type SvgOptions } from 'glyphfox'; interface Props extends SvgOptions { name: string; } const { name, ...options } = Astro.props; const svg = toSvg(name, options); --- <Fragment set:html={svg} />
--- import Icon from './Icon.astro'; --- <nav class="nav"> <a href="/"><Icon name="home" size={20} /> Home</a> <a href="/docs/"><Icon name="book-open" size={20} weight={1.5} /> Docs</a> <a href="/new/"><Icon name="file+plus" size={20} title="New file" /></a> </nav> <style> .nav :global(.glyphfox) { color: #6b7280; } .nav a:hover :global(.glyphfox) { color: #111827; } </style>
Every svg carries the classes glyphfox and glyphfox-NAME. Markup inserted with set:html does not receive the attributes Astro uses to scope component styles, so select it with :global() inside a scoped parent selector, as above, or let it inherit color through currentColor.
The icon link above has no text, so its icon gets a title: that adds role="img", aria-label and a <title>, and the link takes its name from it. Icons next to visible text stay decorative. More patterns are in the accessibility guide.
Motion without JavaScript
The SVG can carry its own CSS, so living states and hover morphs play in static HTML with no script: animate with the default trigger="mount" or with "hover", and morphTo with trigger="hover", which reacts to the svg and to its direct parent.
--- import Icon from './Icon.astro'; --- <a class="next" href="/chapter-2/"> Next chapter <Icon name="arrow-right" morphTo="chevron-right" trigger="hover" /> </a> <p class="status"> <Icon name="loader" animate="spin" title="Building" /> Building the preview </p>
Because Icon.astro adds no wrapper, the svg is a direct child of the link, and hovering anywhere on the link morphs the arrow. Every preset stops under prefers-reduced-motion; the presets and triggers are listed in animated icons.
Islands for state
Icons that follow state, such as a menu button that morphs into a close icon, belong in an island. Use the framework package of the island and hydrate it with a client directive:
import { useState } from 'react'; import { MorphIcon, registerIcons } from 'glyphfox/react'; import menu from 'glyphfox/icons/menu'; import x from 'glyphfox/icons/x'; // Names passed to MorphIcon load on demand; registering them // puts the icon in the server rendered HTML. registerIcons([menu, x]); export default function MenuButton() { const [open, setOpen] = useState(false); return ( <button type="button" aria-expanded={open} aria-label={open ? 'Close menu' : 'Open menu'} onClick={() => setOpen(!open)} > <MorphIcon name={open ? 'x' : 'menu'} duration={300} /> </button> ); }
--- import MenuButton from '../components/MenuButton.jsx'; --- <header> <MenuButton client:load /> </header>
Astro renders islands on the server first. Icon components such as MenuIcon register their icon when your code uses them, but names passed to MorphIcon, Icon or morphTo are loaded on demand, so without registerIcons the server HTML holds an empty svg until the island hydrates.
The same works with glyphfox/vue and with glyphfox/svelte, which needs Svelte 5. For islands below the fold, client:visible or client:idle delays the download until it is needed. The framework details are in the React, Vue and Svelte guides.
The custom element
Without a framework, the <glyph-fox> element covers clicks, hover and name changes. Astro processes and bundles the <script> tags of components, and includes each script once per page, even when the component appears many times:
<button type="button" class="like" aria-pressed="false" aria-label="Like"> <glyph-fox name="heart" morph-to="heart/solid" trigger="state"></glyph-fox> </button> <script> import { defineGlyphFox } from 'glyphfox/web/element'; import { registerIcons } from 'glyphfox/core'; import heart from 'glyphfox/icons/heart'; registerIcons([heart]); defineGlyphFox(); for (const button of document.querySelectorAll('button.like')) { const icon = button.querySelector('glyph-fox'); button.addEventListener('click', () => { const on = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(on)); icon.toggleAttribute('active', on); }); } </script>
The button drives the icon through trigger="state" and the active attribute. With trigger="click" the element would listen on itself, and a click on the padding of the button would toggle aria-pressed without morphing the icon.
Custom elements stay defined across client side navigation with the Astro view transitions router, so icons on new pages upgrade on their own. Code that attaches listeners, like the loop above, runs once; with the router, run it again on the astro:page-load event.
Tailwind classes
With Tailwind CSS in the project, the glyphfox plugin draws icons as CSS masks, another option with no JavaScript, handy in content heavy templates. Setup and class syntax are in the Tailwind guide.
<h1 class="flex items-center gap-3"> <span class="gf-rocket text-4xl text-orange-500" aria-hidden="true"></span> Launch faster </h1>
Props
Icon.astro passes its props to toSvg, so it takes the shared options with their camelCase names, plus class and style as strings for the svg. Unlike in the core renderer, morphTo can be a name here, because toSvg resolves it.
| Option | Type | Default | Description |
|---|---|---|---|
size | number, string | 24 | Width and height; numbers are pixels. |
color | string | currentColor | Main color. |
secondaryColor | string | color | Color of the duotone layer and of the solid fill. |
secondaryOpacity | number | 0.3 / 1 | Opacity of the duotone layer or of the solid fill. |
weight | number | 2 | Stroke from 1 to 3 on the 24 grid, optically compensated below 24 px. |
absoluteWeight | boolean | false | Keeps the stroke at weight pixels at any size. |
corners | round, sharp | round | Round or sharp joins and caps. |
variant | outline, duotone, solid | outline | Style axis; solid comes from the closed shapes. |
rotate | number | 0 | Rotation in degrees. |
flipX | boolean | false | Mirror horizontally. |
flipY | boolean | false | Mirror vertically. |
mirrorRtl | boolean | false | Mirror in right to left layouts. |
animate | draw, pop, wiggle, spin, pulse, bounce, shake | none | Living state preset. |
trigger | mount, hover, click, state | mount | When the animation or the morph plays. |
active | boolean | false | The state for trigger state. |
duration | number (ms) | per preset | Animation or morph duration. |
delay | number (ms) | 0 | Animation or morph delay. |
loop | boolean, number | false | Repeat forever or a number of times (spin and pulse loop by default). |
modifier | plus, minus, check, x, alert, lock, clock, sync, arrow-up, arrow-down, dot | none | Badge with an automatic knockout gap. |
modifierCorner | top-left, top-right, bottom-left, bottom-right | bottom-right | Corner of the badge. |
morphTo | name, data | none | Morph target; the trigger decides when it shows. |
title | string | none | Accessible name: title, role img and aria-label. |
decorative | boolean | true without a title | Hides the icon from assistive technology. |
Pitfalls
- Scoped styles and set:html
- Rules in a component style block do not match markup inserted with
set:html, because it lacks the scoping attributes. Use:global(.glyphfox)inside a scoped selector, or inheritcolor. - Names in islands
MorphIcon,IconandmorphToload names on demand. Register their data withregisterIconsso the server HTML is complete.- Click triggers inside buttons
trigger="click"onglyph-foxlistens on the icon, and clicks on the padding of the button do not reach it. Usetrigger="state"and setactivefrom the button.- Unknown names stop the build
toSvgthrows for a name that does not exist, which fails a static build and returns an error in server mode. Check names from content against the icon list, or catch the error where they come from user data.- Server bundles on edge runtimes
toSvgfrom the main entry puts every icon in the server bundle, about 154 KB minified. Where function size is limited, userenderToStringfromglyphfox/corewith per icon imports, and pass icon data tomorphTo.- Wrappers and hover morphs
- A hover morph reacts to the svg and to its direct parent. Wrapping the icon in an extra element makes that element the parent, so hovering the link around it no longer starts the morph.
Questions
Do icons rendered in the frontmatter ship any JavaScript?
No. The frontmatter runs on the server or at build time, and the page receives plain SVG. Presets and hover morphs are CSS inside that SVG. Only islands and the custom element add client code.
Can I use icons in Markdown or MDX content?
In MDX, import Icon.astro and use it like in any component. Plain Markdown cannot import components, so render the icons in the layout around the content, or use the Tailwind classes in raw HTML where your Markdown allows it.