Icons in Qwik
Most icons never change after the page loads. In Qwik they can be plain SVG written on the server, with no component code sent to the browser. For the few that morph or react, the <glyph-fox> element handles the interaction by itself.
Install
Install the core package. The server side approach uses glyphfox/core and one module per icon from glyphfox/icons/NAME; the element comes from glyphfox/web. Nothing needs to be configured in Vite or in Qwik.
npm install glyphfox
The examples import from @builder.io/qwik. Qwik 2 moves these imports to @qwik.dev/core; the glyphfox side stays the same.
Icons rendered on the server
renderToString returns the SVG markup for icon data and the shared props. Qwik inserts raw markup with dangerouslySetInnerHTML, which in Qwik takes the string itself, not an object as in React.
import type { IconData, SvgOptions } from 'glyphfox'; import { renderToString } from 'glyphfox/core'; type Props = SvgOptions & { icon: IconData; class?: string; }; export const Icon = ({ icon, class: className, ...options }: Props) => ( <span class={className} style="display:inline-flex;line-height:0" dangerouslySetInnerHTML={renderToString(icon, options)} /> );
Icon is a plain function, an inline component in Qwik terms. It renders as part of its parent and has no lazy loading boundary of its own. On the server the parent writes it into the HTML, and as long as the parent does not render again in the browser, no code for it is ever downloaded.
import { component$, Slot } from '@builder.io/qwik'; import home from 'glyphfox/icons/home'; import settings from 'glyphfox/icons/settings'; import bookOpen from 'glyphfox/icons/book-open'; import { Icon } from '~/components/icon/icon'; export default component$(() => ( <div class="shell"> <nav class="sidebar"> <a href="/"><Icon icon={home} size={20} /> Home</a> <a href="/docs/"><Icon icon={bookOpen} size={20} /> Docs</a> <a href="/settings/"><Icon icon={settings} size={20} weight={1.5} /> Settings</a> </nav> <main> <Slot /> </main> </div> ));
If a component renders again on the client, for example because it reads a signal that changes, Qwik downloads its render function and the modules it imports, including the renderer from glyphfox/core. Keep icons in components that render once, and move the parts that change into their own components.
Motion without JavaScript
The markup from renderToString carries its own CSS. With animate it plays a preset, and with morphTo and trigger: 'hover' it morphs while the pointer is over the svg or its direct parent. Neither needs a script, and both stop under prefers-reduced-motion.
import arrowRight from 'glyphfox/icons/arrow-right'; import chevronRight from 'glyphfox/icons/chevron-right'; import loader from 'glyphfox/icons/loader'; import { Icon } from '~/components/icon/icon'; export const Next = () => ( <a href="/next/" class="next"> Next <Icon icon={arrowRight} morphTo={chevronRight} trigger="hover" /> </a> ); export const Saving = () => <Icon icon={loader} animate="spin" title="Saving" />;
With the core, morphTo takes icon data, not a name: import the target icon as above. A string is ignored and the icon stays still.
For a state such as open or selected, render with trigger: 'state': the svg then morphs when it gets the class gf-on. Toggling a class needs no re-render, so the only code the browser downloads is the click handler, and only on the first click.
import { component$ } from '@builder.io/qwik'; import menu from 'glyphfox/icons/menu'; import x from 'glyphfox/icons/x'; import { Icon } from '~/components/icon/icon'; export const MenuToggle = component$(() => ( <button type="button" aria-label="Menu" aria-expanded="false" onClick$={(_, button) => { const open = button.getAttribute('aria-expanded') !== 'true'; button.setAttribute('aria-expanded', String(open)); button.querySelector('svg')?.classList.toggle('gf-on', open); }} > <Icon icon={menu} morphTo={x} trigger="state" /> </button> ));
Interactive icons with the element
When an icon has to switch between names, morph on every change, or react to clicks on its own, use the <glyph-fox> element. Load it from a visible task in the component that uses it, so the browser fetches it only when such an icon is on screen.
import { component$, useVisibleTask$ } from '@builder.io/qwik'; export const LikeButton = component$(() => { // eslint-disable-next-line qwik/no-use-visible-task useVisibleTask$(() => { import('glyphfox/web'); }); return ( <button type="button" class="like" aria-label="Like"> <glyph-fox name="heart" morph-to="heart/solid" trigger="click"></glyph-fox> </button> ); });
Write the props of the element as string attributes, as in HTML: name, size, morph, morph-to, trigger. With a click or hover trigger the element keeps its own state, so the component never renders again and Qwik has nothing to resume for it.
glyphfox/web carries every icon. To load less, import a module of your own from the task that registers a subset with defineGlyphFox and registerIcons, as in the web components guide. If your TypeScript setup rejects the unknown tag, declare glyph-fox among the JSX intrinsic elements in a declaration file.
The element draws after it upgrades, so the server HTML holds an empty tag. Reserve its size with CSS so the layout does not move:
glyph-fox:not(:defined) { display: inline-block; width: 24px; height: 24px; }
Qwik City
Layouts and routes render on the server, so the Icon component works in src/routes/layout.tsx, in pages and in every component they render. Static site generation writes the same markup into HTML files.
When icon names come from data, resolve them on the server. In a routeLoader$, toSvg from glyphfox accepts any name and the full name syntax; it carries every icon, which is fine in server only code.
import { component$ } from '@builder.io/qwik'; import { routeLoader$ } from '@builder.io/qwik-city'; import { toSvg } from 'glyphfox'; const FEATURES = [ { icon: 'shield-check', label: 'Audited' }, { icon: 'cloud&lock:overlap', label: 'Encrypted backups' }, { icon: 'file+check@tr', label: 'Signed releases' }, ]; export const useFeatures = routeLoader$(() => FEATURES.map((f) => ({ ...f, svg: toSvg(f.icon, { size: 28, weight: 1.75 }) })), ); export default component$(() => { const features = useFeatures(); return ( <ul class="features"> {features.value.map((f) => ( <li key={f.label}> <span dangerouslySetInnerHTML={f.svg} /> {f.label} </li> ))} </ul> ); });
To have the element on every page instead, add the single file CDN build to the head in src/root.tsx with defer, as described in HTML and CDN. That trades a 62 KB gzip download on each page for not tracking where the element is used.
Props
The Icon component forwards its props to renderToString, which takes the shared options with their camelCase names, plus class and style strings for the svg. On the element the same props are attributes in kebab-case.
| 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
- dangerouslySetInnerHTML takes a string
- Pass the markup itself. The React form with
__htmldoes not work in Qwik. - Parents that render again
- An icon inside a component that renders again in the browser brings
glyphfox/corewith it, about 37 KB minified. Keep stateful parts in small components of their own. - Names in morphTo
- The core needs icon data for
morphTo. A string is ignored, and the icon does not morph. - Hover reaches only the direct parent
- The CSS hover morph reacts to the svg and its direct parent, which is the wrapper span. Hovering a larger button around it does not start the morph; for that, toggle
gf-onwithtrigger: 'state'. - toSvg in client code
import { toSvg } from 'glyphfox'carries every icon. It belongs in server only code such asrouteLoader$; in components that may run in the browser, use the core with per icon modules.- The element before it upgrades
- Server HTML holds an empty
<glyph-fox>. Reserve its box with CSS, or render icons that must be visible at once with theIconcomponent.
Questions
Do server rendered icons really ship no JavaScript?
Yes, as long as the component that renders them does not render again in the browser. The HTML holds plain SVG, and presets and hover morphs are CSS inside that SVG. Only components that render again, and the <glyph-fox> element, bring code to the browser.
Can I use the React components through qwikify$?
They behave like any React component inside qwikify$, but that island then ships React and the glyphfox React runtime. For icons, server rendered markup or the element costs far less.