Icons in Lit and web components
glyphfox ships a custom element, <glyph-fox>, that takes the icon name and every shared prop as attributes. It works anywhere HTML works and fits Lit templates without a wrapper.
Install
Install the package and register the element once, in the entry module of your app. Importing glyphfox/web registers every icon and defines <glyph-fox> as a side effect, so nothing else is needed.
npm install glyphfox
import 'glyphfox/web';
To ship only the icons you use, take the element from glyphfox/web/element, register icon data from glyphfox/icons/NAME and define the element yourself. registerIcons comes from glyphfox/core, which shares its registry with the element.
import { defineGlyphFox } from 'glyphfox/web/element'; import { registerIcons } from 'glyphfox/core'; import menu from 'glyphfox/icons/menu'; import x from 'glyphfox/icons/x'; import bell from 'glyphfox/icons/bell'; import search from 'glyphfox/icons/search'; registerIcons([menu, x, bell, search]); defineGlyphFox();
| Entry point | What it ships | Size (esbuild, minified) |
|---|---|---|
glyphfox/web | The element, the engine and every icon, registered on import. | about 209 KB, 59 KB gzip |
glyphfox/web/element | The element and the engine; you register the icons. | about 77 KB, 28 KB gzip with one icon, plus a few hundred bytes per icon |
A name you did not register is loaded on demand. webpack and esbuild turn those loads into one small chunk per icon, but a Vite production build leaves the import inside node_modules as written and the icon stays empty (see the tree shaking guide). Register every icon you use with glyphfox/web/element; they then also draw on the first frame, without a network round trip.
Use the element
The element renders an inline SVG into its shadow root. name takes the full name syntax, so modifier badges, fusions and styles need no extra API, and every shared prop is an attribute in kebab-case.
<glyph-fox name="home"></glyph-fox> <glyph-fox name="file+check" size="32" weight="1.5"></glyph-fox> <glyph-fox name="cloud&lock" layout="overlap"></glyph-fox> <glyph-fox name="heart/solid" color="#e11d48"></glyph-fox> <glyph-fox name="bell" animate="shake" trigger="hover"></glyph-fox>
The element observes its attributes. Change one with setAttribute, or through any template that writes attributes, and the icon redraws with the new value.
Lit templates
In Lit, bind values with attribute bindings. The element reads attributes and has no matching JavaScript properties, so a property binding such as .name=${...} sets a field the element never reads and the icon stays as it was.
import { LitElement, html, css } from 'lit'; import 'glyphfox/web'; export class AppToolbar extends LitElement { static properties = { open: { type: Boolean }, unread: { type: Number }, }; static styles = css` button { display: inline-flex; align-items: center; gap: 8px; color: inherit; } glyph-fox { color: var(--toolbar-icon, currentColor); } `; constructor() { super(); this.open = false; this.unread = 0; } render() { return html` <button type="button" aria-expanded=${this.open} @click=${() => (this.open = !this.open)}> <glyph-fox name=${this.open ? 'x' : 'menu'} morph duration="300"></glyph-fox> <span>Menu</span> </button> <glyph-fox name="bell" animate="shake" trigger="state" ?active=${this.unread > 0} title=${this.unread ? `${this.unread} new notifications` : 'No new notifications'} ></glyph-fox> `; } } customElements.define('app-toolbar', AppToolbar);
Use boolean bindings for flags: ?morph=${...} and ?active=${...}. The element reads an empty attribute or "true" as true, and "false" or a missing attribute as false, so a boolean binding maps one to one.
For optional values, bind nothing (or use the ifDefined directive) so the attribute is removed when there is no value. An empty size attribute gives the inner svg an empty width and height, and the browser falls back to its default svg size.
import { html, nothing } from 'lit'; export const iconButton = ({ name, size, label, onClick }) => html` <button type="button" aria-label=${label} @click=${onClick}> <glyph-fox name=${name} size=${size ?? nothing}></glyph-fox> </button> `;
Styling through the shadow root
The svg lives in the element shadow root with its strokes in currentColor. Color inherits across the shadow boundary, so set color on the element or on any ancestor. The host is display: inline-flex with line-height: 0 and vertical-align: middle, so it sits on a line of text without extra CSS.
Size comes from the size attribute, which sets the width and height of the inner svg. CSS width and height on the host do not scale the drawing. Use size="1em" to follow the font size, or write the attribute from your own design tokens.
<p class="note"> <glyph-fox name="circle-info" size="1em" weight="2.5"></glyph-fox> The icon follows the font size of this paragraph. </p> <glyph-fox name="folder" variant="duotone" color="#0f172a" secondary-color="#ff7a1a"></glyph-fox>
.toolbar glyph-fox { color: #6b7280; } .toolbar button:hover glyph-fox { color: #111827; }
There are no parts or slots to style: everything inside is generated from the props. Change the drawing with attributes such as weight, corners, variant and secondary-color, not with selectors that try to reach into the shadow root.
Your own tag name
defineGlyphFox(tag) defines the element under another name and returns its class. Use it to match the prefix of your design system or to avoid a clash with another element. It is safe to call more than once: a tag that is already defined is returned as it is.
import { defineGlyphFox } from 'glyphfox/web/element'; import { registerIcons } from 'glyphfox/core'; import home from 'glyphfox/icons/home'; import settings from 'glyphfox/icons/settings'; registerIcons([home, settings]); defineGlyphFox('acme-icon');
<acme-icon name="home" size="20"></acme-icon> <acme-icon name="settings" size="20" weight="1.5"></acme-icon>
Importing glyphfox/web also defines glyph-fox, so pick one entry point per app: glyphfox/web for the default tag with every icon, glyphfox/web/element when you choose the tag and the icon list.
Triggers and morphs
The element handles its own interaction. With trigger="hover" or trigger="click" it listens on itself, and with trigger="state" it follows the active attribute. The morph attribute animates every change of name, while morph-to morphs toward a second icon whenever the trigger fires.
<glyph-fox name="menu" morph-to="x" trigger="click"></glyph-fox> <glyph-fox name="sparkles" animate="draw" trigger="hover"></glyph-fox> <glyph-fox id="visibility" name="eye" morph></glyph-fox> <script type="module"> const eye = document.getElementById('visibility'); eye.addEventListener('click', () => { eye.setAttribute('name', eye.getAttribute('name') === 'eye' ? 'eye-off' : 'eye'); }); </script>
Hover and click are measured on the element itself. The animation styles live inside the shadow root, where they cannot see the parent, and the click listener sits on the host. When a whole button should react, drive the icon from the button: use trigger="state" and set active from the button handlers.
<button type="button" class="save"> <glyph-fox name="save" animate="pop" trigger="state"></glyph-fox> Save </button> <script type="module"> const button = document.querySelector('.save'); const icon = button.querySelector('glyph-fox'); button.addEventListener('pointerenter', () => icon.setAttribute('active', '')); button.addEventListener('pointerleave', () => icon.removeAttribute('active')); </script>
Morphs and presets respect prefers-reduced-motion: transitions jump to the end and animations do not play. The presets and triggers are described in animated icons.
Static SVG inside Lit templates
When an icon never changes after render, you can skip the element and insert the SVG markup. renderToString from glyphfox/core renders icon data with the shared props, and Lit inserts the string with the unsafeHTML directive. The renderer escapes titles and attribute values, so the markup is safe to insert.
import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { renderToString } from 'glyphfox/core'; import circleCheck from 'glyphfox/icons/circle-check'; const check = unsafeHTML(renderToString(circleCheck, { size: 16, color: '#16a34a' })); export const savedBadge = () => html`<span class="badge">${check} Saved</span>`;
For the name syntax (file+check@tr, cloud&lock), use toSvg from glyphfox. It resolves any name, which means it carries every icon; see tree shaking before you use it in a client bundle.
Attributes
Every shared prop is an attribute in kebab-case. The element adds four of its own: name for the icon, with the full name syntax; layout and gap for fusions; and morph, which animates changes of name. Numbers are read from their text, and booleans follow the rule above.
| Attribute | Type | Default | Description |
|---|---|---|---|
name | string | none | The icon spec: a name, name+modifier@corner, a&b:layout or name/variant. |
layout | overlap, side, stack, grid, orbit, badge, inside | none | Fusion layout when the name joins icons with &. |
gap | number | 1 | Knockout gap of a fusion, in grid units. |
morph | boolean | false | Animates every change of name as a morph. |
size | number, string | 24 | Width and height; numbers are pixels. |
color | string | currentColor | Main color. |
secondary-color | string | color | Color of the duotone layer and of the solid fill. |
secondary-opacity | 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. |
absolute-weight | 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. |
flip-x | boolean | false | Mirror horizontally. |
flip-y | boolean | false | Mirror vertically. |
mirror-rtl | 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. |
modifier-corner | top-left, top-right, bottom-left, bottom-right | bottom-right | Corner of the badge. |
morph-to | 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. |
Accessibility
Without a label the icon is decorative: the inner svg gets aria-hidden="true". That is right for an icon next to visible text, and for an icon button that carries its own aria-label.
To give the icon itself a name, use the title attribute. It adds a <title>, role="img" and aria-label to the inner svg. Do not rely on aria-label on the element: the label stays on the host, which has no role, while the svg inside becomes an image without a name. title on the host is also the global HTML attribute, so browsers show it as a tooltip.
<!-- decorative: the button carries the name --> <button type="button" aria-label="Close dialog"> <glyph-fox name="x"></glyph-fox> </button> <!-- meaningful on its own --> <glyph-fox name="triangle-alert" title="Warning" color="#b45309"></glyph-fox>
Patterns for links, status icons and contrast are in the accessibility guide.
Server rendering and layout shift
The element draws when it upgrades in the browser. Server rendered HTML holds only the empty tag, and the element does not use declarative shadow DOM, so the icon appears after the script runs. Reserve the space so the layout does not move when it does.
glyph-fox:not(:defined) { display: inline-block; width: 24px; height: 24px; vertical-align: middle; } glyph-fox[size="16"]:not(:defined) { width: 16px; height: 16px; }
When an icon has to be in the first paint, in email or in HTML for crawlers, render the SVG string on the server instead. The options for each framework are in server rendering.
Pitfalls
- Self closing tags
- Write
<glyph-fox name="home"></glyph-fox>. HTML ignores the slash in<glyph-fox />, so the element stays open and the content after it becomes its children, which the shadow root does not show. - Property bindings
- Lit
.name=${...}and other property bindings set a JavaScript field that the element does not read. Bind attributes. Solid needsattr:for the same reason, see the Solid guide. - Empty attributes
- An empty
sizerenders an svg with empty width and height. Remove the attribute when there is no value, withnothingorifDefinedin Lit. - Hover and click on the parent
- Hover and click triggers fire on the icon only, not on the button around it. Use
trigger="state"and setactivefrom the parent. - Attribute changes replay mount animations
- The element swaps its inner svg whenever the markup changes, so with an
animatepreset and the defaulttrigger="mount", changingcolororsizeplays the animation again. Usetrigger="state"for icons whose other attributes change often. - An empty icon
- An unknown name renders nothing and logs a
glyphfox:warning in the console. A valid name that is not registered draws as soon as its module loads, so check the network panel before you look for a typo.
Questions
Can I use the element in React, Vue or Angular?
Yes, it is a standard custom element. The framework packages are the better fit there, though: glyphfox/react, glyphfox/vue and glyphfox-angular give typed props and render on the server. Keep the element for Lit, plain HTML and pages that mix stacks. In Vue templates, tell the compiler that glyph-fox is a custom element with compilerOptions.isCustomElement, otherwise Vue tries to resolve it as a component.