Icons in SvelteKit
glyphfox/svelte renders on the server, so SvelteKit pages ship real SVG in the HTML, whether a page is rendered per request or prerendered at build time.
Install
npm install glyphfox
There is nothing to configure. glyphfox/svelte is exposed under the svelte export condition, so vite-plugin-svelte, which every SvelteKit project uses, recognizes it as a Svelte library and compiles its components for the server and the browser. The components need Svelte 5; the Svelte guide covers the components and props in detail.
Register icon names in the root layout
Components such as HomeIcon register their own data. Names that only appear in strings, in Icon, Fusion, MorphIcon or morphTo, need registerIcons, and the best place is a module the root layout imports: it runs on the server before the first render and in the browser before hydration.
import { registerIcons } from 'glyphfox/svelte'; import menu from 'glyphfox/icons/menu'; import x from 'glyphfox/icons/x'; import moon from 'glyphfox/icons/moon'; import file from 'glyphfox/icons/file'; registerIcons([menu, x, moon, file]);
<script> import '$lib/icons.js'; import { MorphIcon } from 'glyphfox/svelte'; let { children } = $props(); let open = $state(false); </script> <header> <button type="button" aria-label="Menu" aria-expanded={open} onclick={() => (open = !open)}> <MorphIcon name={open ? 'x' : 'menu'} /> </button> </header> {@render children()}
The registry is module state. On the server it is shared by every request, which is safe because icon data never changes, and it is filled once when the module first loads.
What the server renders
Icon components produce their full svg during server rendering. Living state animations are a stylesheet inside the svg, so a spinner already turns in the server HTML unless the visitor prefers reduced motion. Morphs and the click and hover triggers start after hydration, because $effect does not run on the server.
For a morph whose end depends on data, use trigger="state" with active. The server renders the right end of the morph, and the animation only plays when the value changes in the browser. With the default trigger (mount) the server renders the starting icon and the morph plays on every page load.
export function load({ cookies }) { return { theme: cookies.get('theme') ?? 'light' }; }
<script> import { SunIcon } from 'glyphfox/svelte'; let { theme } = $props(); let dark = $state(theme === 'dark'); function toggle() { dark = !dark; document.cookie = `theme=${dark ? 'dark' : 'light'}; path=/; max-age=31536000`; } </script> <button type="button" aria-label="Dark theme" aria-pressed={dark} onclick={toggle}> <SunIcon morphTo="moon" trigger="state" active={dark} /> </button>
Reading the theme from a cookie in a server load function gives the server and the browser the same starting value. Render it from the root layout with <ThemeToggle theme={data.theme} />; moon is registered in src/lib/icons.js.
SVG files from an endpoint
toSvg from the main glyphfox entry renders any spec to a string, which makes a +server.js route that serves icons as files: a favicon, an image for an email template, or a link preview. With entries, the files are prerendered at build time.
import { error } from '@sveltejs/kit'; import { iconNames, toSvg } from 'glyphfox'; export const prerender = true; export function entries() { return ['fox', 'home', 'search'].map((name) => ({ name })); } export function GET({ params }) { if (!iconNames.includes(params.name)) error(404, 'Unknown icon'); const svg = toSvg(params.name, { size: 32, color: '#ff7a1a', variant: 'duotone' }); return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } }); }
<link rel="icon" href="/icons/fox.svg" type="image/svg+xml" />
A standalone SVG file has no parent to inherit a color from, so pass color: it is written on the svg and the currentColor strokes pick it up. Return iconNames.map((name) => ({ name })) from entries to prerender every icon.
The main entry includes the data of every icon. That is fine in +server.js and +page.server.js files, which never reach the browser; imported into a component, toSvg would ship the whole set to the client.
Server only markup with {@html}
A load function in +page.server.js can return rendered strings that the page inserts with {@html}. The browser receives the markup in the HTML and in the serialized page data, but no icon code.
import { toSvg } from 'glyphfox'; const features = [ { icon: 'zap', title: 'Fast' }, { icon: 'shield-check', title: 'Safe' }, { icon: 'accessibility', title: 'Accessible' }, ]; export function load() { return { features: features.map((f) => ({ ...f, svg: toSvg(f.icon, { size: 28, weight: 1.5 }) })), }; }
<script> let { data } = $props(); </script> <ul class="features"> {#each data.features as feature (feature.icon)} <li>{@html feature.svg} {feature.title}</li> {/each} </ul>
{@html} is safe here because the strings come from your own server code, never from user input. Without a title the icons are rendered with aria-hidden, which is right next to a visible label.
adapter-static and prerendering
With adapter-static, or export const prerender = true on any route, pages are rendered at build time and the icons are written into the HTML files. The rules of server rendering apply at build time too: a name the build cannot resolve is written as an empty svg.
import adapter from '@sveltejs/adapter-static'; export default { kit: { adapter: adapter() }, };
export const prerender = true;
For pages that need no JavaScript, add export const csr = false: the icons stay static SVG and no component code is sent. CSS animations still play, while morphs and click or hover triggers, which need JavaScript, do nothing.
Names in production builds
In development Vite pre-bundles the package, so any name seems to work. In a production build Vite does not bundle the on demand loader that glyphfox keeps in node_modules, because it skips dynamic imports with variables in dependencies, and unregistered names stay empty. Register them, or let Vite bundle the loader, which then emits one small chunk per icon:
import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [sveltekit()], build: { dynamicImportVarsOptions: { exclude: [/node_modules\/(?!glyphfox\/)/] }, }, });
Those chunks only help in the browser after hydration. The server render does not wait for them, so registration remains the way to get an icon into the HTML.
Props
SvelteKit uses the components of glyphfox/svelte, so the props are the shared ones, in camelCase.
| Prop | 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
- Registering in onMount or in browser only code
- Code in
onMount, in$effector behindif (browser)runs after the server has rendered. The HTML then has empty icons that pop in after hydration. Register in a module that the root layout imports. - A different icon on the server and in the browser
- The svg body is inserted with
{@html}, and Svelte keeps the server markup of an{@html}block when the first client render differs (it warns withhydration_html_changedin development). An icon chosen fromlocalStorageorwindowcan therefore stay wrong until the next change. Derive the first value from data the server also has, such as a cookie read in a load function. - toSvg in a shared component
- Importing
toSvgfromglyphfoxin a.sveltefile pulls the data of every icon into the client bundle. Keep it in server files, or use the components. - A prerendered morph in the wrong state
- With the mount trigger, prerendered HTML shows the starting icon and the morph plays after hydration. Use
trigger="state"for anything that should be prerendered in its end state.
Questions
Can I prerender every icon as an SVG file?
Yes. Return iconNames.map((name) => ({ name })) from entries in the endpoint above, and the build writes one file per icon under /icons/. Each file is a few hundred bytes.
Do icons work on pages with csr = false?
Yes. The server writes the svg into the HTML, and living state animations still play because they are CSS inside the svg. Morphs and the click and hover triggers need JavaScript, so they stay on their first frame.